GraphQL’s ability to request exactly the data you need is powerful, but it also opens the door to malicious or accidental query complexity that can bring your server to its knees.

Let’s see this in action. Imagine a simple GraphQL schema:

type User {
  id: ID!
  name: String!
  friends: [User!]!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
  comments: [Comment!]!
}

type Comment {
  id: ID!
  text: String!
  author: User!
  replies: [Comment!]!
}

type Query {
  user(id: ID!): User
}

A client could send this innocent-looking query:

query {
  user(id: "123") {
    name
    friends {
      name
      friends {
        name
        friends {
          name
        }
      }
    }
  }
}

This query asks for the user, their friends, their friends’ friends, and their friends’ friends’ friends. If a user has many friends, and those friends have many friends, this nested request can explode exponentially. The server has to traverse these relationships, potentially resolving hundreds or thousands of User objects and their friends fields.

The core problem is that GraphQL queries are often priced by complexity rather than number of fields. A single deeply nested or recursive query can have a vastly higher computational cost than a broad, shallow query with many more fields.

To combat this, we implement rate limiting based on query depth and complexity.

Depth Limiting

This limits how many levels deep a query can go.

  • Diagnosis: Check your GraphQL server logs for errors indicating excessive recursion or depth. Many frameworks (like Apollo Server, graphql-java, etc.) have built-in logging for this. If not, you can instrument your resolver execution to track depth.
  • Common Causes & Fixes:
    1. Missing depth limit configuration: The most common reason is simply not having a limit set.
      • Fix (Apollo Server):
        const server = new ApolloServer({
          typeDefs,
          resolvers,
          validationRules: [depthLimit(5)], // Allow a maximum depth of 5
        });
        
      • Why it works: The depthLimit validation rule, provided by the graphql-tools package, analyzes the query AST and rejects any query whose nesting exceeds the specified limit.
    2. Too low a limit: A limit set too low might block legitimate, albeit deep, queries.
      • Fix: Increase the limit after analyzing your typical query patterns. For example, change depthLimit(5) to depthLimit(7).
      • Why it works: Provides more room for valid deep queries while still preventing runaway recursion.
    3. Client over-reliance on nested data: Clients might be designed to fetch data in an overly hierarchical way.
      • Fix: Educate frontend developers to refactor queries to be shallower, fetching related data in separate, sequential requests if necessary, or using techniques like connection patterns for lists.
      • Why it works: Shifts the burden of deep traversal from the server to the client, allowing the server to manage requests more predictably.
    4. Recursive schema design without explicit limits: Schemas with self-referential types (like User.friends or Comment.replies) are prime candidates for deep queries.
      • Fix: Apply depth limits specifically to these recursive fields if your validation library supports it, or enforce a global depth limit that accounts for the maximum reasonable recursion.
      • Why it works: Directly targets the structural cause of deep queries.
    5. Framework defaults too permissive: Some GraphQL server frameworks might have high or no default depth limits.
      • Fix: Always explicitly define your desired depth limit in your server configuration.
      • Why it works: Ensures you are in control of the maximum query depth, rather than relying on potentially unsuitable defaults.
    6. External GraphQL gateways unaware of internal depth: If you have a GraphQL gateway, it might not be propagating or enforcing depth limits correctly to the downstream services.
      • Fix: Configure the gateway to enforce depth limits or ensure it’s correctly passing depth information to the backend resolvers.
      • Why it works: Prevents clients from bypassing server-side depth restrictions via a gateway.

Complexity Limiting

This assigns a "cost" to each field and sums them up to limit the total query "weight."

  • Diagnosis: Similar to depth, check logs for errors related to query complexity or timeouts. You might need to implement custom logic to track the cumulative complexity score during query parsing or execution.
  • Common Causes & Fixes:
    1. Missing complexity limit configuration: No score is being calculated or enforced.
      • Fix (using graphql-parse-resolve-info and a custom limiter):
        import { parseResolveInfo } from 'graphql-parse-resolve-info';
        
        // In your resolver or middleware:
        const complexityLimit = 1000;
        const queryComplexity = calculateQueryComplexity(parsedResolveInfo); // Custom function
        if (queryComplexity > complexityLimit) {
          throw new Error('Query is too complex.');
        }
        
        function calculateQueryComplexity(info) {
          let complexity = 1; // Base cost for the field itself
          const { fields } = parseResolveInfo(info);
          for (const fieldName in fields) {
            const field = fields[fieldName];
            // Assign a complexity score to each field type/name
            complexity += getFieldComplexity(fieldName, field.args);
            if (field.fields) {
              complexity += calculateQueryComplexity(field.fields); // Recursive call for nested fields
            }
          }
          return complexity;
        }
        
        function getFieldComplexity(fieldName, args) {
          // Example: simple cost based on field name and presence of certain args
          let cost = 1;
          if (fieldName === 'friends') cost += 5;
          if (fieldName === 'posts') cost += 3;
          if (args && args.filter) cost += 10;
          return cost;
        }
        
      • Why it works: By traversing the query’s Abstract Syntax Tree (AST) and assigning a weighted score to each field (e.g., fetching a list of friends is more expensive than fetching a name), this approach prevents queries that might be shallow but computationally intensive.
    2. Unrealistic field complexity weights: Assigning low weights to expensive operations (like fetching deeply related data or performing heavy computations in resolvers).
      • Fix: Profile your resolvers and query execution. Assign higher weights to fields that consume significant resources. For example, friends might be 5 and posts 3, but if posts involves complex filtering or pagination, its weight should increase.
      • Why it works: Accurately reflects the cost of data fetching and processing, leading to more effective limits.
    3. Ignoring arguments that increase complexity: Arguments like filter, search, or pagination parameters can drastically increase the work done by a resolver.
      • Fix: Ensure your complexity calculation accounts for arguments that influence performance. A filter argument on posts might increase its base complexity by 10.
      • Why it works: Prevents clients from using arguments to artificially reduce a query’s calculated complexity.
    4. Lack of a robust complexity calculation library: Manually calculating complexity can be error-prone and difficult to maintain.
      • Fix: Use established libraries like graphql-depth-limit (which also has complexity features) or graphql-complexity for more sophisticated and maintainable complexity analysis.
      • Why it works: Offloads the intricate AST traversal and scoring to a tested, maintained library.
    5. Client requesting large lists without pagination: A query asking for all posts from a user with thousands of posts will be very expensive, even if not deep.
      • Fix: Enforce pagination on list fields. For example, a posts field might have a default limit of 20 items, and a first argument to control the page size. The complexity calculation should reflect the cost of fetching N items.
      • Why it works: Breaks down large data fetches into manageable chunks, both for complexity calculation and server performance.
    6. Overlapping or redundant data fetches: A client might request the same nested data multiple times within a single query.
      • Fix: While the GraphQL spec aims to prevent this, complex queries might still lead to inefficiencies. The complexity calculation can help by assigning a higher cost to fields that are frequently requested or have high inherent costs.
      • Why it works: Discourages inefficient query patterns by making them prohibitively "expensive."

After implementing both depth and complexity limits, the next error your users might encounter is a 400 Bad Request with a message like "Query exceeds maximum complexity" or "Query depth limit exceeded."

Want structured learning?

Take the full Rate-limiting course →