Why GraphQL query complexity needs its own limits
In a REST API, the server decides what each endpoint returns, so one request costs roughly the same every time. GraphQL gives that choice to the client. A single POST to one endpoint can ask for a few scalar fields or for a tree of related objects many levels wide. GraphQL query complexity is the amount of work a query asks the server to do: resolver calls, database or downstream lookups, and the size of the response it has to build.
Per-request rate limiting assumes requests are roughly equal, and here they aren't. Take a query only three levels deep: fetch users(first: 100), and for each user fetch posts(first: 50), and for each post fetch comments(first: 20). Nothing about it looks unusual, but the list sizes multiply. As an illustrative estimate using those assumed page sizes, that is 100 users, 5,000 posts and 100,000 comments from one request. A limit of 60 requests per minute treats it the same as a query that fetches one user's name.
This matters for anyone exposing GraphQL to untrusted or semi-trusted clients. That includes public APIs, mobile and single-page apps whose queries can be extracted and modified, and partner integrations. A shallow query can be expensive and still be completely valid against the schema, so this is a resource problem as much as a security one.
The approach in this guide is to treat each query as a priced request. Estimate its cost before execution, charge it against a budget, and reject or throttle it based on the work it asks for rather than the number of requests.
// A shallow, schema-valid query: only three list levels deep.
const query = `
query FeedDump {
users(first: 100) {
name
posts(first: 50) {
title
comments(first: 20) {
body
}
}
}
}
`;
// Page sizes requested at each nested list level (taken from the query above).
const levels = [
{ field: "users", first: 100 },
{ field: "posts", first: 50 },
{ field: "comments", first: 20 },
];
// Each level runs once per parent item, so the worst-case counts multiply.
let parents = 1;
let totalObjects = 0;
for (const { field, first } of levels) {
const count = parents * first;
console.log(`${field}: up to ${count.toLocaleString("en-US")} objects`);
totalObjects += count;
parents = count;
}
// One HTTP request, one rate-limit token, but about 105,100 objects to resolve.
console.log(`total: ${totalObjects.toLocaleString("en-US")} objects in 1 request`);
// Output (illustrative estimate, assuming every page is full):
// users: up to 100 objects
// posts: up to 5,000 objects
// comments: up to 100,000 objects
// total: 105,100 objects in 1 request
A three-level query whose first arguments multiply to about 105,100 objects in the worst case, while a per-request rate limiter counts it as a single request.
Who should use query complexity limits
Complexity limits are worth the effort when you do not control, or cannot fully trust, the queries reaching your server:
- Teams running public GraphQL APIs. Third-party developers write arbitrary queries, so you cannot review them in advance. Cost analysis is the main way to price their work.
- Backends for web and mobile apps. Even if you wrote every query, anyone can read them from the client bundle or network traffic, modify them, and replay them against your endpoint.
- Partner and B2B integrations. Partners often write broad queries for syncs and exports. Budgets keep one partner's batch job from degrading service for everyone else.
- Gateways in front of expensive backends. If resolvers call databases, search clusters, or metered third-party services, a cost model lets you charge for that work instead of discovering it during an incident.
- Platform and API teams responsible for shared capacity. Per-client cost budgets give them a concrete, explainable policy to point to when a client is throttled.
Static depth and size guards are cheap enough that almost any production GraphQL server should have them. Full cost analysis with weights and per-client budgets is aimed at the groups above.
How query complexity limiting works
The mechanism has three layers, applied in order. Each one is covered in detail in the sections below.
- Static guards before and during parsing. The server rejects documents that are too large in bytes or tokens, then validates the parsed AST against the schema. Validation includes rejecting cyclic fragments. It then walks the AST to enforce a maximum nesting depth and a cap on total fields, counting aliases. None of this runs a resolver.
- Static cost analysis before execution. The analyzer walks the same AST and assigns each field a weight. List fields multiply the cost of their children by the requested page size, and a missing size argument is treated as the schema maximum. The total is compared to a per-query ceiling and deducted from the client's budget for the current time window. Over-budget queries are rejected with an error reporting the computed cost.
- Runtime backstops during execution. Resolver timeouts, an overall execution deadline, concurrency caps per client, and dataloader-style batching handle what static analysis cannot see, such as real list lengths and slow dependencies.
The static layers work because a GraphQL document declares everything it will fetch before any data is read. The server can price that declaration without touching a database. The runtime layer exists because the price is an estimate.
Depth limits and other cheap static guards
Depth limiting is the simplest guard. After parsing, the server walks the AST and counts how deeply selection sets nest, following fragment spreads into their definitions, and rejects the operation if any path exceeds a fixed maximum. This targets cyclic relationships such as user -> friends -> friends -> friends, where each level can multiply the result set.
Depth says nothing about breadth. A query only three levels deep can alias the same expensive field hundreds of times, select every field on a wide type, or request first: 10000 on a connection. Apply these cheap guards before or alongside depth:
| Guard | What it stops | Blind spot |
|---|---|---|
| Max document size / token count | Huge payloads that are costly to parse | Small queries that are expensive to execute |
| Depth limit | Deep recursion through cycles | Wide, aliased or large-page queries |
| Alias and total-field caps | Repeated expensive fields | Large page-size arguments |
| Fragment spread limits, cyclic-fragment rejection | Fragment blow-up and infinite expansion | Cost of the resolvers themselves |
Order matters because analysis uses CPU. Size and token caps must run before parsing completes, or the guard becomes the attack surface. Per-operation limits also fail when HTTP batching sends fifty operations in one request, so budgets must apply to the whole request.
A low depth limit breaks legitimate nested UIs. A high one protects little. Granting exceptions to individual operations erodes the guarantee, because every exempt query becomes a path an attacker can reuse.
import { parse, validate, specifiedRules, Kind, GraphQLSchema,
SelectionSetNode, FragmentDefinitionNode } from "graphql";
// Illustrative limits; tune them against your real client queries.
const MAX_BYTES = 10_000, MAX_TOKENS = 2_000, MAX_DEPTH = 8;
const MAX_FIELDS_PER_REQUEST = 500; // shared by every operation in a batch
type Stats = { fields: number; depth: number };
// Expand fragments as executed. Aliases count as separate fields.
// Bail out at the cap so a fragment fan-out cannot make this walk slow.
function walk(set: SelectionSetNode, frags: Map<string, FragmentDefinitionNode>, d: number, s: Stats): void {
s.depth = Math.max(s.depth, d);
for (const sel of set.selections) {
if (++s.fields > MAX_FIELDS_PER_REQUEST) throw new Error("too many fields in request");
if (sel.kind === Kind.FIELD && sel.selectionSet) walk(sel.selectionSet, frags, d + 1, s);
else if (sel.kind === Kind.INLINE_FRAGMENT) walk(sel.selectionSet, frags, d, s);
else if (sel.kind === Kind.FRAGMENT_SPREAD) {
const f = frags.get(sel.name.value);
if (f) walk(f.selectionSet, frags, d, s);
}
}
}
// One HTTP request can carry several operations, so the budget is per request.
export function checkRequest(schema: GraphQLSchema, batch: string[]): void {
const total: Stats = { fields: 0, depth: 0 };
for (const query of batch) {
// Cheapest checks first: byte size, then a token cap enforced inside the parser.
if (Buffer.byteLength(query, "utf8") > MAX_BYTES) throw new Error("document too large");
const doc = parse(query, { maxTokens: MAX_TOKENS });
// The spec rules include NoFragmentCyclesRule, so cyclic fragments fail before walk().
const errors = validate(schema, doc, specifiedRules);
if (errors.length) throw errors[0];
const frags = new Map(doc.definitions.filter((d): d is FragmentDefinitionNode =>
d.kind === Kind.FRAGMENT_DEFINITION).map((d) => [d.name.value, d]));
for (const def of doc.definitions) {
if (def.kind !== Kind.OPERATION_DEFINITION) continue;
const op: Stats = { fields: total.fields, depth: 0 };
walk(def.selectionSet, frags, 1, op);
if (op.depth > MAX_DEPTH) throw new Error(`depth ${op.depth} exceeds ${MAX_DEPTH}`);
total.fields = op.fields; // carry the field count into the next operation
}
}
}
A graphql-js pre-execution guard checks byte size first and caps tokens during parsing. It then rejects cyclic fragments through the spec validation rules and enforces a depth limit on each operation. A field budget that counts aliases is shared across the whole batched request.
How static cost analysis works
Static cost analysis addresses this by measuring what a query asks for rather than how it is shaped. The server walks the parsed document before running any resolver. Each field gets a weight, typically 1 for a scalar or single object and more for a field that hits a slow backend. A list field multiplies the cost of its children by its size argument, such as first or limit. If the client omits the argument, the analyzer should assume the schema's maximum page size. Otherwise clients can skip the argument to get a lower score than their actual cost.
Illustrative estimate (assumed numbers, not a benchmark). Assume each field costs 1 and the client requests users(first: 100) { posts(first: 100) { comments(first: 100) { id } } }.
Cost = n₁ + n₁·n₂ + n₁·n₂·n₃ = 100 + 10,000 + 1,000,000 ≈ 1,010,100 nodes.
That is only three levels deep. Compare an eight-level chain of single-object fields, such as viewer → organization → owner → profile → …. That query costs about 8. A depth limit would reject the cheap query and admit the expensive one. A cost limit reverses both decisions.
The result is compared to a budget, and over-budget queries are rejected with an error that reports the computed cost. Because this happens before execution, a rejected query never touches a database. The full pipeline:
parse → size guards → depth check → cost calculation → budget check → execution (runtime limits)
The static score is still an estimate, because real list lengths are often smaller than the requested page size. Runtime limits at the end of the pipeline catch cases where the estimate is wrong.
import { parse, Kind, SelectionSetNode } from "graphql";
const MAX_PAGE_SIZE = 100; // schema's largest allowed page
const BUDGET = 10_000; // max cost accepted per request
// A real analyzer reads list-ness from the schema; hardcoded here for brevity.
const LIST_FIELDS = new Set(["users", "posts", "comments"]);
function cost(set: SelectionSetNode | undefined, parentCount: number): number {
if (!set) return 0;
let total = 0;
for (const sel of set.selections) {
if (sel.kind !== Kind.FIELD) continue; // fragments omitted for brevity
let size = 1;
if (LIST_FIELDS.has(sel.name.value)) {
const arg = sel.arguments?.find(
(a) => a.name.value === "first" || a.name.value === "limit"
);
// Missing or variable size: assume the worst case, not the best.
size = arg && arg.value.kind === Kind.INT
? Math.min(parseInt(arg.value.value, 10), MAX_PAGE_SIZE)
: MAX_PAGE_SIZE;
}
const nodes = parentCount * size;
// Leaf scalars weigh 0 here so the total matches the estimate above.
total += (sel.selectionSet ? nodes : 0) + cost(sel.selectionSet, nodes);
}
return total;
}
export function checkBudget(query: string): void {
const doc = parse(query); // runs before any resolver
const op = doc.definitions.find((d) => d.kind === Kind.OPERATION_DEFINITION);
if (!op || op.kind !== Kind.OPERATION_DEFINITION) throw new Error("No operation");
const score = cost(op.selectionSet, 1);
if (score > BUDGET) {
throw new Error(`Query cost ${score} exceeds budget ${BUDGET}`);
}
}
// checkBudget("{ users(first: 100) { posts(first: 100) { comments(first: 100) { id } } } }")
// -> throws: Query cost 1010100 exceeds budget 10000
A minimal static cost analyzer built on graphql-js's parse(). It multiplies child costs by first/limit, assumes the maximum page size when the argument is missing, and rejects over-budget queries before execution.
Designing and maintaining the cost model
A useful cost model starts with defaults that match where work actually happens. A scalar read from an already-loaded parent costs almost nothing. A resolver that issues a database query or calls a remote service is where latency and contention come from, so it should carry most of the weight.
| Field kind | Example default | Reasoning |
|---|---|---|
| Scalar on a loaded object | 0 | No extra I/O |
| Object field resolved from parent | 1 | Small cost, but enables nesting |
| Resolver hitting a DB or remote service | 5–10 | Round trip, pool or quota usage |
| List field | page size × per-item cost | Multiplies everything beneath it |
Lists need bounds. Require first/limit on connections, reject values above a cap during validation, and give legacy unbounded lists an assumed size so the analyzer never has an open-ended multiplier.
Illustrative estimate (assumed numbers, not a benchmark): cost = page size × (item cost + nested cost). For friends(first: 50) with item cost 1 and a nested DB-backed posts field at 5, cost = 50 × (1 + 5) = 300.
That score is the same for a user with 10 friends, which overcharges them. On an uncapped legacy list with an assumed size of 50, a user with 10,000 friends is undercharged by a factor of 200. Static cost is an estimate. Weights also drift: a resolver that read from cache last quarter may call a service today. Keep weights in schema directives or a config file next to the schema, and review weight changes as part of schema changes. A single reviewed source of costs is cheaper to maintain than per-endpoint patches added after each incident.
// cost.config.ts: lives next to schema.graphql and changes in the same PR.
export const DEFAULTS = { scalar: 0, object: 1, io: 5 };
export const MAX_PAGE = 100; // validation rejects first/limit above this
export const ASSUMED_LIST = 50; // legacy lists with no page argument
// Per-field overrides keyed by "Type.field". Revisit these when a resolver's I/O changes.
export const FIELD_COST: Record<string, number> = {
"User.friends": DEFAULTS.object,
"User.posts": DEFAULTS.io, // one DB query per user
"User.recommendations": 10, // remote service call, counts against quota
};
interface Sel {
key: string; // "Type.field"
isList?: boolean;
args?: { first?: number };
children?: Sel[];
}
export function cost(sel: Sel): number {
const own = FIELD_COST[sel.key] ?? (sel.children ? DEFAULTS.object : DEFAULTS.scalar);
const nested = (sel.children ?? []).reduce((sum, c) => sum + cost(c), 0);
if (!sel.isList) return own + nested;
const first = sel.args?.first;
if (first !== undefined && first > MAX_PAGE) {
throw new Error(`${sel.key}: first=${first} exceeds cap ${MAX_PAGE}`);
}
// Static estimate: the page size or assumed size, not the real row count.
return (first ?? ASSUMED_LIST) * (own + nested);
}
// friends(first: 50) { name posts } => 50 * (1 + 0 + 5) = 300
console.log(cost({
key: "User.friends", isList: true, args: { first: 50 },
children: [{ key: "User.name" }, { key: "User.posts" }],
}));
A cost config kept beside the schema assigns default and per-field weights, rejects page sizes above a cap, substitutes an assumed size for unbounded lists, and reproduces the 300-point estimate for friends(first: 50).
Cost budgets, rate limiting and runtime backstops
A per-query ceiling stops one expensive request but not a client sending thousands of moderately expensive ones. Request-count rate limiting misses this as well, because ten cheap lookups and ten deep list queries count the same. Instead, give each client or token a cost budget per time window and deduct each query's computed cost. A token bucket fits well: points refill at a steady rate, so short bursts pass but sustained load is capped.
Return the computed cost and remaining budget in the response extensions field. Clients can then see which operations are expensive and trim fields or page sizes before they hit limits.
Illustrative estimate (assumed numbers, not a benchmark): required budget per minute = cost per query × queries per refresh × refreshes per minute. Assume a dashboard with 4 panels, each running a 250-point query every 30 seconds: 250 × 4 × 2 = 2,000 points per minute. A 1,000-point default budget would throttle it.
Static analysis cannot see real data sizes or slow dependencies, so keep runtime backstops:
- per-resolver timeouts
- an overall execution deadline per request
- a cap on concurrent in-flight requests per client
Use dataloader-style batching so that N child fetches become one backend call. Without batching, real load scales with result size in ways the cost model does not capture.
Separate budgets per client tier add configuration to review and support work whenever limits change. Dashboards and internal tools usually break first, so inventory them and measure their actual cost before you enforce budgets.
Alternatives: persisted queries, allowlists and introspection hygiene
If you control every client, you can skip judging arbitrary queries and refuse to run them at all. With persisted queries or an operation allowlist, the build pipeline extracts each operation from the client code and registers it by hash. In production the server executes only registered hashes. An attacker can still replay your own operations with hostile variables, so pagination caps still matter. But they cannot write a new query, which removes most of the attack surface that cost analysis exists to cover.
The price is deploy coupling. A client release has to register its operations before it ships. Old mobile builds keep sending old hashes for months, so the registry needs retention rules and an owner. That model fails for public APIs, where third-party developers write queries you never see in advance.
Disabling introspection in production is obscurity, not security. The schema leaks through client bundles anyway. Turning it off also breaks IDE tooling and makes life harder for external developers. Suppressing "did you mean" field suggestions in error messages is cheap and worth doing, because they let anyone rebuild the schema field by field.
| Control | Protection strength | Fits | Maintenance cost |
|---|---|---|---|
| Allowlist / persisted queries | High | Private, first-party | Registry, deploy coupling |
| Static cost analysis | Medium–high | Public | Cost model upkeep |
| Disabling introspection | Low | Private only | Tooling friction |
| Suppressing suggestions | Low | Both | Negligible |
On a fully allowlisted private API, you can review each operation's cost once at registration, so runtime cost analysis is unnecessary.
When to avoid full cost analysis
A weighted cost model with per-client budgets is not always the right tool. Skip it, or limit yourself to the cheap static guards and runtime backstops, in these cases:
- Every operation is allowlisted. If the server only executes registered persisted queries, review each operation's cost at registration and cap pagination variables. Runtime cost scoring adds maintenance without adding much protection.
- The API is internal and the schema changes constantly. When all clients belong to your organization and the schema changes weekly, weights go stale faster than they prevent problems. Depth, size and field caps plus execution deadlines are usually enough until the schema settles.
- Resolvers are uniformly cheap and lists are already bounded. If every list has a hard server-side cap and resolvers read from memory or a single batched query, cost scores will barely differ between operations. A simple field cap covers most of the risk.
- You cannot observe traffic before enforcing. Enforcing budgets without a log-only period means guessing thresholds, and a guess that is too low breaks legitimate clients. Gather data first.
- The real bottleneck is elsewhere. If incidents come from N+1 resolver patterns or missing indexes, cost limits only hide the symptom. Fix batching and data access first, since the cost model assumes they are reasonable.
Avoid depth limits as the only control on a public API. As shown above, depth admits wide, expensive queries and rejects deep, cheap ones.
Rolling out limits safely and what it costs to run
Rollout, step by step
- Run in log-only mode. Compute depth and cost for every operation but reject nothing. Record both values with the operation name and client identifier so every outlier can be traced to an owner.
- Observe a full business cycle. Include weekly batch jobs and month-end reports. Look at high percentiles per client, not only the global maximum. One integration's routine query can look like an attack in another client's traffic.
- Set thresholds with headroom. Put each limit well above the highest legitimate value you observed. Before you enforce, contact the owners of any operation that comes close to the limit.
- Enforce with stable error codes. Return a fixed, documented code in the error's
extensions, along with the computed cost and the limit. Clients write logic against the code, so never rename it. The message text can change freely.
When to introduce limits
Add limits before opening the API to external clients. Once third parties depend on it, you can no longer coordinate threshold changes with every consumer. After an incident, enforce immediately against the offending query shape, but run the general budget in log-only mode first. Hold back when every client is internal and the schema changes weekly. In that situation, cost weights go stale faster than they prevent problems.
What it costs to run
The upfront cost is modelling plus a review step on every schema change. The return is fewer incident investigations, clear rejection messages for clients instead of timeouts, and less firefighting over expensive queries.
Illustrative estimate (assumed numbers, not a benchmark). Net hours saved per year = (incidents avoided × hours per investigation) − (initial modelling hours + review hours per change × changes per year)
With assumed inputs: (5 × 12) − (40 + 0.2 × 100) = 60 − 60 = 0.
Under these assumptions, the first year roughly breaks even. Later years skip the 40 hours of initial modelling, so the net saving grows.
How complexity limits save engineering time
Most of the time saved comes from moving work out of incident response and into routine review:
- Shorter incident investigations. Without cost scoring, engineers have to work backwards from database load or latency graphs to find which query caused it. With cost and operation names logged per request, the expensive operation and its client are already identified.
- Fewer incidents. Over-budget queries are rejected before execution, so a runaway query from a buggy client or a scraper is answered with an error instead of a degraded database. Fewer pages means fewer interrupted engineers.
- Less back-and-forth with client teams. A stable error code with the computed cost and the limit tells a client developer exactly what to change. A generic timeout leads to support tickets and debugging sessions on both sides.
- Self-service optimization. Returning cost and remaining budget in
extensionslets client developers find expensive operations during development, before they reach production. - One place to change policy. Keeping weights next to the schema and reviewing them in the same change replaces ad hoc fixes after each incident, such as per-resolver caps and special-case rate limits, which accumulate and become hard to reason about.
- Capacity planning with real inputs. Per-client cost data shows which consumers drive load, so budget and scaling discussions start from logged numbers rather than guesses.
The time savings depend on how often expensive queries currently cause trouble. Use the formula in the previous section with your own incident history and change rate to check whether the effort pays off for your API.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to graphql query complexity, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.