GraphQL — Deeply Nested Queries Exhaust the Server
Protect GraphQL servers from depth and complexity bombs, read resolver timing to find exponential fan-out, and enforce depth, complexity, and rate limits.
Symptoms
- A single GraphQL request pegs CPU for minutes while the list endpoint of the same data returns instantly
- Resolver traces show the same field resolving millions of times through nested fan-out
- The server becomes unresponsive for all tenants whenever one client sends a crafted deep query
Possible causes
- Nested list fields resolve recursively: friends of friends of friends, each level multiplying row counts
- No depth limit or complexity budget rejects the query before execution
- Per-request N+1 fan-out in resolvers compounds multiplicatively with nesting
- Persisted queries are not enforced, letting arbitrary query text reach the executor
Diagnosis steps
Read the resolver trace
Apollo Studio or graphql-depth analyzers: count field executions per requestInterpretation
A field resolving a million times in one request is the bomb, quantified — the multiplication is visible as executions per nesting level.
Measure the depth of incoming queries
log document depth distribution: node -e "depth(query)" on sampled requestsInterpretation
Normal clients stay under 5 levels; a tail at 20+ is either a crafted bomb or an accidental recursion in a generated client.
Test with a crafted bomb
curl -s -X POST https://api.example.com/graphql -H "content-type: application/json" -d '{ "query": "{ a { b { a { b { a } } } } }" }'Interpretation
An unguarded server executing this validates the vulnerability; a depth-limited one rejects it before execution.
Root cause
GraphQL executes whatever structure the client sends. Without depth and complexity limits, a nested query over list fields multiplies resolver work exponentially — each level fans out into per-item resolutions, and one request can demand more work than the entire normal traffic combined.
Fix
Enforce maximum depth and complexity at validation time (graphql-depth-limit, query cost analysis), batch loader for per-item resolutions (DataLoader), require persisted queries for production clients, and apply per-token rate limits weighted by query complexity.
Risk note
Depth limits set below legitimate client needs break the API. Measure real query shapes before choosing thresholds.
Verify the fix
The crafted bomb is rejected at validation with a clear error; representative legitimate queries pass; resolver execution counts per request stay bounded under load tests.
Symptom walkthrough
The multiplication is visible in resolver traces:
Query.user 1 execution
User.friends 500 executions
User.friends 250,000 executions
User.friends 125,000,000 executions <- the bomb
Three levels of a friends-of-friends field, each level a 500x fan-out, and one request demands 125 million resolutions. The same data via REST list endpoints returns in milliseconds because the server controls the shape. The tenant-isolation giveaway: one crafted query makes the whole server unresponsive for everyone.
Fix in practice
Guard at validation, before execution:
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(7), // reject deeper documents
createCostAnalysis({ maximumCost: 1000 }),
],
});
Kill the per-item fan-out with batching:
const userLoader = new DataLoader((ids) =>
db.users.findByIds(ids) // one query per level, not per item
);
And close the arbitrary-text channel for production clients:
// persisted queries only: clients register documents, send hashes
if (!isPersistedQuery(request)) {
return reject('ad-hoc queries disabled');
}
Verification
Replay the crafted bomb against production configuration and assert it is rejected at validation, before any resolver executes — the response must be a depth or cost error, not a timeout. Then run the legitimate query corpus collected from real clients through the same rules and confirm zero false rejections; measure the depth distribution first and set the ceiling above the honest maximum. Under load, watch resolver executions per request in your trace store: the number stays bounded and flat as traffic scales. Distinguish the related failure mode: a single slow resolver makes one query slow, while a depth bomb makes every query slow — the trace fan-out shape tells them apart immediately.