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:
- 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
depthLimitvalidation rule, provided by thegraphql-toolspackage, analyzes the query AST and rejects any query whose nesting exceeds the specified limit.
- Fix (Apollo Server):
- 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)todepthLimit(7). - Why it works: Provides more room for valid deep queries while still preventing runaway recursion.
- Fix: Increase the limit after analyzing your typical query patterns. For example, change
- 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.
- Recursive schema design without explicit limits: Schemas with self-referential types (like
User.friendsorComment.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.
- 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.
- 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.
- Missing depth limit configuration: The most common reason is simply not having a limit set.
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:
- Missing complexity limit configuration: No score is being calculated or enforced.
- Fix (using
graphql-parse-resolve-infoand 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
friendsis more expensive than fetching aname), this approach prevents queries that might be shallow but computationally intensive.
- Fix (using
- 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,
friendsmight be5andposts3, but ifpostsinvolves 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.
- Fix: Profile your resolvers and query execution. Assign higher weights to fields that consume significant resources. For example,
- 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
filterargument onpostsmight increase its base complexity by10. - Why it works: Prevents clients from using arguments to artificially reduce a query’s calculated complexity.
- Fix: Ensure your complexity calculation accounts for arguments that influence performance. A
- 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) orgraphql-complexityfor more sophisticated and maintainable complexity analysis. - Why it works: Offloads the intricate AST traversal and scoring to a tested, maintained library.
- Fix: Use established libraries like
- Client requesting large lists without pagination: A query asking for all
postsfrom a user with thousands of posts will be very expensive, even if not deep.- Fix: Enforce pagination on list fields. For example, a
postsfield might have a default limit of 20 items, and afirstargument to control the page size. The complexity calculation should reflect the cost of fetchingNitems. - Why it works: Breaks down large data fetches into manageable chunks, both for complexity calculation and server performance.
- Fix: Enforce pagination on list fields. For example, a
- 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."
- Missing complexity limit configuration: No score is being calculated or enforced.
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."