Depth Counts Nesting, Money Counts Work
Depth Counts Nesting, Money Counts Work
A depth limit says how tall a query can be; it says nothing about how much work it causes. Two documents at depth 3 differ by orders of magnitude: { posts { author { name } } } reads three columns, while the same shape over a list field with an unbounded page size reads thousands of rows and joins them.
Cost analysis replaces the structural guess with an arithmetic model. Every field has a base weight (default 1), a field that resolves from an expensive source overrides it with @cost(weight: N), and when a field returns a list, the cost of its entire sub-selection is multiplied by the list size. The operation's cost is the sum over the whole selection tree.
Walk a real one. shippingEstimate calls a pricing service, so it is weighted; reviews is annotated with an assumed page of 5; products takes a slicing argument:
type Query {
products(first: Int!): [Product!]! @listSize(slicingArguments: ["first"])
}
type Product {
id: ID! # weight 1
name: String! # weight 1
shippingEstimate: Int! @cost(weight: 20) # weight 20 — calls pricing
reviews: [Review!]! @listSize(assumedSize: 5)
}
type Review { rating: Int! }
| Node | Rule applied | Cost |
|---|---|---|
rating |
default weight | 1 |
reviews |
own weight 1 + sub-selection 1, × list size 5 | 10 |
id, name |
default weights | 1 + 1 |
shippingEstimate |
@cost overrides the default |
20 |
one Product |
sum of its fields | 32 |
products(first: 3) |
own weight 1 + 3 × 32 | 97 |
The same operation with only id and name selected scores 7. Nothing about depth distinguishes 7 from 97 — argument values, list sizes, and field weights do. That is the point: cost is a proxy for work, so a budget is a proxy for capacity.
Annotating the Schema So Estimates Are Honest
Annotating the Schema So Estimates Are Honest
The gateway cannot know list sizes before execution, so it must estimate — and an estimate is only as good as the schema metadata behind it. Three directives carry that metadata:
type Query {
# size comes from the argument the client actually sends: first: 25 → multiplier 25
products(first: Int!): [Product!]! @listSize(slicingArguments: ["first"])
# fixed, predictable size — no pagination argument exists
bestsellers: [Product!]! @listSize(assumedSize: 5)
# cursor connection: the slicing args size Connection.edges, not Connection itself
search(q: String!, first: Int!): ProductConnection!
@listSize(slicingArguments: ["first"], sizedFields: ["edges"])
# arbitrary list input: length of the ids argument is the multiplier
productsByIds(ids: [ID!]!): [Product!]! @listSize(slicingArguments: ["ids"])
}
slicingArgumentsis the accurate one: the client's own pagination argument becomes the multiplier, so a query asking for 50 items is scored as 50 items. Without it, every list falls back to a configured default assumed size, and a schema with unpaginated list fields will be both over- and under-estimated.sizedFieldsexists because connection types wrap the list:search(first: 25)should scaleedges, not the single connection object.@cost(weight: N)replaces the default field weight. Use it where work is genuinely disproportionate — an estimate call, an external API hop, a full-text scan — not to punish fields you merely dislike.
Estimate versus actual is a measurable gap. The only difference between them is list size: the estimated run uses assumed sizes, the actual run uses what the subgraphs returned. Run enforcement in measure mode first and compare the two distributions per operation. If search consistently estimates 300 and returns 40, your default assumed size is wrong, not your budget. Tune @listSize on the outliers, then enforce.
Budgets, Errors, and Rollout
Budgets, Errors, and Rollout
A weight model without a ceiling is just telemetry. The budget is a maximum cost per operation: the gateway statically scores the document before execution, and anything over the limit is rejected without a single subgraph call.
# gateway configuration (Apollo Router-style)
demand_control:
mode: enforce # start in measure, flip to enforce after a week
strategy: static_estimated
max: 1000 # per-operation ceiling
list_size: 10 # fallback for list fields with no @listSize
Rejection must be typed and machine-readable, because the client has to do something with it:
{
"errors": [{
"message": "Operation exceeds the cost budget of 1000 (estimated 4820)",
"extensions": { "code": "COST_ESTIMATED_TOO_EXPENSIVE", "cost": 4820, "maxCost": 1000 }
}]
}
With a code, the mobile client can fall back to a lighter document or page results differently; without one, it retries the same query in a loop and turns your budget into an outage. Log the operation name alongside the rejection so you know which client shipped an unbounded query.
Three rollout rules keep this from becoming a support ticket generator:
- Measure before you enforce. A week of cost histograms tells you whether the ceiling is 100 or 10,000, and which operations sit closest to it.
- Budget per operation, not per field. Field-level limits are trivially bypassed by combining ten moderately priced fields — the total is the only number an attacker cannot split.
- Watch the rejection rate as a client-quality metric. A spike means a new client release sends an unbounded document; a permanent trickle means some legitimate query needs a cheaper shape or a higher weight annotation.
Budgets compose with the structural guards rather than replacing them: depth limits reject pathological nesting in microseconds, and cost analysis catches the expensive documents that nest perfectly well.
Practice Problems
Using the weights below, compute the estimated cost of the operation, compare it to a budget of 50, and state the exact response the gateway should return if it enforces. Then compute the cost of the cheapest valid document selecting the same root field.
Example:
Input: schema: products(first: Int!) @listSize(slicingArguments: ["first"]) Product { id=1, name=1, shippingEstimate=@cost(20), reviews @listSize(assumedSize: 5) } Review { rating=1 } operation: products(first: 3) { id name shippingEstimate reviews { rating } } budget: 50
Output: per Product = 1(id) + 1(name) + 20(shippingEstimate) + (1(reviews) + 1(rating)) × 5 = 32 total = 1(products) + 3 × 32 = 97 → rejected (97 > 50) response = errors[].extensions { code: COST_ESTIMATED_TOO_EXPENSIVE, cost: 97, maxCost: 50 } cheapest doc: products(first: 3) { id name } = 1 + 3 × (1+1) = 7 → accepted
The slicing argument makes the list multiplier exact: first: 3 means three products, each costing the sum of its selected fields. Dropping shippingEstimate and reviews removes 30 points per item — the same depth and the same root field, which is exactly why depth limits could not separate 7 from 97.
Optimal Solution — O(F) for F selected fields time, O(D) for recursion depth D space
Traverse the selection tree with a resolver that returns (own weight + child cost × list size), reading list sizes from @listSize slicing arguments and weights from @cost.
function score(node, type, vars) {
const fieldDef = type.getNestedField(node.name.value);
const own = fieldDef.costWeight ?? 1;
if (!node.selectionSet) return own;
let child = 0;
for (const sel of node.selectionSet.selections) {
child += score(sel, fieldDef.type, vars);
}
const size = listSizeOf(fieldDef, node, vars); // @listSize slicingArguments / assumedSize
return own + child * (size ?? 1);
}
// products(first:3){ id name shippingEstimate reviews{rating} }
// = 1 + 3 × (1 + 1 + 20 + (1+1)×5) = 97 → reject when budget is 50 Edge Cases:
- A slicing argument omitted when requireOneSlicingArgument is false falls back to the configured default list size
- Connection types need sizedFields, otherwise the parent object (size 1) is scaled instead of edges
- Variables used in pagination must be resolved before scoring, or the estimate uses a placeholder size
Quiz
1. A list field returns items the client paginates with first: 25, and each item has a sub-selection costing 4 points. What multiplier applies to the sub-selection?
2. Why must the budget be enforced on the operation total rather than on individual fields?
3. What should a server return when an operation exceeds the cost budget?
4. Estimated cost and actual cost differ. What is the sole source of that difference?
Flashcards
Question
Cost model
Click to reveal answer
Answer
Field base weight (default 1) + sub-selection, with list sub-selections multiplied by list size; @cost overrides a field weight.
Question
@listSize options
Click to reveal answer
Answer
assumedSize = fixed estimate; slicingArguments = read client pagination args; sizedFields = apply size to inner list of a connection.
Question
Why budget the total
Click to reveal answer
Answer
Per-field limits are bypassed by combining cheap fields; only the sum reflects the work resolvers will do.
Question
Rollout order
Click to reveal answer
Answer
measure mode → compare estimated vs actual per operation → tune @listSize → enforce → watch rejection rate.
Question
Rejected operation shape
Click to reveal answer
Answer
errors[].extensions carries code, estimated cost, and max so clients can pick a lighter document.
Revision Notes
Key Takeaways
- 1. Cost = weights plus list-size multipliers, computed statically before execution
- 2. Annotate pagination arguments with slicingArguments so estimates track what clients really request
- 3. Measure first, enforce second, and treat rejection rate as a client-quality signal
Interview Tips
- • Score a query by hand on the board: state the weight rules as you go, then compare against the budget — interviewers want the arithmetic, not the definition
- • Explain estimate versus actual in one sentence (list sizes) and say what you would tune when they diverge
Cheat Sheet
Rules
field cost = @cost(weight) or 1 (default)
list field = (own weight + sub-selection) × list size
operation cost = Σ over the selection tree
reject when = operation cost > max
Directives
products(first: Int!): [Product!]! @listSize(slicingArguments: ["first"])
bestsellers: [Product!]! @listSize(assumedSize: 5)
search(q: String!): Connection! @listSize(slicingArguments: ["first"], sizedFields: ["edges"])
shippingEstimate: Int! @cost(weight: 20)
Worked score: products(first:3){ id name shippingEstimate reviews{rating} }1 + 3×(1 + 1 + 20 + (1+1)×5) = 97
Rollout: measure → histogram → tune list sizes → enforce → alert on rejection spikes.