Lesson 33 / 34
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.
Contents
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.
// 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?
// 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.
// 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
// 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,
firstmust 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.