GraphQL Query Complexity Analysis and Depth Limiting
Protect your GraphQL API from expensive queries using query complexity scoring, depth limiting, and cost analysis techniques.
What you'll learn
- ✓Why GraphQL APIs are vulnerable to expensive queries
- ✓How to implement query depth limiting to prevent deeply nested queries
- ✓How to assign complexity scores and enforce cost budgets per query
Prerequisites
- •Basic GraphQL knowledge
- •Familiarity with Apollo Server or similar
The Problem with Unrestricted Queries
GraphQL gives clients the power to request exactly the data they need. That same power lets a malicious or careless client craft queries that overwhelm your server:
# A deeply nested query that triggers exponential database calls
query DeeplyNested {
users {
friends {
friends {
friends {
friends {
friends {
name
}
}
}
}
}
}
}
# A wide query that fetches massive amounts of data
query WideQuery {
users(first: 1000) {
posts(first: 100) {
comments(first: 100) {
author {
posts(first: 100) {
title
}
}
}
}
}
}
Without protections, a single query can trigger millions of database operations, exhaust server memory, or cause cascading failures. You need multiple layers of defense.
Layer 1: Query Depth Limiting
Depth limiting is the simplest protection. It rejects queries that exceed a maximum nesting level:
npm install graphql-depth-limit
import depthLimit from 'graphql-depth-limit';
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)],
});
A depth limit of 7 allows reasonable nested queries while blocking pathological ones. The depth counts each selection set level:
query Example { # depth 0
users { # depth 1
posts { # depth 2
comments { # depth 3
author { # depth 4
name # depth 5
}
}
}
}
}
Choosing a Depth Limit
Audit your legitimate queries to find the maximum depth they use, then add a small buffer. Most production APIs work well with a limit between 5 and 10. If legitimate queries need deep nesting, consider restructuring your schema to expose data through dedicated root queries instead.
Layer 2: Query Complexity Analysis
Depth limiting alone is not sufficient. A shallow but wide query can be just as expensive:
# Only depth 2, but fetches 1000 * 100 = 100,000 records
query ShallowButExpensive {
users(first: 1000) {
posts(first: 100) {
title
}
}
}
Query complexity analysis assigns a cost to each field and rejects queries that exceed a budget.
Install the library:
npm install graphql-query-complexity
import {
getComplexity,
simpleEstimator,
fieldExtensionsEstimator,
} from 'graphql-query-complexity';
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
requestDidStart: async () => ({
async didResolveOperation({ request, document, schema }) {
const complexity = getComplexity({
schema,
operationName: request.operationName,
query: document,
variables: request.variables,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
});
const MAX_COMPLEXITY = 1000;
if (complexity > MAX_COMPLEXITY) {
throw new GraphQLError(
`Query too complex: ${complexity}. Maximum allowed: ${MAX_COMPLEXITY}.`,
{ extensions: { code: 'QUERY_TOO_COMPLEX', complexity } }
);
}
console.log(`Query complexity: ${complexity}`);
},
}),
},
],
});
Assigning Field Complexity
The simpleEstimator gives every field a cost of 1 by default. For more accurate cost modeling, assign custom complexity to expensive fields using schema extensions:
const typeDefs = `
type Query {
user(id: ID!): User
users(first: Int = 20): [User!]!
}
type User {
id: ID!
name: String!
posts(first: Int = 10): [Post!]!
followers(first: Int = 20): [User!]!
}
`;
const resolvers = {
Query: {
users: {
resolve: (_, { first }) => db.users.findAll({ limit: first }),
extensions: {
complexity: ({ args, childComplexity }) => {
return (args.first || 20) * childComplexity;
},
},
},
},
User: {
posts: {
resolve: (user, { first }) => db.posts.findByAuthor(user.id, first),
extensions: {
complexity: ({ args, childComplexity }) => {
return (args.first || 10) * childComplexity;
},
},
},
followers: {
resolve: (user, { first }) => db.users.findFollowers(user.id, first),
extensions: {
complexity: ({ args, childComplexity }) => {
return (args.first || 20) * childComplexity;
},
},
},
},
};
With this configuration, the complexity of a query is calculated recursively:
query {
users(first: 10) { # 10 * childComplexity
name # 1
posts(first: 5) { # 5 * childComplexity
title # 1
}
}
}
# Complexity: 10 * (1 + 5 * 1) = 60
A more expensive query:
query {
users(first: 100) { # 100 * childComplexity
name # 1
posts(first: 50) { # 50 * childComplexity
title # 1
comments(first: 20) { # 20 * childComplexity
body # 1
}
}
}
}
# Complexity: 100 * (1 + 50 * (1 + 20 * 1)) = 100 * (1 + 1050) = 105,100
# Exceeds the 1000 limit -- rejected!
Layer 3: Query Breadth Limiting
Limit the total number of aliases and root-level fields to prevent alias-based attacks:
# Alias attack: same expensive query repeated via aliases
query AliasAbuse {
a1: users(first: 100) { name }
a2: users(first: 100) { name }
a3: users(first: 100) { name }
# ... repeated 50 times
}
function breadthLimitRule(maxBreadth) {
return function (context) {
return {
OperationDefinition(node) {
if (node.selectionSet.selections.length > maxBreadth) {
context.reportError(
new GraphQLError(
`Query exceeds maximum breadth of ${maxBreadth} root selections.`
)
);
}
},
};
};
}
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(7),
breadthLimitRule(20),
],
});
Layer 4: Timeout and Resource Limits
Even with complexity analysis, some queries might take longer than expected due to slow database queries or network issues. Set timeouts as a final safeguard:
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
requestDidStart: async () => {
const timeout = setTimeout(() => {
throw new Error('Query timeout exceeded');
}, 10000); // 10 second timeout
return {
willSendResponse: async () => {
clearTimeout(timeout);
},
};
},
},
],
});
Persisted Queries
For maximum control, use persisted queries. Instead of accepting arbitrary queries, the server only accepts pre-registered query hashes:
import { ApolloServer } from '@apollo/server';
// Only allow known query hashes
const allowedQueries = new Map([
['abc123', 'query GetUser($id: ID!) { user(id: $id) { name email } }'],
['def456', 'query GetPosts { posts { title author { name } } }'],
]);
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: {
cache: {
async get(hash) {
return allowedQueries.get(hash);
},
async set() {
// No-op: only pre-approved queries allowed
},
},
},
});
Automatic persisted queries (APQ) offer a middle ground where clients send a hash first, and the server caches the full query on first request. This reduces bandwidth but does not restrict which queries are allowed.
Monitoring Query Performance
Track complexity scores and execution times to identify problematic queries:
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
requestDidStart: async ({ request }) => {
const start = Date.now();
return {
didResolveOperation: async ({ document, schema }) => {
const complexity = getComplexity({
schema,
query: document,
variables: request.variables,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
});
request.http?.headers.set('x-query-complexity', String(complexity));
},
willSendResponse: async () => {
const duration = Date.now() - start;
metrics.trackQuery({
operationName: request.operationName,
duration,
});
},
};
},
},
],
});
Choosing Complexity Budgets
Start with a generous budget (e.g., 1000), monitor your actual query complexities for a few weeks, then tighten the budget based on real usage. Expose the complexity score in response headers or extensions so frontend teams can see how close their queries are to the limit.
Summary
Protect your GraphQL API with multiple layers: depth limiting prevents deeply nested queries, complexity analysis catches wide expensive queries with multiplied costs, breadth limiting blocks alias-based abuse, and timeouts provide a final safeguard. Assign realistic complexity costs to list fields based on their pagination arguments. For maximum security, use persisted queries to restrict the API to pre-approved operations. Monitor query complexity in production to continuously tune your budgets.
Related articles
- GraphQL Authentication and Authorization in GraphQL
Implement auth in GraphQL using context-based authentication, custom directives, and field-level authorization patterns.
- GraphQL Solving the N+1 Problem in GraphQL with DataLoader
Learn how the DataLoader pattern batches and caches database calls to eliminate N+1 query performance issues in GraphQL APIs.
- GraphQL GraphQL N+1 and DataLoader
Why GraphQL resolvers cause N+1 query storms and how DataLoader batches and caches them away. Clear examples, real code, and the pitfalls.
- GraphQL GraphQL Rate Limiting Strategies
Why GraphQL rate limiting is harder than REST and what to do about it. Covers query complexity analysis, depth limits, cost-based budgets, and per-field throttling.