---
title: 'Query Cost and Depth Limit'
source: 'https://academia.sh/en/courses/api-design/query-cost-and-depth-limit'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:29+00:00'
license: 'CC BY-SA 4.0'
---

# Query Cost and Depth Limit

Query depth growing without bound in a cyclic type system, a cost ceiling computed before running, and a measurement showing the depth limit alone is not enough.

Authorization bounded what a query could see, not how much it could ask for. The
Pagination Patterns lesson left this open: even though every field respected its own
limit, the multiplication of nested lists could still grow the response.

The real problem is that the type system is **cyclic**. From a loan record you can go to
its member, from the member to their loans, and from there back to a member again. In
the schema this cycle is a bidirectional relationship spelled out in one line each way;
in a query it can be expanded to any requested depth. This lesson measures the query
before running it and rejects the one that exceeds the bound.

This lesson's parser, flattener, and engine are used exactly as they were written in the
previous lessons; a page-size argument is added to the schema, and the measurement layer
is what is new. The data source holds three members and six loan records.

```js
// data.mjs — 3 members, 6 loans; the type system is cyclic, so depth can be walked without limit
export const MEMBERS = new Map(Array.from({ length: 3 }, (_, i) =>
  [`U-${1001 + i}`, { id: `U-${1001 + i}`, createdAt: "2025-01-01", name: `Member ${i}`, fine: i * 5 }]));

export const LOANS = Array.from({ length: 6 }, (_, i) => ({
  id: `O-${i + 1}`, createdAt: `2026-01-0${i + 1}`, memberId: `U-${1001 + (i % 3)}`,
  status: "OPEN", returnDate: "2026-04-01", branch: "central", note: null,
  items: [{ isbn: "978-0262033848", branch: "central" }],
}));

export const RESOLVERS = {
  __type: { Record: (d) => (d.items ? "Loan" : "Member") },
  Query: {
    loan: (_, a) => LOANS.find((o) => o.id === a.id) ?? null,
    loans: (_, a) => LOANS.slice(0, a.first ?? LOANS.length),
    member: (_, a) => MEMBERS.get(a.code) ?? null,
  },
  Loan: { member: (o) => MEMBERS.get(o.memberId) },
  Member: { loans: (u, a) => LOANS.filter((o) => o.memberId === u.id).slice(0, a.first ?? LOANS.length) },
};
```

The measurement reads how many records a list field will return from its `first`
argument. If the argument is not declared in the schema, a query using it cannot pass
validation; so both list fields are extended with a page-size argument. The argument
being **optional** already sets up the question this lesson will discuss at the end:
what does the measurement rest on when it is not supplied?

```js
// schema-list.mjs — the schema with a page-size argument added to list fields
import { SCHEMA } from "./schema.mjs";

const LIST_FIELD = { type: "[Loan!]!", args: { status: "Status", first: "Int" } };

export const SCHEMA_L = {
  ...SCHEMA,
  types: {
    ...SCHEMA.types,
    Member: { ...SCHEMA.types.Member, fields: { ...SCHEMA.types.Member.fields, loans: LIST_FIELD } },
    Query:  { ...SCHEMA.types.Query,  fields: { ...SCHEMA.types.Query.fields,  loans: LIST_FIELD } },
  },
};
```

## Computing the Cost

A query's cost can be computed before it runs, from just the document and the schema.
The model is simple: the node count a field will produce is the product of the
multiplier coming from the parent level and its own multiplier. A list field's
multiplier is read from the `first` argument; if the argument is not given, a default
the schema anticipates is used.

```js
// cost.mjs — computes a query's depth and cost before running it
// Cost model: the node count a field produces = parent multiplier × its own multiplier.
// A list field's multiplier comes from the "first" argument, or the default otherwise.
import { parseType, fieldDef } from "./schema.mjs";
import { flatten } from "./flatten.mjs";

const LEAF = new Set(["scalar", "enum"]);

export function measure(schema, document, op, { defaultMultiplier = 10, variables = {} } = {}) {
  const root = op.type === "mutation" ? schema.mutation : schema.query;
  const readMultiplier = (a) => {
    const d = a.args.first;
    if (!d) return null;
    return d.kind === "variable" ? variables[d.name] : d.value;
  };

  let deepest = 0, cost = 0;
  const walk = (typeName, selection, depth, parentMultiplier) => {
    deepest = Math.max(deepest, depth);
    for (const a of flatten(schema, document, typeName, selection)) {
      const def = fieldDef(schema.types[typeName], a.name);
      if (!def) continue;
      const t = parseType(def.type);
      const multiplier = parentMultiplier * (t.list ? (readMultiplier(a) ?? defaultMultiplier) : 1);
      cost += multiplier;
      if (!LEAF.has(schema.types[t.name].kind)) walk(t.name, a.selection, depth + 1, multiplier);
    }
  };
  walk(root, op.selection, 1, 1);
  return { depth: deepest, cost };
}

// A query that exceeds the limits is rejected without running.
export function checkLimits(measurement, { maxDepth, maxCost }) {
  const errors = [];
  if (measurement.depth > maxDepth) errors.push(`depth ${measurement.depth}, limit ${maxDepth}`);
  if (measurement.cost > maxCost) errors.push(`cost ${measurement.cost}, limit ${maxCost}`);
  return errors;
}
```

It is worth noticing that the fields the computation walks over come through the
**flattener**: a depth hidden inside a fragment does enter the measurement. If the
measurement were a counter that only looked at the literally written selection set, the
query could be split into fragments to bypass the limit.

## Measurement

```js
// run.mjs — measures queries' depth and cost, applies limits, tests the estimate
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { measure, checkLimits } from "./cost.mjs";
import { SCHEMA_L as SCHEMA } from "./schema-list.mjs";
import { RESOLVERS } from "./data.mjs";

const LIMITS = { maxDepth: 6, maxCost: 1000 };

// Cyclic type system: loan -> member -> loans -> member -> ...
const cycle = (layers) => {
  let inner = "id";
  for (let i = 0; i < layers; i++) inner = `id member { name loans { ${inner} } }`;
  return `{ loans { ${inner} } }`;
};

const QUERIES = {
  "ordinary query": `{ loans { id status member { name } } }`,
  "paged query": `{ loans { id member { name loans { id } } } }`,
  "cycle ×2": cycle(2),
  "cycle ×5": cycle(5),
  "shallow but wide": `{ a: loans(first: 500) { id member { name } } b: loans(first: 500) { id member { name } }
                        c: loans(first: 500) { id member { name } } d: loans(first: 500) { id member { name } } }`,
  "limited query": `{ loans(first: 6) { id status member { name } } }`,
};

// Leaf value count in the response: the real number the estimate is tested against
const countLeaves = (d) =>
  d === null || typeof d !== "object" ? 1 : Array.isArray(d) ? d.reduce((t, x) => t + countLeaves(x), 0)
    : Object.values(d).reduce((t, x) => t + countLeaves(x), 0);

console.log("query             depth  cost  verdict     leaves in response");
for (const [name, text] of Object.entries(QUERIES)) {
  const document = parse(text);
  const measurement = measure(SCHEMA, document, document.operations[0], { defaultMultiplier: 6 });
  const errors = checkLimits(measurement, LIMITS);
  let leaves = "—";
  if (!errors.length) leaves = String(countLeaves((await execute(SCHEMA, RESOLVERS, document)).data));
  console.log(`${name.padEnd(17)} ${String(measurement.depth).padStart(8)}  ${String(measurement.cost).padStart(7)}  ` +
    `${(errors.length ? "REJECT" : "accept").padEnd(9)}  ${leaves.padStart(15)}${errors.length ? "   (" + errors.join("; ") + ")" : ""}`);
}
```

```
query             depth  cost  verdict     leaves in response
ordinary query           3       30  accept                  18
paged query              4       96  accept                  24
cycle ×2                 6      600  accept                  60
cycle ×5                12   130632  REJECT                   —   (depth 12, limit 6; cost 130632, limit 1000)
shallow but wide         3     8000  REJECT                   —   (cost 8000, limit 1000)
limited query            3       30  accept                  18
```

## What the Three Lines Say

**The cycle ×5 line** shows the size of the problem. A cycle expanded five layers deep
reaches a depth of twelve and a cost past one hundred thirty thousand. This query is
shorter than twenty lines and takes a minute to write; if it were run, it would keep the
processor busy for a long time even against a data source with six loan records.
Against real library data, the result is memory exhaustion.

**The shallow-but-wide line** shows why the depth limit is not enough. This query's
depth is three, well under the limit of six; but because each of its four root fields
asks for five hundred records, its cost climbs to eight thousand. A protection that only
looked at depth would let this query through. **Depth is not a shield, it is a ceiling
measure; cost draws the real boundary.**

**The limited-query line** shows the estimate's character. When `first: 6` is given, the
cost computes to thirty, and eighteen leaves come back in the response. The estimate
sits above reality, and it must: cost also counts composite fields, the real leaf count
only the tips. If an accept decision is going to rest on an estimate, the estimate has to
be an **upper bound**; an estimate that falls short lets through queries that should not
pass.

## Where the Limit Should Be Chosen From

Both numbers look arbitrary. The basis for choosing them is measurement.

The cost limit is derived from the load the server can afford: the relationship between
the node count a request produces and the time it spends is measured, and the node count
that corresponds to the longest acceptable response time becomes the limit. The depth
limit, on the other hand, is derived from the deepest query real clients actually use;
the distribution of query depths in the logs is examined, and a value above real usage is
chosen.

The default multiplier is the third and most critical number. There is no way to know
how many records a list field will return when its `first` argument is not given, so the
estimate rests on an assumption. There are two fixes: make the `first` argument
**required** on list fields, or declare a per-field upper bound in the schema. If
neither is done, the default multiplier remains a guess, and the real response can
exceed the estimate — which breaks the basic assumption behind cost protection.

How the rejection gets reported is a separate decision. Because the query never ran,
there is no partial data; the response carries only the error. The error has to report
the measured values and the limits, because what the client needs to do is shrink its
query, and it needs to know what to shrink. An error that just says "query too complex"
leaves the developer to trial and error.

## Summary

- A cyclic type system places no bound on query depth by itself; a short query can be
  expanded to arbitrary depth.
- Cost is computed before running, from just the document and the schema: the node count
  a field produces is the product of the parent multiplier and its own multiplier.
- The measurement happens after fragments are opened; otherwise the query could be split
  into fragments to bypass the limit.
- The depth limit alone is not enough: a query with depth three can multiply its root
  fields to push the cost eight times past the limit.
- The cost estimate must sit above the real leaf count; an estimate that falls short lets
  through queries that should not pass.
- For the default multiplier to be trustworthy, `first` must be required on list fields
  or a per-field upper bound declared in the schema; a rejection must report the
  measured value and the limit.

## Next Step

Everything written up to this point ran inside a single process: the query was a string,
the result an object. A real service receives the query over the network and returns the
response over the network. This is not just a wrapping job. A transport that uses a
single endpoint and, mostly, a single method requires rethinking everything the previous
topic built — address-based caching, status code mapping, the information intermediaries
see. The next lesson puts GraphQL on top of HTTP and measures what this transport
decision does to caching, error reporting, and logging.
