Limiting Query Depth and Complexity in Federation
A GraphQL endpoint accepts any document the schema permits, and in a federated graph the gap between what a request costs to send and what it costs to serve is larger than anywhere else — one accepted operation can become dozens of subgraph fetches resolving thousands of entities. Depth, height and alias limits close that gap cheaply. This page covers what each limit actually bounds, how to choose values from your own traffic, and why complexity scoring is usually the wrong first move. It sits under Gateway Routing Strategies for Federated APIs.
When to use this pattern
- Use it on any endpoint whose clients you do not control, and on internal endpoints too, because a badly written dashboard costs the same as a hostile one.
- Use it before a public launch, since these are the cheapest controls available and the ones most easily added later without breaking anybody — if you measure first.
- Skip complexity scoring until depth and alias caps are in place and proving insufficient, because it costs per-field cost estimates that must stay accurate as the schema grows.
Prerequisites
Four limits, four different attacks
The four knobs bound genuinely different things, and only using all four closes the gap.
Depth bounds nesting. user { friends { friends { friends { … } } } } grows the result exponentially with each level while the document grows linearly, which is the classic amplification.
Height bounds the total number of fields in the document. It catches the wide-and-flat shape that depth misses entirely — a document selecting four hundred fields at depth two is enormous and perfectly shallow.
Aliases bound repetition of the same field under different names. a: expensive b: expensive c: expensive multiplies work without adding depth or, in some accountings, height. It is the cheapest attack to write and the one most often left unbounded.
Parser tokens bound the document itself, before any of the above can be evaluated. It is what stops a multi-megabyte document consuming CPU in the parser.
Implementation walkthrough
Values should come from measurement, so the first version of the configuration is deliberately generous and reporting-only in spirit.
# router.yaml — phase 1: set far above real traffic and observe
limits:
max_depth: 50
max_height: 2000
max_aliases: 200
max_root_fields: 100
parser_max_tokens: 60000
Measure the distribution of each dimension across a week of real operations, then set each limit at the observed maximum plus meaningful headroom. For a graph whose deepest genuine operation is eight levels, twelve is comfortable and fifty is decoration.
# router.yaml — phase 2: derived from what your clients actually send
limits:
max_depth: 12 # deepest real operation is 8
max_height: 300 # widest real operation selects 180 fields
max_aliases: 30 # legitimate aliasing peaks at 12
max_root_fields: 20
parser_max_tokens: 15000
The schema does its own share of the work, and it is the part limits cannot do. A list field without a bounded page size is unbounded regardless of how shallow the query is:
type Query {
# A default AND a maximum. Without the maximum, first: 100000 is a valid query
# that no depth or alias limit will stop.
products(first: Int = 20, after: String): ProductConnection!
}
const MAX_PAGE = 100;
export const resolvers = {
Query: {
products: (_: unknown, { first = 20, after }: PageArgs, ctx: Context) =>
ctx.catalog.page({ first: Math.min(first, MAX_PAGE), after }),
},
};
Verification steps
Verify from both directions: that real operations pass, and that the shapes you meant to block are blocked.
# Every operation in the persisted manifest must still be accepted.
jq -r '.operations[].body' persisted-queries.json | while read -r q; do
curl -s localhost:4000/graphql -H 'content-type: application/json' \
--data "$(jq -nc --arg q "$q" '{query:$q}')" \
| jq -e '.errors[]?.extensions.code == "GRAPHQL_VALIDATION_FAILED"' >/dev/null \
&& echo "REJECTED: $q"
done
Any output from that loop is a limit set below real traffic, and it is far better to see it in a script than in a support ticket. Then confirm the blocking works by sending a document that exceeds each limit in turn and asserting a validation error rather than a slow success.
Finally, check that the pagination cap is enforced in the resolver rather than only documented in the schema. Request first: 100000 and confirm the response contains at most your maximum — a default without a ceiling is not a limit.
It is worth being clear about what limits do and do not buy, because they are frequently oversold. They bound the shape of a document, which is a proxy for its cost and not a measurement of it. A shallow, narrow query that requests a page of ten thousand items is cheap by every limit on this page and expensive by every measure that matters. Conversely a deep query over small collections may be entirely harmless and rejected anyway.
That imprecision is acceptable because the controls are nearly free and because the shapes they reject are overwhelmingly ones no real client sends. What it means in practice is that limits belong in a set rather than alone: page-size caps in the schema bound result size, rate limiting bounds volume, and safelisting bounds the document set entirely. Each covers a dimension the others miss, and a graph relying on any one of them has an obvious gap.
The other thing limits buy, which is easy to overlook, is a bounded worst case for capacity planning. Without them the most expensive possible operation is unknowable, so no load test tells you anything about your ceiling. With them you can construct the worst document your configuration permits, measure it, and know that no client can do worse — which turns capacity planning from guesswork into arithmetic.
Rolling limits out is the part that decides whether they stay enabled, and the sequence below is the same one that works for every other gateway control: observe, warn, then enforce.
Common mistakes & gotchas
Setting depth without aliases. Alias multiplication needs no depth at all, so a graph with a tight depth limit and no alias cap is protected against the attack nobody writes and open to the one everybody does.
Counting fragment expansion incorrectly. Depth is measured after fragments are expanded, so a client that heavily uses fragments may be deeper than its source suggests. Measure against expanded documents, not against the files in the repository.
Treating limits as a substitute for pagination caps. No document-level limit bounds first: 100000. Cap page size in the resolver, in every subgraph, with a shared constant.
Frequently Asked Questions
Is query complexity scoring worth adopting?
Eventually, and rarely first. A useful score needs a cost estimate per field, and those estimates decay as the schema and the data change, so the maintenance is ongoing and invisible until it is wrong. Depth, height and alias caps capture most of the risk with values you can derive in an afternoon and revisit annually. Reach for scoring when one specific expensive field justifies its own budget.
How do these interact with persisted operations?
They become far less important and remain worth keeping. With safelisting enforced, the set of documents is finite and already reviewed, so an unbounded document cannot arrive. Limits still guard the case where a safelisted operation is expensive with hostile variables, and they cost nothing, so keeping both is the right posture.
Should limits differ per client?
Ideally yes, and in practice it is rarely worth the machinery. A mobile client and an internal reporting tool genuinely have different needs, but expressing that means limits keyed on identity, which the router applies globally. The cleaner separation is a second deployment with its own configuration — the same approach used for contracts and schema variants.
What should a rejection look like to a client?
A validation error with a clear code, returned before any execution, and named in your API documentation. Clients need to be able to distinguish “your query is too large” from “something failed”, because the first is a fix they can make and the second is not.
Do limits need to change as the graph grows?
Rarely, and that is worth knowing before you build a process around reviewing them. Depth is a property of how clients traverse the graph rather than of how many subgraphs exist, so adding services barely moves it. Height creeps up slowly as screens get richer. An annual look at the distribution is enough, and a rejection appearing in your metrics is a better trigger than a calendar.
Related
- Gateway Routing Strategies for Federated APIs — parent guide
- Securing the Federated Gateway — where these limits sit among other controls
- Rate Limiting Federated GraphQL Operations — bounding volume rather than size
- Enforcing Persisted Query Safelists in Apollo Router — bounding the document set entirely
- Query Plan Optimization Strategies for Federated APIs — reducing what an accepted query costs