Skip to content

Depth & Complexity

We have settled who is asking and whether they are allowed. Now the third question: how expensive is this query? A request can be perfectly authenticated, fully authorized, and still bring a server to its knees — because GraphQL lets clients author the shape of the work, and a small query string can describe an enormous amount of it.

How a tiny query becomes a denial of service

Section titled “How a tiny query becomes a denial of service”

Two features of a connected graph make abuse easy:

  • Deep nesting. If User has friends: [User!]!, then user → friends → friends → friends → … is legal SDL forever. Each level multiplies the rows the server must fetch. Ten levels of a modest fan-out is millions of objects from a few lines of query.
  • Aliases. A client may request the same expensive field many times under different names: a: search(...) b: search(...) c: search(...). The query stays short, but the server runs the work once per alias.
# Short to write, ruinous to execute: nesting plus aliasing.
query Abuse {
user(id: "1") {
friends { # level 1
friends { # level 2
friends { # level 3 ... and on, and on
a: posts { title }
b: posts { title } # same field, billed twice via aliases
}
}
}
}
}

You cannot fix this with authentication or authorization — the attacker may be a legitimate, logged-in user. You fix it by measuring cost and refusing what is too expensive before execution.

flowchart LR
  P["parse: query string -> AST"] --> V["validate against schema"]
  V --> Depth{"Depth within limit?"}
  Depth -->|"No"| Rej["Reject (no resolver runs)"]
  Depth -->|"Yes"| Cost{"Complexity within budget?"}
  Cost -->|"No"| Rej
  Cost -->|"Yes"| Exec["Execute with a timeout"]
  Exec -->|"too slow"| Abort["Abort on timeout"]
  Exec --> R["Response"]
Cost checks run after parsing and validation but before resolvers touch any data source.
  1. Depth limiting. Walk the parsed query and reject it if nesting exceeds a fixed maximum (say, 7). It is blunt but cheap and catches the recursive-nesting attack outright. This is the easiest control to add and the one we will compute live below.
  2. Complexity (cost) analysis. Assign each field a cost — a flat number, or a formula based on arguments like first: 100 for a paginated list — sum the costs for the whole query, multiply through nesting, and reject anything over a budget. This handles wide and deep queries and accounts for aliases, since each alias is a separate selection that adds to the total.
  3. Timeouts. A backstop for whatever slips through static analysis: cap wall-clock execution (and per-data-source calls) so a query that turns out expensive at runtime is aborted rather than allowed to run forever.

Libraries such as graphql-depth-limit and graphql-query-complexity implement the first two as validation rules, so the query is rejected during validation — no resolver ever runs. The cost rule reads like this:

import { createComplexityRule, simpleEstimator } from 'graphql-query-complexity';
const complexityRule = createComplexityRule({
maximumComplexity: 1000,
estimators: [
// A list field costs (childComplexity * the requested page size).
simpleEstimator({ defaultComplexity: 1 }),
],
onComplete: (cost) => {
if (cost > 1000) throw new Error('Query is too complex: ' + cost);
},
});
// Pass complexityRule in the server's validationRules so it runs before execution.

Depth limiting is something you can implement in a few lines, and the heart of it is the GraphQL parse function — the same one a server uses. It turns a query string into an AST (abstract syntax tree). Walk the selectionSets, and the deepest chain of nested selections is the query’s depth. The demo below parses three queries and measures each, then applies a limit of 5. Press Run.

JavaScript

The output ranks the three queries by depth and applies the limit. The shallow and medium queries pass; the abusive one — five levels of friends deep — exceeds the limit of 5 and is rejected. Note that no resolver ran: we worked purely on the parsed AST, exactly as a validation rule does, so a malicious query is thrown out before it can cost anything. Real depth-limit libraries do the same walk; complexity analysis extends it by weighting each field instead of just counting nesting levels.

Why can a short query string cause a denial of service in GraphQL?
What does depth limiting actually inspect?
At what point do depth and complexity rules reject an over-budget query?
Why is complexity (cost) analysis more powerful than depth limiting alone?