skip to content

Query Complexity Scoring

Give every field a cost, multiply by the slice a list argument asks for, and reject the document against a budget before any resolver runs. Interviewers ask because shallow documents can be costly.

part ofGraphQLoverview, primer and where to startread it →
on this pageshow

questions

4

How does a GraphQL cost limiter compute a document's complexity score before execution?

level: middleimportance: must knowfreq 58%

answer

  1. Static walk, no data touched
  2. Weight per field, slice as multiplier
  3. Fragments expand, abstract branches take the max
  4. Variables must be coerced before scoring
  5. 5 + 96, then ×137, then ×24

basics

~20 s

It walks the document's selection set, charges every field a configured weight, multiplies each list field's subtree by the slice its arguments request, and sums the result. The total is compared to a budget after validation, before any resolver runs.

solid answer

~50 s

The limiter needs three inputs: the schema, the document, and the request's variable values. It walks the selection set recursively. Each field is charged a **weight** from configuration — 1 by default, higher for fields backed by an expensive call — and a list field's child cost is **multiplied** by the slice its arguments request, so nested lists compound. Named and inline fragments are expanded first; for a selection on an interface or union only one branch applies per object, so the tight bound is the maximum over the branches. Variables matter: `readings(first: $n)` has no number until the request's variables are coerced, so the limiter scores document plus variables, or falls back to a configured ceiling for that list. The total is checked once, before execution, so an over-budget document produces an error and no partial data. None of this is in the GraphQL specification — the weights, the multiplier rule and the budget are all server configuration.

code

graphql · 9 lines
graphql
query InverterHistory($window: Int! = 96) {
  sites(first: 24) {
    name
    inverters(first: 137) {
      serial
      readings(first: $window) { wattage }
    }
  }
}

go deeper

for a junior

Know the shape of the calculation: each field has a weight, a list field's slice multiplies whatever is selected inside it, and the total is checked against a budget before the server executes anything.

for a middle

Walk the recursion out loud on a small document and get the arithmetic right, including where fragments go, how an aliased field is counted twice, and why variables have to be coerced before the score exists.

for a senior

Be ready on the judgement calls the algorithm hides: maximum versus sum across abstract-type branches, what to assume for a list with no slice argument, and how weights get calibrated from measured work instead of guessed.

for a principal

Own the fact that every input is local configuration, not specification. That means weights are an interface you publish, version and review alongside the schema, or callers cannot predict whether their document will be accepted.

## The three inputs A cost limiter is a static analysis, and like any static analysis it is only as good as what it is given. It needs: 1. **The schema**, to know which fields return lists, what type each field has, and which fields are abstract. 2. **The document**, after parsing and normally after the standard validation rules have run — there is no point pricing a document that is not executable. 3. **The request's variable values**, because a slice size is very often a variable. 4. **A weight table**, which is configuration: a map from `Type.field` to a number, plus a default for anything unlisted. Nothing here comes from the GraphQL specification. The specification defines the type system, validation and execution; it says nothing about cost, weights or budgets. Cost analysis is implemented as an extra validation-style rule, and different servers legitimately score the same document differently. Some schemas declare weights in SDL with a custom type-system directive rather than in a config file; that is a convention too, not a specified construct. ## The recursion The core is a few lines. For each field in a selection set: charge its own weight, recurse into its sub-selection, and multiply the recursive result by the slice if the field returns a list. ``` function cost(selectionSet, parentType, variables): total = 0 for field in collectFields(selectionSet, parentType, variables): weight = weights[parentType.name + "." + field.name] or defaultWeight childCost = field.hasSubSelection ? cost(field.subSelection, field.type, variables) : 0 multiplier = field.returnsList ? sliceArgument(field, variables) or configuredCeiling(field) : 1 total = total + weight + multiplier * childCost return total ``` Two details in that sketch carry most of the meaning. `collectFields` is where fragments disappear: a named fragment spread and an inline fragment contribute their fields to the parent's selection, so you never score "a fragment" as such. And the multiplier is applied to the **child** cost, not to the field's own weight — a choice, not a law. Other implementations multiply the whole subtree including the field weight, and both are defensible; what matters is that a nested list compounds. Aliases are worth one sentence: an aliased field is a separate entry in the collected selection, so the same field named three times under three response keys is charged three times. A limiter that deduplicated by field name would be scoring the schema rather than the document. ## A worked total Weights: everything 1, except `Inverter.readings`, which is backed by a time-series store and is configured at 5. ```graphql query { sites(first: 24) { name inverters(first: 137) { serial readings(first: 96) { wattage } } } } ``` Working inside out: - `readings`: weight 5, multiplier 96, child `{ wattage }` = 1 → 5 + 96 × 1 = **101** - one inverter's selection: `serial` (1) + `readings` (101) = **102** - `inverters`: weight 1, multiplier 137 → 1 + 137 × 102 = **13,975** - one site's selection: `name` (1) + `inverters` (13,975) = **13,976** - `sites`: weight 1, multiplier 24 → 1 + 24 × 13,976 = **335,425** Against a budget of 25,000 this document is refused, and the rejection costs a parse and a tree walk. ## Variables, and why the document alone is not enough `readings(first: $n)` scores as nothing until `$n` has a value. The honest positions are: - Score **after variable coercion**, using the values the caller actually sent. This is the common choice and gives the tightest bound. - Score the document alone against a **configured ceiling** per list field — the largest slice the server would ever allow. Useful when you want to price a document once and reuse the number, at the cost of over-charging every caller who asks for less. - Use the variable definition's **default value** when the caller omits the argument, and the field's schema default when there is no variable at all. A list field with no slice argument and no default is the real hazard: the limiter has to invent a number, and inventing 1 makes an unbounded list free. ## Abstract types For a selection on an interface or union, only one inline-fragment branch applies to any given object. The tight upper bound is therefore the **maximum** over the branches. Summing them is a valid, more conservative choice that over-charges polymorphic selections; the important thing in an interview is to notice that the two differ and to say which one you would pick and why. ## What the number means The result is an **upper bound on the work the document authorises**, derived without touching data. It is not a latency estimate and not a byte count. Because it is checked before execution, an over-budget document is a request-level failure: an error and no `data`, rather than a half-filled response. And because every input to it is configuration, the same document is cheap on one deployment and refused on another — which is exactly why weights need to be versioned and reviewed like schema.

  • The slice arrives as a variable — can you still score the document on its own?
    Only against an assumption. `first: $n` carries no number until the request's variables are coerced, so either you score document plus variables, which gives the tightest bound, or you score the document alone against a configured ceiling for that list field and over-charge everyone who asks for less. Remember the variable definition's default value, and treat a list field with no slice argument and no default as the dangerous case: the limiter has to invent a number, and inventing 1 makes an unbounded list free.
  • How do you score a selection on an interface with several inline-fragment branches?
    Only one branch applies to any given object, so the tight upper bound is the maximum cost across the branches, not their sum. Summing is simpler and stays a valid upper bound, but it over-charges polymorphic selections and will refuse legitimate documents that fan out over many possible types. Say which you chose and why — the interviewer is checking that you noticed the branches are exclusive.
  • Does the GraphQL specification define weights, a budget or a cost directive?
    No. The specification covers the type system, validation and execution, and stops there. Cost analysis is an extra rule a server adds, its weights live in configuration or in a custom type-system directive on the schema by convention, and the budget is local policy. Two conformant servers can price the same document differently. It follows that a client cannot infer your cost model from introspection, so if you enforce one you have to publish it.

It is a bill of materials priced from the drawing: quantity times unit price, totalled before anything is manufactured.

saying these in an interview costs you the question

  • Scores the response rather than the document
  • Adds nested slice sizes instead of multiplying them
  • Ignores variables and scores the raw document text
  • Believes the specification defines the weights
  • Claims the score predicts response latency
  • Assumes every field is worth exactly one point

context

open as a page

Why can a shallow GraphQL document still be expensive enough to need a cost limit?

level: juniorimportance: should knowfreq 44%

basics

~20 s

Cost follows how many objects a document asks the server to produce, not how deeply it nests. Three levels of list fields, each requesting a few hundred items, multiply into millions of objects while staying shallow.

open as a page

Your GraphQL cost limiter began rejecting a shipped mobile app's query after a schema change — how do you diagnose and fix it?

level: seniorimportance: should knowfreq 41%

basics

~20 s

Log every operation's score, its budget and the path that contributed most, then replay the rejected document against the weight tables before and after the deploy. A rename that adds charged levels inside a list multiplier is the usual cause.

open as a page

How do you set a GraphQL cost budget and allocate points across callers?

level: principalimportance: should knowfreq 37%

basics

~20 s

Anchor a point in something measurable — one object fetched, or a millisecond of service time. Set the per-request cap above the p99 score of legitimate traffic, then budget each caller in points per window rather than requests per window.

open as a page