Why GraphQL needs query-level limits

A GraphQL query complexity limit is a server-side check that estimates how much work a query will cause before any resolver runs, and rejects it if that estimate exceeds a budget. It exists because the cost of a GraphQL request is decided by the client's query shape, not by the endpoint or the payload size.

Consider a query that asks for 50 users, each user's 50 posts, and each post's 50 comments. The request body is a few hundred bytes. Executing it can mean 50 × 50 × 50 = 125,000 comment objects, plus the resolver calls and database lookups behind them (illustrative arithmetic, not a measurement). A rate limiter counting requests per minute sees one request. A body size check sees a tiny payload. Neither sees the fan-out, which is where the CPU, memory, and database load actually come from.

Who needs it most

  • Public APIs and third-party access. You don't control the queries. Some will be expensive by accident, and some will be expensive on purpose.
  • Schemas with nested list relationships. Users to posts to comments, or orgs to teams to members. Each list multiplies the cost of every list nested inside it. Cycles in the graph let a client nest those lists as deep as it wants.
  • Shared backends. One heavy query can degrade latency for every other tenant.

When it can wait

A small internal API used only by your own clients, with queries reviewed in code, has a bounded set of query shapes. In that case, monitoring resolver timings may be enough for now. Add limits before you open the schema to outside callers, not after the first incident.

import { parse } from 'graphql';

// Tiny body, huge fan-out: 50 users x 50 posts x 50 comments.
const query = `
  query {
    users(first: 50) {
      posts(first: 50) {
        comments(first: 50) { id body }
      }
    }
  }
`;

// Naive estimate: each field costs 1, multiplied by the list sizes above it.
// Simplified on purpose: ignores fragments, variables and per-field weights.
function estimate(selectionSet, multiplier = 1) {
  let total = 0;
  for (const sel of selectionSet.selections) {
    if (sel.kind !== 'Field') continue;
    const first = sel.arguments.find((a) => a.name.value === 'first');
    const size = first && first.value.kind === 'IntValue' ? Number(first.value.value) : 1;
    const count = multiplier * size;
    total += count;
    if (sel.selectionSet) total += estimate(sel.selectionSet, count);
  }
  return total;
}

const op = parse(query).definitions[0];
const cost = estimate(op.selectionSet);
const MAX_COST = 10000; // placeholder budget, calibrate against real traffic

// A body-size check sees a few hundred bytes; the cost estimate sees the fan-out.
console.log(`bytes=${Buffer.byteLength(query)} estimatedCost=${cost}`);
if (cost > MAX_COST) {
  console.log('reject: over budget, no resolver has run yet');
}

A naive complexity estimator built on graphql-js parse() shows a few-hundred-byte query scoring far above a placeholder budget, so it can be rejected before execution.

How it works: depth limits and static complexity analysis

In short, a complexity limit works by inspecting the query document, not by running it. The server parses the query text into an abstract syntax tree (AST), validates it against the schema, and then walks that tree. During the walk it assigns each field a cost, multiplies the cost of nested selections by the list sizes the client asked for, and adds everything up. If the total is above the configured budget, the server returns an error and execution never starts. If it is within budget, the query proceeds to the resolvers as normal. Because all of this happens on the AST, the decision costs one parse and one tree walk, regardless of how much data the query would have touched. A depth limit is the simplest version of the same idea: it measures only how deeply selections nest.

A depth limit is the cheapest check you can add. After parsing, walk the selection set and reject any query that nests deeper than N levels. To choose N, look at your schema and your real clients. Find the deepest query a legitimate client sends, then add one or two levels of headroom. If your deepest product query is viewer → orders → items → product → reviews, a limit around seven gives room without allowing deep cyclic traversals like user → friends → friends → friends….

Depth alone is not enough. A query can be two levels deep and still ask for products(first: 1000) { reviews(first: 1000) }, which is a million rows. Aliasing makes it worse. One request can repeat the same expensive top-level field fifty times under different alias names, and its depth never changes.

Static complexity analysis covers this gap. Give each field a cost: scalars near zero, and fields backed by a resolver or a database call higher. When a field takes a size argument such as first or limit, multiply the cost of its child selection by that value. Then sum the costs across the whole tree, counting each alias separately. The server computes this score from the parsed AST before any resolver runs, so a rejected query costs almost nothing.

request
  │
  ▼
parse ──► validate ──► depth check ──► complexity check ──► execute
            │              │                  │
            ▼              ▼                  ▼
         reject         reject             reject

Run the cheap checks first, in this order, and reject a query as soon as it fails one.

       request: query text + variables
       │
       ▼
┌──────────────┐   ┌──────────────┐   ┌──────────────┐   ┌──────────────┐
│    parse     │──►│   validate   │──►│ depth check  │──►│  complexity  │
│  text → AST  │   │  vs. schema  │   │  depth ≤ N   │   │ score ≤ max  │
└──────┬───────┘   └──────┬───────┘   └──────┬───────┘   └──┬────────┬──┘
       │ fail             │ fail             │ fail         │ fail   │ pass
       ▼                  ▼                  ▼              ▼        ▼
┌─────────────────────────────────────────────────────────────┐ ┌────────────┐
│ reject with an error; no resolver has run                   │ │  execute   │
│ cost so far: parse + one AST walk                           │ │ resolvers  │
└─────────────────────────────────────────────────────────────┘ └────────────┘

 complexity score from the AST (illustrative costs:
 resolver field = 1, scalar = 0, child cost × first)

   products(first: 100) {         1 + 100 × 1  =   101
     reviews(first: 10) {         1 +  10 × 0  =     1
       body                       0
   same field under 50 aliases    50 × 101     = 5,050  → compare to max

Each request passes through parse, validate, a depth check, and a static complexity check, which scores the AST by multiplying child costs by list-size arguments and summing aliases separately. A failure at any stage rejects the query before a resolver runs.

Building a cost model that reflects real resolver cost

Static analysis is only as good as the costs it adds up. A useful model has four parts:

  • Default field cost. Give every field a cost of 1. This covers scalars and objects resolved from data the parent already loaded.
  • Expensive fields. Fields that trigger a database query, a downstream service call, or a computation get a higher cost, such as 10. Set these from what the resolver actually does, not from how the schema looks.
  • Required, capped pagination. List fields multiply the cost of their children by the requested size. That only works if the size is known, so require first and enforce a maximum, such as 100. Reject values above the cap rather than silently clamping them, so clients learn the real limit.
  • Fallback size. For lists that have no size argument, assume a conservative size, such as 50. Otherwise those lists count as size 1, and that becomes the bypass.

Illustrative estimate (assumed numbers, not a benchmark). Take users(first: 50) { orders(first: 20) { shippingQuote } }. Assume each user and order costs 1, shippingQuote calls an external service and costs 10, and the limit is 5,000.

Each field's cost is the product of the list sizes above it multiplied by its own cost:

  • users: 50 × 1 = 50
  • orders: 50 × 20 × 1 = 1,000
  • shippingQuote: 50 × 20 × 10 = 10,000

The total is 11,050, so the query is rejected. A depth limit would pass it, because the query is only three levels deep.

Making first required breaks every client that omits it. Migrate in stages:

  1. Ship the fallback size first.
  2. Log which operations omit the argument.
  3. Mark the omission as deprecated.
  4. Make the argument required once traffic shows those operations are gone.
directive @cost(weight: Int!) on FIELD_DEFINITION
directive @listSize(
  sizeArg: String       # argument that sets the page size
  max: Int              # requests above this are rejected, not clamped
  assumedSize: Int      # fallback when no size argument is given
) on FIELD_DEFINITION

type Query {
  # Required and capped: the analyzer always knows the multiplier.
  users(first: Int!, after: String): [User!]!
    @listSize(sizeArg: "first", max: 100)
}

type User {
  id: ID!                 # default cost 1: already loaded with the parent
  name: String!
  # Migration stage: `first` is still optional. Omitted values are scored
  # as 50 and logged. Make it Int! once logs show no callers omit it.
  orders(first: Int, after: String): [Order!]!
    @cost(weight: 1)
    @listSize(sizeArg: "first", max: 100, assumedSize: 50)
  # No size argument: scored as 50 so it can't count as size 1.
  addresses: [Address!]! @listSize(assumedSize: 50)
}

type Order {
  id: ID!
  total: Float!
  # Calls an external shipping service on every resolve.
  shippingQuote: Float @cost(weight: 10)
}

type Address {
  line1: String!
  city: String!
}

A GraphQL schema with custom cost directives: a default cost of 1, weight 10 on the externally backed shippingQuote, a required and capped first on users, and an assumed size of 50 for the orders field that is still being migrated and for the unpaginated addresses list.

Closing common bypasses

Analyzers that pass unit tests often fail against a hostile client, because the client controls the document's shape.

Bypass Why it gets through Fix
Aliases a: search(...), b: search(...) repeat one field many times; an analyzer that keys by field name counts it once Cost every selection node, not every unique field name
Fragment spreads A small fragment spread in many places expands far beyond the raw text size Cost the expanded tree, and memoize each fragment's cost
Fragment cycles Mutually referencing fragments make naive expansion loop forever Run spec validation, which rejects cycles, before cost analysis
Batched operations An array of operations in one HTTP request resets a per-operation budget Apply the budget to the whole request and cap batch length
Introspection Introspection queries nest deeply through type references Cost them like any other query

The analysis itself costs CPU. Parsing, validating, and walking a multi-megabyte document of nested fragments is real work. If that work happens before any limit applies, the cost check becomes the denial-of-service target. Enforce a maximum request body size and a maximum token count inside the parser, so oversized documents are rejected before an AST is built.

Disabling introspection in production breaks schema-aware tooling and does not hide the schema. Field suggestions in error messages, client bundles, and plain guessing all expose it. Treat it as a small reduction in exposure, not a security boundary.

Error detail matters too. A response such as "cost 1,043 exceeds limit 1,000" lets an attacker probe the cost model and tune queries to land just under the limit. Return exact figures only to trusted clients, and send a generic rejection to everyone else.

import { Lexer, Source, TokenKind, Kind, parse, validate } from 'graphql';

const MAX_BODY_BYTES = 100_000, MAX_TOKENS = 5_000, MAX_BATCH = 10, BUDGET = 1_000;
const reject = (detail, trusted) => ({ ok: false, error: trusted ? detail : 'Query rejected' });

// Count tokens with the lexer and stop early, so no AST is built for oversized documents.
function tooManyTokens(text) {
  const lexer = new Lexer(new Source(text));
  for (let n = 0; lexer.advance().kind !== TokenKind.EOF; ) if (++n > MAX_TOKENS) return true;
  return false;
}

// Cost every selection node, so aliased repeats each count. Memoize fragments so spreads stay cheap to cost.
// Production code would also weight list fields by their pagination arguments.
function cost(set, frags, memo) {
  let total = 0;
  for (const sel of set.selections) {
    if (sel.kind === Kind.FIELD) total += 1 + (sel.selectionSet ? cost(sel.selectionSet, frags, memo) : 0);
    else if (sel.kind === Kind.INLINE_FRAGMENT) total += cost(sel.selectionSet, frags, memo);
    else {
      const name = sel.name.value;
      if (!memo.has(name)) memo.set(name, cost(frags[name].selectionSet, frags, memo));
      total += memo.get(name);
    }
  }
  return total;
}

export function checkRequest(rawBody, schema, trusted = false) {
  if (Buffer.byteLength(rawBody) > MAX_BODY_BYTES) return reject('Body too large', trusted);
  const body = JSON.parse(rawBody);
  const ops = Array.isArray(body) ? body : [body];
  if (ops.length > MAX_BATCH) return reject('Batch too long', trusted);
  let spent = 0; // one budget for the whole HTTP request, not one per operation
  for (const { query } of ops) {
    if (typeof query !== 'string' || tooManyTokens(query)) return reject('Too many tokens', trusted);
    const doc = parse(query);
    // Spec validation runs first; it rejects fragment cycles and unknown fragments.
    if (validate(schema, doc).length > 0) return reject('Invalid document', trusted);
    const frags = Object.fromEntries(doc.definitions.filter(d => d.kind === Kind.FRAGMENT_DEFINITION).map(d => [d.name.value, d]));
    const memo = new Map();
    // Introspection operations go through the same costing path as any other operation.
    for (const d of doc.definitions) if (d.kind === Kind.OPERATION_DEFINITION) spent += cost(d.selectionSet, frags, memo);
    if (spent > BUDGET) return reject(`cost ${spent} exceeds limit ${BUDGET}`, trusted);
  }
  return { ok: true, cost: spent };
}

A graphql-js request gate that caps body size, batch length and lexer token count before parsing, then runs spec validation, costs every selection node with memoized fragments against one budget per request, and returns exact cost figures only to trusted clients.

Calibrating the limit from production traffic

Don't pick the threshold by intuition. Deploy the analyzer in observe-only mode first: compute the cost of every operation, but never reject. For each request, log the operation name or persisted-query hash, the computed cost, the measured wall-clock latency, the number of resolver invocations, and the client identifier. A week or two of data usually covers weekly batch jobs and month-end reporting, which a single day will miss.

Next, plot computed cost against measured latency and resolver count. The correlation tells you whether the cost model works. If expensive-looking queries run fast and cheap-looking ones are slow, fix the per-field weights before you enforce anything. Static scores are only estimates. They cannot see how many rows a filter actually matches, whether a DataLoader batch or cache absorbed the work, or whether the database was under load from something else.

Set the limit from the distribution of legitimate traffic. A typical choice is a margin above the highest-cost operation your known clients send, not a high percentile, because the tail is where dashboards and CSV exports live. If the limit is too strict, those break, and the people who own them find out first. If it is too loose, it blocks only queries no real client would send, which protects little. When a few heavy internal operations sit far above everything else, give them a per-client allowance or a persisted-query exemption instead of raising the global ceiling.

Calibration doesn't end after the first pass. New fields arrive with default weights, and resolvers change their backing queries, so the cost model drifts. Keep the logging running after enforcement, and re-check the cost-versus-latency fit whenever the schema changes.

Alternatives and complementary layers

A complexity limit is one layer. The others cover gaps it cannot see, and one of them can make it unnecessary.

Persisted queries and operation allowlisting replace the free-form query with a hash or ID that the server maps to a pre-registered document. If you control every client, such as your own web and mobile apps, an allowlist is stronger than any cost model. Nothing unreviewed runs, so you check cost once at registration. The price is coupling. Client builds must extract and upload operations before release, and the server must keep old operation IDs as long as older mobile versions are still installed. Public APIs that accept arbitrary third-party queries cannot use this approach, so complexity analysis remains their main defence.

Layer Protects against Misses
Persisted queries / allowlist Any unreviewed query Public APIs; abusive call rates
Depth limit Deep recursive nesting Wide, shallow queries; large page sizes
Complexity limit Expensive single queries before execution Cost-model drift; slow backends at low cost
Per-request timeout Runaway execution of any kind Wasted work done before the cutoff
Resolver-level limits Unbounded lists, oversized arguments Combined cost across fields
DataLoader-style batching N+1 fan-out to backends Queries that are legitimately large
Cost-based rate limiting Many cheap-looking queries over time Single expensive bursts

Cost-based rate limiting charges each client the computed complexity of its queries against a budget instead of counting requests. One heavy query then uses the same budget as many light ones, which removes the incentive to split abusive work into many small requests.

Rollout, operations, and impact on engineering cost

Rolling it out

Start in log-only mode. Compute cost on every request and record it, but don't reject anything yet. When enforcement begins, return an error that includes the computed cost, the limit, and the fields that contributed most. A client engineer can then fix the query without opening a ticket. Store field costs with the schema and review them in the same pull request, so a new list field cannot ship with a default cost of one. Alert on the rejection rate per client rather than on the total. A spike from one client usually means a release added an expensive query. Give trusted internal clients and batch jobs their own, higher budgets, and identify them by authenticated identity rather than by a header anyone can set.

What it costs and what it saves

The ongoing work has three parts:

  • Field costs need upkeep. Every schema change needs its costs reviewed.
  • Pagination caps break clients. Clients that relied on unbounded lists have to migrate.
  • Persisted queries need a pipeline. Someone has to build and maintain registration and deployment.

In return, runaway queries are rejected before they become incidents. Logged cost points straight at the expensive operation during an outage. Capacity planning can work from cost per second instead of request counts, which says little about load.

How it saves engineering time

The time savings come from moving work earlier, from incident response to code review and client development:

  • Fewer incidents to investigate. Without a limit, an expensive query is usually found after it has already slowed the database, and someone has to work backwards from slow-query logs to the GraphQL operation that caused them. With a limit, the query is rejected at the edge, and the rejection log already names the operation, the client, and the computed cost.
  • Faster diagnosis when something does go wrong. Per-operation cost logs let an on-call engineer sort traffic by cost and find the culprit directly, instead of correlating resolver traces with database load by hand.
  • Self-service fixes for client teams. An error that reports the cost, the limit, and the heaviest fields tells the client engineer what to change, such as a smaller first or removing an expensive field. That replaces a support ticket and a back-and-forth with the API team.
  • Review in the pull request, not in the postmortem. Because field costs live with the schema, the question "how expensive can this new list field get?" is answered when the field is added, rather than discovered in production.
  • Less ad hoc hardening. One general mechanism replaces a series of one-off fixes, such as special-case caps added to individual resolvers after each incident.

Illustrative estimate (assumed inputs, not a measured result). Assume a team handles 3 query-related performance incidents per quarter, and each one takes 4 engineers about 3 hours to triage, trace to an operation, and fix. That is 3 × 4 × 3 = 36 engineer-hours per quarter. Assume the cost model takes 40 hours to build and calibrate, plus about 1 hour of cost review per schema change at 8 schema changes per quarter. Under those assumptions, the upfront work is recovered in roughly two quarters if the limit prevents most of those incidents. Replace every input with your own figures. The comparison only holds if your incidents are actually caused by query shape rather than by other load.

When to defer

Defer complexity limits if your API serves only first-party clients that already ship persisted queries. An allowlist already blocks arbitrary queries. Also defer if traffic is too low for any single query to threaten your datastore. In either case, a depth limit and a request timeout are enough until those conditions change.

When to avoid complexity limits

Deferring means "not yet". Some situations are a poor fit for a complexity limit, and adding one there costs more than it protects:

  • Every operation is already allowlisted. If the server executes only registered persisted queries and rejects free-form text, cost each operation once at registration time. A per-request complexity check then repeats work that was already done and adds a second set of limits to maintain.
  • The schema has little or no list nesting. If the graph is mostly flat, with single-object lookups and small bounded lists, query cost is close to constant. A depth limit, a body size cap, and a timeout cover the realistic risks.
  • Nobody will maintain the cost model. Weights that are never reviewed drift away from what resolvers actually do. A stale model blocks legitimate queries that look expensive and admits slow ones that look cheap. That gives you broken clients and a false sense of safety. If no team owns the weights, a request timeout and resolver-level caps are more honest protection.
  • You have no traffic data to calibrate against. Enforcing a threshold picked by intuition tends to break dashboards and exports first. Run in observe-only mode until you have the data, and don't enforce before then.
  • The expensive work is a known, scheduled internal job. Reporting and export jobs that run under authenticated service identities are better controlled by scheduling, dedicated replicas, or a per-client budget than by a global complexity limit tuned around them.

In each of these cases, the cheaper layers in the table above (allowlisting, depth limits, timeouts, and resolver-level caps) give most of the protection with less upkeep. Revisit the decision when you open the schema to third parties or add deeply nested list relationships.

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 limit, 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.