Skip to content
All essays
ArchitectureMarch 18, 202514 min

GraphQL Rate Limiting: Protect Your API from Abuse

Implement robust rate limiting for GraphQL APIs. Prevent abuse, ensure fair usage, and maintain performance.

Ü
Ümit Uz
Mobile & Full Stack Developer

Understanding Rate Limiting in GraphQL

Rate limiting is crucial for protecting your GraphQL API from abuse, ensuring fair resource distribution, and maintaining optimal performance for all users. Unlike REST APIs with fixed endpoints, GraphQL's flexible query structure presents unique challenges for rate limiting.

The GraphQL Rate Limiting Challenge

typescript
// REST API - Simple rate limiting
GET /api/users/1
GET /api/posts/1
// Each endpoint counts as 1 request

// GraphQL - Complex rate limiting needed
query GetUserAndPosts {
  user(id: "1") {
    name
    email
    posts {
      title
      comments {
        author {
          name
        }
      }
    }
  }
}
// Is this 1 request or potentially 100+ database queries?

Rate Limiting Strategies

1. Query Complexity Analysis

Calculate complexity based on query depth and field count.

typescript
import { parse, visit } from 'graphql';

interface ComplexityResult {
  complexity: number;
  depth: number;
}

function calculateQueryComplexity(
  query: string,
  schema: GraphQLSchema
): ComplexityResult {
  const document = parse(query);
  let complexity = 0;
  let depth = 0;

  visit(document, {
    Field(node, key, parent, path, ancestors) {
      // Base complexity for each field
      complexity += 1;

      // Calculate depth
      const currentDepth = ancestors.filter(
        (node) => node.kind === 'Field'
      ).length;
      depth = Math.max(depth, currentDepth);

      // Multipliers for nested fields
      if (node.name.value === 'posts') {
        complexity += 10; // Posts are expensive
      }

      if (node.name.value === 'comments') {
        complexity += 5; // Comments are moderately expensive
      }
    },
  });

  return { complexity, depth };
}

// Usage
const query = `
  query GetUser {
    user(id: "1") {
      name
      posts {
        title
        comments {
          author {
            name
          }
        }
      }
    }
  }
`;

const { complexity, depth } = calculateQueryComplexity(query, schema);

console.log(`Complexity: ${complexity}, Depth: ${depth}`);
// Complexity: 20, Depth: 4

2. Request Rate Limiting

Limit requests per time window per user/IP.

typescript
import Redis from 'ioredis';

interface RateLimitConfig {
  windowMs: number;
  maxRequests: number;
}

class RateLimiter {
  private redis: Redis;

  constructor() {
    this.redis = new Redis({
      host: process.env.REDIS_HOST,
      port: 6379,
    });
  }

  async checkRateLimit(
    identifier: string,
    config: RateLimitConfig
  ): Promise<{ allowed: boolean; remaining: number }> {
    const key = `ratelimit:${identifier}`;
    const now = Date.now();
    const windowStart = now - config.windowMs;

    // Remove old entries
    await this.redis.zremrangebyscore(key, 0, windowStart);

    // Count requests in current window
    const count = await this.redis.zcard(key);

    if (count >= config.maxRequests) {
      const ttl = await this.redis.pttl(key);
      return {
        allowed: false,
        remaining: 0,
      };
    }

    // Add current request
    await this.redis.zadd(key, now, `${now}-${Math.random()}`);
    await this.redis.expire(key, Math.ceil(config.windowMs / 1000));

    return {
      allowed: true,
      remaining: config.maxRequests - count - 1,
    };
  }

  async resetLimit(identifier: string): Promise<void> {
    await this.redis.del(`ratelimit:${identifier}`);
  }
}

// Usage in GraphQL context
const rateLimiter = new RateLimiter();

const server = new ApolloServer({
  schema,
  context: async ({ req }) => {
    const identifier = req.headers['x-user-id'] || req.ip;

    const result = await rateLimiter.checkRateLimit(identifier, {
      windowMs: 60000, // 1 minute
      maxRequests: 100,
    });

    if (!result.allowed) {
      throw new Error('Rate limit exceeded. Please try again later.');
    }

    return {
      rateLimitRemaining: result.remaining,
    };
  },
});

3. Field-Level Rate Limiting

Limit expensive fields individually.

typescript
import { GraphQLField } from 'graphql';

interface FieldLimitConfig {
  maxCallsPerMinute: number;
  costMultiplier: number;
}

class FieldRateLimiter {
  private fieldLimits = new Map<string, FieldLimitConfig>();
  private usageTracker = new Map<string, number[]>();

  constructor() {
    // Configure expensive fields
    this.fieldLimits.set('Query.expensiveOperation', {
      maxCallsPerMinute: 10,
      costMultiplier: 5,
    });

    this.fieldLimits.set('Mutation.bulkImport', {
      maxCallsPerMinute: 5,
      costMultiplier: 10,
    });
  }

  async checkFieldLimit(
    fieldPath: string,
    identifier: string
  ): Promise<boolean> {
    const config = this.fieldLimits.get(fieldPath);
    if (!config) return true;

    const key = `${fieldPath}:${identifier}`;
    const now = Date.now();
    const windowStart = now - 60000; // 1 minute

    // Get usage history
    let usage = this.usageTracker.get(key) || [];
    usage = usage.filter((timestamp) => timestamp > windowStart);

    if (usage.length >= config.maxCallsPerMinute) {
      return false;
    }

    usage.push(now);
    this.usageTracker.set(key, usage);

    return true;
  }

  getFieldCost(fieldPath: string): number {
    const config = this.fieldLimits.get(fieldPath);
    return config?.costMultiplier || 1;
  }
}

// Apply to resolvers
const fieldRateLimiter = new FieldRateLimiter();

const resolvers = {
  Query: {
    expensiveOperation: async (_: unknown, args: unknown, context: any) => {
      const { userId } = context;

      const allowed = await fieldRateLimiter.checkFieldLimit(
        'Query.expensiveOperation',
        userId
      );

      if (!allowed) {
        throw new Error(
          'Rate limit exceeded for expensiveOperation. ' +
          'Maximum 10 calls per minute.'
        );
      }

      // Execute operation
      return performExpensiveOperation(args);
    },
  },
};

Implementing GraphQL Rate Limiting

Complete Rate Limiting Middleware

typescript
import { GraphQLSchema, GraphQLFieldNode } from 'graphql';
import { getOperationAST } from 'graphql';

interface RateLimitConfig {
  maxRequestsPerMinute: number;
  maxComplexity: number;
  maxDepth: number;
}

class GraphQLRateLimiter {
  private rateLimiter: RateLimiter;
  private config: RateLimitConfig;

  constructor(config: RateLimitConfig) {
    this.config = config;
    this.rateLimiter = new RateLimiter();
  }

  async validate(
    schema: GraphQLSchema,
    query: string,
    variables: any,
    identifier: string
  ): Promise<{ allowed: boolean; reason?: string }> {
    // Check request rate
    const rateLimitResult = await this.rateLimiter.checkRateLimit(
      identifier,
      {
        windowMs: 60000,
        maxRequests: this.config.maxRequestsPerMinute,
      }
    );

    if (!rateLimitResult.allowed) {
      return {
        allowed: false,
        reason: 'Too many requests. Please try again later.',
      };
    }

    // Calculate query complexity
    const { complexity, depth } = this.calculateComplexity(schema, query);

    if (complexity > this.config.maxComplexity) {
      return {
        allowed: false,
        reason: `Query complexity (${complexity}) exceeds maximum allowed (${this.config.maxComplexity})`,
      };
    }

    if (depth > this.config.maxDepth) {
      return {
        allowed: false,
        reason: `Query depth (${depth}) exceeds maximum allowed (${this.config.maxDepth})`,
      };
    }

    return { allowed: true };
  }

  private calculateComplexity(
    schema: GraphQLSchema,
    query: string
  ): { complexity: number; depth: number } {
    const document = parse(query);
    const operation = getOperationAST(document);

    if (!operation) {
      return { complexity: 0, depth: 0 };
    }

    let complexity = 0;
    let maxDepth = 0;

    const calculateFieldComplexity = (
      node: GraphQLFieldNode,
      depth: number
    ) => {
      maxDepth = Math.max(maxDepth, depth);
      complexity += 1;

      if (node.selectionSet) {
        node.selectionSet.selections.forEach((selection) => {
          if (selection.kind === 'Field') {
            calculateFieldComplexity(selection, depth + 1);
          }
        });
      }
    };

    if (operation.selectionSet) {
      operation.selectionSet.selections.forEach((selection) => {
        if (selection.kind === 'Field') {
          calculateFieldComplexity(selection, 1);
        }
      });
    }

    return { complexity, depth: maxDepth };
  }
}

// Usage with Apollo Server
const rateLimiter = new GraphQLRateLimiter({
  maxRequestsPerMinute: 100,
  maxComplexity: 1000,
  maxDepth: 10,
});

const server = new ApolloServer({
  schema,
  context: async ({ req }) => {
    const query = req.body.query;
    const variables = req.body.variables;
    const identifier = req.headers['x-user-id'] || req.ip;

    const validation = await rateLimiter.validate(
      schema,
      query,
      variables,
      identifier
    );

    if (!validation.allowed) {
      throw new ValidationError(validation.reason);
    }

    return {
      userId: identifier,
    };
  },
});

Advanced Rate Limiting Techniques

Dynamic Rate Limiting

Adjust limits based on user tier or system load.

typescript
class DynamicRateLimiter extends GraphQLRateLimiter {
  async getLimitsForUser(identifier: string): Promise<RateLimitConfig> {
    // Get user tier from database
    const user = await getUser(identifier);

    if (user.tier === 'premium') {
      return {
        maxRequestsPerMinute: 1000,
        maxComplexity: 5000,
        maxDepth: 15,
      };
    } else if (user.tier === 'pro') {
      return {
        maxRequestsPerMinute: 500,
        maxComplexity: 2500,
        maxDepth: 12,
      };
    }

    // Free tier
    return {
      maxRequestsPerMinute: 100,
      maxComplexity: 1000,
      maxDepth: 10,
    };
  }

  async validate(
    schema: GraphQLSchema,
    query: string,
    variables: any,
    identifier: string
  ): Promise<{ allowed: boolean; reason?: string }> {
    const userLimits = await this.getLimitsForUser(identifier);

    // Update config for this user
    this.config = userLimits;

    return super.validate(schema, query, variables, identifier);
  }
}

Cost-Based Rate Limiting

Calculate actual resource cost of queries.

typescript
interface FieldCost {
  baseCost: number;
  multipliers: {
    [key: string]: number;
  };
}

class CostBasedRateLimiter {
  private fieldCosts = new Map<string, FieldCost>();
  private userCredits = new Map<string, number>();

  constructor() {
    // Define costs for different fields
    this.fieldCosts.set('Query.user', {
      baseCost: 1,
      multipliers: {
        posts: 5,
        comments: 3,
      },
    });

    this.fieldCosts.set('Mutation.createUser', {
      baseCost: 10,
      multipliers: {},
    });
  }

  calculateQueryCost(
    schema: GraphQLSchema,
    query: string
  ): number {
    const document = parse(query);
    let totalCost = 0;

    visit(document, {
      Field(node) {
        const fieldPath = this.getFieldPath(node);
        const costConfig = this.fieldCosts.get(fieldPath);

        if (costConfig) {
          totalCost += costConfig.baseCost;
        }
      },
    });

    return totalCost;
  }

  async consumeCredits(identifier: string, cost: number): Promise<boolean> {
    const currentCredits = this.userCredits.get(identifier) || 1000;

    if (currentCredits < cost) {
      return false;
    }

    this.userCredits.set(identifier, currentCredits - cost);
    return true;
  }

  async rechargeCredits(identifier: string): Promise<void> {
    // Recharge credits every minute
    setInterval(async () => {
      const user = await getUser(identifier);
      const rechargeRate = user.tier === 'premium' ? 100 : 10;

      const currentCredits = this.userCredits.get(identifier) || 0;
      this.userCredits.set(
        identifier,
        Math.min(currentCredits + rechargeRate, 1000)
      );
    }, 60000);
  }
}

Monitoring and Analytics

Track Rate Limiting Metrics

typescript
import { Counter, Histogram } from 'prom-client';

export class RateLimitMetrics {
  private requestCounter: Counter;
  private blockedCounter: Counter;
  private complexityHistogram: Histogram;

  constructor() {
    this.requestCounter = new Counter({
      name: 'graphql_requests_total',
      help: 'Total GraphQL requests',
      labelNames: ['user_id', 'status'],
    });

    this.blockedCounter = new Counter({
      name: 'graphql_rate_limit_blocked_total',
      help: 'Total blocked requests due to rate limiting',
      labelNames: ['user_id', 'reason'],
    });

    this.complexityHistogram = new Histogram({
      name: 'graphql_query_complexity',
      help: 'Query complexity distribution',
      labelNames: ['user_id'],
      buckets: [0, 100, 500, 1000, 2500, 5000],
    });
  }

  recordRequest(userId: string, allowed: boolean) {
    this.requestCounter.inc({
      user_id: userId,
      status: allowed ? 'allowed' : 'blocked',
    });
  }

  recordBlocked(userId: string, reason: string) {
    this.blockedCounter.inc({
      user_id: userId,
      reason,
    });
  }

  recordComplexity(userId: string, complexity: number) {
    this.complexityHistogram.observe({
      user_id: userId,
    }, complexity);
  }
}

// Use metrics in rate limiter
const metrics = new RateLimitMetrics();

class MonitoredRateLimiter extends GraphQLRateLimiter {
  async validate(
    schema: GraphQLSchema,
    query: string,
    variables: any,
    identifier: string
  ): Promise<{ allowed: boolean; reason?: string }> {
    const { complexity } = this.calculateComplexity(schema, query);
    metrics.recordComplexity(identifier, complexity);

    const result = await super.validate(schema, query, variables, identifier);

    metrics.recordRequest(identifier, result.allowed);

    if (!result.allowed) {
      metrics.recordBlocked(identifier, result.reason);
    }

    return result;
  }
}

Best Practices

1. Set Appropriate Limits

typescript
// Start conservative
const initialLimits = {
  maxRequestsPerMinute: 60,
  maxComplexity: 500,
  maxDepth: 7,
};

// Adjust based on metrics
// Monitor: average query complexity, p95, p99
// Monitor: request patterns, user behavior

2. Provide Clear Feedback

typescript
throw new GraphQLError('Rate limit exceeded', {
  extensions: {
    code: 'RATE_LIMIT_EXCEEDED',
    http: {
      status: 429,
      headers: {
        'X-RateLimit-Limit': '100',
        'X-RateLimit-Remaining': '0',
        'X-RateLimit-Reset': '1640995200',
      },
    },
  },
});

3. Implement Graduated Limits

typescript
// New users: stricter limits
// Verified users: standard limits
// Premium users: higher limits
// Trusted IPs: no limits

function getUserLimits(user: User): RateLimitConfig {
  if (user.isPremium) return premiumLimits;
  if (user.isVerified) return standardLimits;
  return newLimits;
}

4. Cache Rate Limit Decisions

typescript
// Cache decisions for small time windows
const decisionCache = new Map<string, { allowed: boolean; expires: number }>();

function getCachedDecision(key: string): boolean | null {
  const cached = decisionCache.get(key);
  if (!cached) return null;

  if (Date.now() > cached.expires) {
    decisionCache.delete(key);
    return null;
  }

  return cached.allowed;
}

Testing Rate Limiting

Load Testing

typescript
import { createClient } from 'graphql-ws';

async function testRateLimit() {
  const client = createClient({
    url: 'ws://localhost:4000/graphql',
  });

  let successCount = 0;
  let blockedCount = 0;

  // Send 200 requests rapidly
  const requests = Array.from({ length: 200 }, async (_, i) => {
    try {
      await client.subscribe({
        query: `query { user(id: "${i}") { name } }`,
      }, {
        next: () => successCount++,
        error: () => blockedCount++,
      });
    } catch (error) {
      blockedCount++;
    }
  });

  await Promise.all(requests);

  console.log(`Successful: ${successCount}, Blocked: ${blockedCount}`);
}

Conclusion

Implementing robust rate limiting for GraphQL APIs requires understanding the unique challenges posed by GraphQL's flexible query structure. By combining multiple strategies—query complexity analysis, request rate limiting, and field-level limits—you can protect your API from abuse while ensuring fair access for legitimate users.

Key takeaways:

  • Calculate query complexity based on depth and field count
  • Implement multi-tiered rate limiting (request, complexity, depth)
  • Use Redis for distributed rate limiting
  • Monitor and adjust limits based on actual usage patterns
  • Provide clear feedback when limits are exceeded

Protect your GraphQL API today with comprehensive rate limiting!

Enjoyed this essay? Share it.

Related essays

Next essay
Serverless Architecture Patterns: Event-Driven & Microservices