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.

Four limits, four document shapes Depth bounds nesting, height bounds total field count, alias caps bound repetition, and parser token limits bound the raw document. Each misses what the others catch. Each limit is blind to what the others catch limit bounds blind to max_depth nesting levels a wide, shallow document max_height total fields selected a deep, narrow document max_aliases repetition of one field many distinct expensive fields parser_max_tokens the raw document size a small document with huge results The last row is why limits alone are not enough — pagination caps belong in the schema.

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.

Set the limit from the distribution, not from a guess Almost all real operations are shallow, with a small tail reaching depth eight. A limit at twelve leaves headroom for legitimate growth while rejecting anything far outside observed behaviour. Operations by depth, one week of traffic limit: 12 headroom for growth 2 5 8 depth A limit chosen from a blog post is either far too loose to protect anything or tight enough to break a client you did not know about. The distribution answers both questions at once.

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.

Observe, warn, enforce, review Setting a limit far above traffic first, then reporting, then enforcing, means enforcement surprises nobody. Rolling a limit out without breaking anyone phase what you set what you learn 1. observe far above real traffic the actual distribution 2. warn the intended value, reported which operations would fail 3. enforce the intended value, blocking nothing new — that is the point 4. review annually, from the distribution whether real usage has moved Phase two is the one teams skip, and skipping it is why a limit gets rolled back within a week. By phase three you should already know the answer, which is what makes enforcement uneventful.

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.