The N+1 Sentinel
Expressive queries for the frontend. Survivable queries for the database.
The incident
The GraphQL migration was a frontend triumph — one flexible endpoint, no more bespoke REST routes. Then someone shipped a screen that asked for posts, each post's author, and each author's followers. One innocent query. Four thousand database calls. The database didn't distinguish between a feature and an attack, because at that shape, there isn't one.
Why the obvious approach fails
GraphQL's power is that clients compose queries the backend never anticipated — which is also its threat model. A naive resolver fetches each nested entity individually, turning one query into N+1 (then N×M+1) database calls, and query depth becomes a lever anyone can pull: a deeply nested request is a denial-of-service whether or not it meant to be. The flexibility isn't the mistake; shipping it without batching and budgets is. DataLoader collapses the N calls into batched fetches; depth and complexity limits put a ceiling on what any single query may cost — so the frontend keeps its expressiveness and the database keeps its life.
The standard
- Every relational resolver batches through DataLoader (or equivalent); per-entity fetch loops are banned.
- Query depth and computed complexity budgets are enforced at the gateway — over-budget queries are rejected with a clear error, before execution.
- Persisted/allowlisted queries are the default for public clients; arbitrary query composition is a privilege, not a right.
- Resolver-level database-call counts are observable per query shape — the N+1 that slips through is caught by metrics, not by an outage.
- Pagination is mandatory on every list field; unbounded collections do not exist in the schema.
How Cortex validates it
At plan time
GraphQL blueprints declare depth/complexity budgets and the persisted-query policy.
At validation
assert: nested_query(posts→authors→followers) → db_calls == batched(3), not N×Massert: query(depth > budget) → rejected_pre_executionassert: list_fields.paginated == 100%Assertion syntax is illustrative.
At review
the approver sees the hostile-query run: pathological shapes fired at the gateway, rejections and batching both proven, database call counts on the record.