← Debugging Guides

阅读中文版本

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.

intermediatedebugginggraphqlapiperformance

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 request

Interpretation

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 requests

Interpretation

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.