Why GraphQL query complexity needs its own defenses

Query complexity is the amount of work a single GraphQL operation forces the server to do: how many resolvers run, how many rows are fetched and how much response is serialized. REST protections assume each request costs roughly the same, so a per-endpoint rate limit of, say, 100 requests per minute bounds the load. GraphQL breaks that assumption. Every operation hits one endpoint, and its cost depends on the shape of the query, not on the number of requests.

Take a small schema. Query.users(first: Int) returns [User]. User has friends(first: Int): [User] and posts: [Post]. Post has comments: [Comment]. A client can write a few lines that select users(first: 100), then friends(first: 50) twice nested, then posts and their comments.

Illustrative estimate, assuming 20 posts per user and 10 comments per post:

  • 100 users × 50 friends × 50 friends = 250,000 User resolutions
  • × 20 posts = 5,000,000 Post resolutions
  • × 10 comments = 50,000,000 Comment resolutions

Aliases make it worse. Requesting the same field under ten aliases multiplies that branch by ten. Fragments let a deeply nested selection be defined once and spread in several places, so the query text stays short while the work grows. Request-count limits and payload-size limits won't flag any of this, because the request body is small.

The teams that need dedicated defenses most run public, partner and multi-tenant APIs. On those APIs, clients write arbitrary queries and one tenant's expensive query can slow every other tenant. Any schema that exposes nested list fields carries the same risk, even if it is used only internally.

# Schema (SDL): three levels of nested list fields
type Query { users(first: Int): [User] }
type User {
  id: ID!
  friends(first: Int): [User]
  posts: [Post]          # assumed ~20 per user
}
type Post { id: ID! comments: [Comment] }   # assumed ~10 per post
type Comment { id: ID! body: String }

# A short operation whose cost multiplies at every list level.
# Illustrative estimate: 100 x 50 x 50 users, x20 posts, x10 comments
# = 50,000,000 Comment resolutions from one small request body.
query FanOut {
  users(first: 100) {
    friends(first: 50) {
      friends(first: 50) {
        ...PostTree            # the fragment hides the deepest nesting
      }
    }
  }
}

fragment PostTree on User {
  posts {
    comments { body }
  }
}

# Aliases repeat the same expensive branch under new names.
# Each alias adds another full copy of the subtree's work.
query FanOutAliased {
  a: users(first: 100) { friends(first: 50) { ...PostTree } }
  b: users(first: 100) { friends(first: 50) { ...PostTree } }
  c: users(first: 100) { friends(first: 50) { ...PostTree } }
}

A small schema with nested list fields and two short operations, one using a fragment and one using aliases, that expand into millions of resolver calls under the assumed list sizes.

Depth limiting: what it catches and what it misses

Depth limiting is the simplest control: walk the query's selection set, count the levels of nesting, and reject anything past a fixed maximum. It targets recursive traversals. A schema where User.friends returns [User] lets a client write user { friends { friends { friends { ... } } } }. Each level can multiply the number of resolved objects by the average friend count, so a cap of, say, 7 or 10 levels stops that growth before it starts.

The depth must be computed on the query after fragments are expanded. A client can hide nesting inside named fragments that spread into each other, or inside inline fragments on interface types. If the validator counts only the literal braces in the document, a query that looks two levels deep can resolve ten. Spec-compliant validation already rejects fragment cycles, but fragments that spread into one another without forming a cycle are allowed. Your depth rule has to follow every spread to its full length.

Depth tells you nothing about width, so it misses three common patterns:

  • Shallow but wide queries. A two-level query that selects every list field on a root type fans out to many resolvers and data sources without ever going deep.
  • Aliasing. The same expensive field can appear 500 times under different aliases at depth 1. Each alias runs its resolver separately.
  • Large page sizes. orders(first: 10000) is depth 2. The cost comes from the argument value, which a depth count never reads.

Each of these stays under any reasonable depth cap. Depth limiting is useful as a cheap first check, but you need a measure of total work behind it.

import { parse, Kind, SelectionSetNode, FragmentDefinitionNode } from "graphql";

// Max field depth of an operation, computed AFTER expanding fragments.
export function maxDepth(query: string): number {
  const doc = parse(query);
  const frags = new Map<string, FragmentDefinitionNode>();
  for (const d of doc.definitions)
    if (d.kind === Kind.FRAGMENT_DEFINITION) frags.set(d.name.value, d);

  const depth = (set: SelectionSetNode, seen: Set<string>): number => {
    let max = 0;
    for (const sel of set.selections) {
      if (sel.kind === Kind.FIELD) {
        // A field is one level; its sub-selection adds more.
        max = Math.max(max, 1 + (sel.selectionSet ? depth(sel.selectionSet, seen) : 0));
      } else if (sel.kind === Kind.INLINE_FRAGMENT) {
        // Inline fragments (e.g. on interfaces) add no level, but their fields do.
        max = Math.max(max, depth(sel.selectionSet, seen));
      } else {
        // Follow every named spread to its full length; guard against cycles.
        const f = frags.get(sel.name.value);
        if (f && !seen.has(f.name.value))
          max = Math.max(max, depth(f.selectionSet, new Set(seen).add(f.name.value)));
      }
    }
    return max;
  };

  let result = 0;
  for (const d of doc.definitions)
    if (d.kind === Kind.OPERATION_DEFINITION) result = Math.max(result, depth(d.selectionSet, new Set()));
  return result;
}

// Caught: nesting hidden in chained fragments resolves to depth 5, not 2.
maxDepth(`{ user(id: 1) { ...A } } fragment A on User { friends { ...B } }
  fragment B on User { friends { ...C } } fragment C on User { friends { name } }`); // 5

// Missed: aliased fan-out and huge page sizes both stay at depth 2.
maxDepth(`{ a: search(q: "x") { id } b: search(q: "y") { id } c: search(q: "z") { id } }`); // 2
maxDepth(`{ orders(first: 10000) { id total } }`); // 2

A graphql-js depth calculator that expands named and inline fragments before counting, catching nesting hidden in chained fragments while scoring repeated aliases and a 10,000-item page request at depth 2.

How static cost analysis works

Static cost analysis supplies that measure. It runs on the parsed query before any resolver executes. Each field gets a base cost. List fields multiply the cost of their children by the size argument (first, last, limit). When the client omits that argument, the analyzer uses the field's default or maximum page size. Skipping the argument must never make a query cheaper.

The rule is recursive: cost(field) = base + multiplier × Σ cost(children), where the multiplier is 1 for non-list fields.

Illustrative estimate (assumed numbers, not a benchmark). Assume scalars cost 0, objects cost 1, and the query is users(first: 50) { name posts(first: 20) { title author { name } } }:

  1. author = 1 + 0 = 1
  2. Each post = 1 + 1 (author) = 2. posts = 20 × 2 = 40
  3. Each user = 1 + 40 = 41. users = 50 × 41 = 2,050

If this is your heaviest legitimate client query, a budget around 2× (about 4,000) leaves headroom without admitting first: 1000 variants.

Use a few defaults. Scalars cost near zero. Objects and lists cost more. Fields that call an external service or run a slow query get extra weight because their real expense is outside your process.

Approach Where costs live Strength Risk
Schema directives/annotations Next to field definitions Reviewed with schema changes; visible to tooling Depends on directive support in your server library
Separate cost map Server code, keyed by type.field Works with any schema source Drifts silently as fields are added

With either approach, add a CI check that diffs the schema. The check should fail the build when a new field has no explicit cost. An implicit default then becomes a choice someone made on purpose.

// Minimal static cost analyzer over a pre-parsed field tree (no resolvers run).
type Field = { key: string; args?: Record<string, number>; children?: Field[] };
type Cost = { base: number; maxPage?: number }; // maxPage set => list field

// Separate cost map keyed by Type.field. Scalars ~0, objects 1.
const COSTS: Record<string, Cost> = {
  "Query.users": { base: 1, maxPage: 100 },
  "User.name": { base: 0 },
  "User.posts": { base: 1, maxPage: 50 },
  "Post.title": { base: 0 },
  "Post.author": { base: 1 }, // raise this if it calls an external service
};

function cost(f: Field): number {
  const c = COSTS[f.key];
  // No silent default: an unknown field fails, same rule the CI check enforces.
  if (!c) throw new Error(`No explicit cost for ${f.key}`);
  const childSum = (f.children ?? []).reduce((s, ch) => s + cost(ch), 0);
  if (c.maxPage === undefined) return c.base + childSum; // multiplier = 1
  const requested = f.args?.first ?? f.args?.last ?? f.args?.limit;
  // Omitted size falls back to the max, so skipping it never lowers the cost.
  const size = Math.min(requested ?? c.maxPage, c.maxPage);
  return size * (c.base + childSum); // base is charged per list item
}

// users(first: 50) { name posts(first: 20) { title author { name } } }
const query: Field = {
  key: "Query.users", args: { first: 50 }, children: [
    { key: "User.name" },
    { key: "User.posts", args: { first: 20 }, children: [
      { key: "Post.title" },
      { key: "Post.author", children: [{ key: "User.name" }] },
    ] },
  ],
};

const BUDGET = 4000; // ~2x the heaviest legitimate query (2,050)
const total = cost(query);
console.log(total, total <= BUDGET ? "accept" : "reject"); // 2050 accept

A self-contained TypeScript cost calculator that multiplies child costs by first/last/limit, falls back to the maximum page size when the size argument is missing, rejects fields with no explicit cost, and reproduces the 2,050-point worked example against a 4,000-point budget.

Implementing the layered defense in the request pipeline

Order the checks so that each one is cheaper than the one after it. An attacker should hit a fast rejection before reaching anything that costs real CPU.

  1. Size limits on the raw request. Cap the body size and the document's byte length, then cap the token count during lexing. These run before cost analysis because analysis has to walk the document. A hostile query with thousands of nested fragments, or a megabyte of aliases, burns CPU in the analyzer itself. Rejecting on size bounds that work before it starts.
  2. Parse and validate. Standard spec validation runs next, so later checks only see well-formed documents.
  3. Shape limits. Require pagination arguments on list fields and clamp them to a maximum. Cap aliases per selection and root fields per operation. Cap the number of operations in a single batched HTTP request, so that batching cannot multiply every per-query budget.
  4. Depth and cost analysis, still before any resolver runs.

When a query exceeds its budget, reject it with a structured error. The error states the computed cost and the limit, so the client author knows how far over they are. In development, also return the cost in response extensions on successful queries, so engineers see costs while they write queries.

Disclosure should depend on the audience. Internal and partner clients can see the per-field weights. Anonymous public clients should get only the total and the budget, because publishing the full formula makes it easier to craft queries that sit just under the limit.

Static analysis is an estimate, so keep runtime safeguards behind it. Set an execution timeout per operation, and add concurrency limits on expensive resolvers. Together they cap the damage when the cost model underestimates a query.

import { parse, validate, specifiedRules, execute, GraphQLError, GraphQLSchema } from 'graphql';
// Local helpers (not shown): shape checks, depth walk, and the cost model.
import { checkShape, maxDepth, estimateCost } from './limits';

const LIMITS = { bodyBytes: 64_000, docBytes: 16_000, tokens: 2_000, batch: 5, depth: 10, cost: 1_000, timeoutMs: 5_000 };

function reject(message: string, extensions: Record<string, unknown> = {}) {
  return { errors: [new GraphQLError(message, { extensions })] };
}

export async function handle(rawBody: string, schema: GraphQLSchema, audience: 'internal' | 'public', dev: boolean) {
  // 1. Cheapest checks first: bytes and batch size, before any GraphQL work.
  if (Buffer.byteLength(rawBody) > LIMITS.bodyBytes) return reject('Request body too large');
  const payload = JSON.parse(rawBody);
  const ops = Array.isArray(payload) ? payload : [payload];
  if (ops.length > LIMITS.batch) return reject('Too many operations in batch');

  return Promise.all(ops.map(async ({ query, variables }) => {
    if (typeof query !== 'string' || Buffer.byteLength(query) > LIMITS.docBytes) return reject('Document too large');
    let doc;
    try { doc = parse(query, { maxTokens: LIMITS.tokens }); } // lexer stops at the token cap
    catch (e) { return { errors: [e] }; }
    // 2. Spec validation, then 3. shape limits (pagination args, aliases, root fields).
    const errors = [...validate(schema, doc, specifiedRules), ...checkShape(schema, doc)];
    if (errors.length) return { errors };
    // 4. Depth and cost, still before any resolver runs.
    if (maxDepth(doc) > LIMITS.depth) return reject('Query too deep', { maxDepth: LIMITS.depth });
    const { total, perField } = estimateCost(schema, doc, variables);
    if (total > LIMITS.cost) {
      // Public clients see total and budget only; internal clients also get field weights.
      return reject('Query cost exceeds budget', { cost: total, budget: LIMITS.cost, ...(audience === 'internal' ? { perField } : {}) });
    }
    // Runtime backstop: the static estimate can be wrong. Expensive resolvers
    // should also acquire a per-resolver semaphore (not shown) to cap concurrency.
    const timeout = new Promise<never>((_, r) => setTimeout(() => r(new Error('Execution timed out')), LIMITS.timeoutMs));
    const result = await Promise.race([execute({ schema, document: doc, variableValues: variables }), timeout]).catch((e) => reject(e.message));
    return dev ? { ...result, extensions: { cost: total, budget: LIMITS.cost } } : result;
  }));
}

A request handler that applies the checks in order of cost: size and token limits, then validation and shape limits, then depth and cost analysis, then execution behind a timeout, with cost details disclosed according to the audience.

Persisted queries, allowlists and cost-based rate limiting as alternatives and complements

Persisted queries close that gap for first-party clients. At build time, the client's operations are extracted, registered with the server and replaced by an identifier, typically a hash of the document. At runtime the client sends only the identifier and its variables. The server knows every operation in advance, so it can cost-check each one once, at registration, and an expensive query fails in CI instead of in production.

That guarantee holds only in strict allowlist mode, where the server refuses unknown identifiers and raw query strings. Opportunistic hash caching is different. The client sends a hash and falls back to the full document on a cache miss. This saves bandwidth, but the server still accepts arbitrary queries, so it provides no protection.

Cost-based rate limiting serves APIs that must accept arbitrary queries. Each client gets a budget of complexity points per time window, and each request deducts its computed cost. A request-counting limiter treats a trivial lookup and a deep nested query as equal, while a cost budget does not. Return the remaining budget and the reset time on every response so clients can slow down before they hit the limit. When the budget runs out, reject the request with an explicit error and a retry delay rather than dropping it silently.

Defense Stops Misses Suits
Network rate limiting Request floods Single expensive queries Every API, as a baseline
Depth limit Deep nesting Wide, shallow fan-out Any API, as a cheap first check
Static cost limit Over-budget single queries Many mid-cost queries Public and partner APIs
Strict allowlist All unknown operations Nothing outside the registered set First-party-only APIs
Cost-based rate limiting Sustained expensive usage Cost model inaccuracies Public, metered APIs

Calibrating the cost model and rolling it out safely

A cost model is a guess until you compare it with production. For every operation, log the computed cost next to observed execution time and the number of downstream calls (database queries, service requests). Group by operation name and look for divergence. A field weighted at 1 that triggers a full-text search on every resolve is underpriced. A list field weighted at 10 per item that is almost always served from a warm cache is overpriced. Adjust weights where the gap is consistent, not where one slow request skewed the numbers.

Some of the gap will never close, because static analysis cannot see runtime behavior:

  • Cache hits make an expensive field cheap on most requests.
  • DataLoader batching collapses N lookups into one, so per-item multipliers overstate the real fan-out.
  • Actual result sizes are often far below the first: 100 the client requested.

Treat the computed cost as an upper bound on work, not a prediction of it.

Rollout path

  1. Run the analyzer in log-only mode and collect the cost distribution of real production traffic.
  2. Set budgets above legitimate peak usage, so no current client breaks on day one.
  3. Enforce for anonymous or third-party traffic first, where you have the least control and the least context.
  4. Widen enforcement to first-party clients gradually, watching rejection logs at each step.

Illustrative estimate (assumed numbers, not a benchmark):

budget = p99.9 observed cost × headroom

Assume a p99.9 cost of 1,200 and a headroom factor of 1.5. The budget is 1,800. Any legitimate query that gets rejected at that level points to a weight that needs recalibration.

When to avoid it, trade-offs, and impact on cost and engineering time

Not every API needs every layer, and some layers do harm in the wrong context.

Technique Skip or relax when Main risk
Size limits, timeouts Never; they are the baseline Timeouts fire only after the work has started
Depth limiting Schema has no cycles Misses wide, shallow queries
Static cost analysis Small internal API with few known clients Over-estimation rejects valid queries. Under-estimation admits expensive ones
Strict allowlist Public or partner API Every new client query needs a server release
Per-client budgets No reliable client identity A shared token or IP penalises everyone behind it

Budgets that are too tight generate support tickets. Budgets that are too loose block nothing. Charging the default maximum list size when a client omits first rejects queries that would return a handful of rows. Consider a smaller default page size instead.

IDE, codegen and introspection queries are deep by design. Exempt them for authenticated developer tokens rather than raising the global budget. Disabling introspection in production is debated. It hides the schema from casual probing, but the schema often leaks through shipped clients, and it does nothing to bound the cost of a hand-written query.

The ongoing tax is real. Every new field needs a reviewed weight, and budgets need retuning. Against that, you get fewer incident investigations, capacity planning in cost units, and earlier feedback when clients see cost in extensions.

Illustrative estimate (assumed numbers, not a benchmark): net hours per quarter = incidents avoided × hours per investigation − schema changes × review hours − tuning hours. With 3 × 8 − 40 × 0.25 − 6, the result is 8 hours saved.

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.