Skip to content
advanced Phase 14 · Performance, Caching, and Production Hardening

Query Cost Analysis and Budgets

Assigning point costs to fields and rejecting operations that exceed a budget.

50m
1 problems
Topic Progress 0%

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"])
}
  • slicingArguments is 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.
  • sizedFields exists because connection types wrap the list: search(first: 25) should scale edges, 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:

  1. 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.
  2. 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.
  3. 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

0 / 1 solved
Score an Operation Against a Cost Budget
Cost Analysis

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?

Question 1 options

2. Why must the budget be enforced on the operation total rather than on individual fields?

Question 2 options

3. What should a server return when an operation exceeds the cost budget?

Question 3 options

4. Estimated cost and actual cost differ. What is the sole source of that difference?

Question 4 options

Flashcards

Question

Cost model

Answer

Field base weight (default 1) + sub-selection, with list sub-selections multiplied by list size; @cost overrides a field weight.

Question

@listSize options

Answer

assumedSize = fixed estimate; slicingArguments = read client pagination args; sizedFields = apply size to inner list of a connection.

Question

Why budget the total

Answer

Per-field limits are bypassed by combining cheap fields; only the sum reflects the work resolvers will do.

Question

Rollout order

Answer

measure mode → compare estimated vs actual per operation → tune @listSize → enforce → watch rejection rate.

Question

Rejected operation shape

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.

Continue Learning