GraphQL Depth Limiting and Query Complexity: Practical Defenses

Protect GraphQL APIs with depth limits and query complexity analysis—patterns, code samples, and a production‑ready checklist.

ASOasis
7 min read
GraphQL Depth Limiting and Query Complexity: Practical Defenses

Image used for representation purposes only.

Why depth and complexity limits matter

GraphQL’s power is also its risk. A single query can traverse many relationships, request deeply nested selections, and fan out across large lists. Left unchecked, this flexibility can be abused—intentionally or accidentally—leading to expensive database fan‑outs, N+1 storms, and denial‑of‑service incidents. Two complementary controls address this: depth limiting and query complexity analysis.

  • Depth limiting caps how far a query may nest.
  • Complexity analysis assigns a “cost” to a query based on the fields and arguments used, then enforces a maximum budget.

Used together, they form a practical baseline for protecting performance, controlling compute spend, and preserving predictable SLAs.

The anatomy of risky GraphQL queries

Risk usually scales with some mix of:

  • Depth: nested relationships like user → posts → comments → author → …
  • Breadth: large lists with wide selections (many fields per node)
  • Multipliers: arguments such as first, last, limit, pageSize
  • Repetition: aliases and fragments that repeat subtrees
  • Expensive fields: aggregations, complex resolvers, or remote calls

Depth-only policies miss shallow-but-heavy queries, while naive cost models can undercount pathological nesting. A layered approach solves this.

Depth limiting, explained

Depth limiting computes the longest path from the operation root (Query/Mutation/Subscription) to any leaf field in the selection set, following fragments and inline fragments.

Benefits:

  • Simple mental model
  • Fast to evaluate during validation
  • Immediately blocks “deep recursion” patterns

Limitations:

  • Doesn’t reflect list sizes or field costs
  • Can over-restrict legitimate but deep queries (e.g., graph traversal tools)

Example: enforcing max depth in Node.js

Below is a minimal Express + graphql-js style example that adds a depth limit as a validation rule. The same idea applies in frameworks that expose validation hooks.

import express from 'express';
import { createHandler } from 'graphql-http/lib/use/express';
import { buildSchema, specifiedRules, validate, parse } from 'graphql';
import depthLimit from 'graphql-depth-limit';

const schema = buildSchema(`
  type User { id: ID!, name: String!, friends(first: Int): [User!]! }
  type Query { me: User! }
`);

const app = express();

app.use('/graphql', express.json(), async (req, res) => {
  const { query, variables, operationName } = req.body;
  const document = parse(query);
  const errors = validate(schema, document, [...specifiedRules, depthLimit(8)]);
  if (errors.length) return res.status(400).json({ errors });
  return createHandler({ schema })(req, res);
});

app.listen(4000);

Recommendations:

  • Set a sensible default (e.g., 6–10) and adjust by client tier.
  • Consider an allowlist or a bypass directive for internal operations you own.

Query complexity: putting a price on fields

Query complexity models the “work” a server must perform. Each field contributes a cost. For list fields, the cost usually scales with the number of items requested. Child selections increase cost, too.

A common, effective approach:

  • Assign a default cost (e.g., 1) to scalar fields.
  • Assign multiplicative cost to connections/lists based on arguments (e.g., first × child cost).
  • Increase weight for expensive resolvers (e.g., calls to third‑party services or heavy aggregations).

Annotating complexity in the schema

With graphql-js, you can annotate field configs via extensions and use a complexity estimator during validation.

import {
  GraphQLObjectType,
  GraphQLInt,
  GraphQLList,
} from 'graphql';

const UserType = new GraphQLObjectType({
  name: 'User',
  fields: () => ({
    id: { type: GraphQLInt },
    name: { type: GraphQLInt },
    friends: {
      type: new GraphQLList(UserType),
      args: { first: { type: GraphQLInt } },
      // Complexity grows with the requested page size and nested selection
      extensions: {
        complexity: ({ args, childComplexity }) => {
          const page = args.first ?? 10; // default multiplier
          return page * childComplexity;
        },
      },
    },
  }),
});

Enforcing a complexity budget during validation

The snippet below demonstrates computing complexity for each request and rejecting queries that exceed a budget.

import { parse, validate, specifiedRules, execute } from 'graphql';
import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity';

const MAX_COMPLEXITY = 1000;

app.post('/graphql', async (req, res) => {
  const { query, variables, operationName } = req.body;
  const document = parse(query);

  // 1) Standard GraphQL validation (syntax, schema adherence, etc.)
  const validationErrors = validate(schema, document, specifiedRules);
  if (validationErrors.length) return res.status(400).json({ errors: validationErrors });

  // 2) Complexity calculation
  const complexity = getComplexity({
    schema,
    query: document,
    variables,
    operationName,
    estimators: [
      fieldExtensionsEstimator(), // respects extensions.complexity
      simpleEstimator({ defaultComplexity: 1 }), // fallback for unannotated fields
    ],
  });

  if (complexity > MAX_COMPLEXITY) {
    return res.status(400).json({
      errors: [{
        message: `Query is too complex: ${complexity}. Maximum allowed: ${MAX_COMPLEXITY}. ` +
                 `Reduce nesting, page sizes, or selected fields.`,
      }],
    });
  }

  // 3) Safe to execute under the budget
  const result = await execute({ schema, document, variableValues: variables, operationName });
  return res.status(200).json(result);
});

Notes:

  • Compute complexity after validation but before execution.
  • Record the measured complexity for observability (see Monitoring below).
  • Return a 400 (validation error) or 413 (payload too large) when over budget. Consistency matters more than the exact code—document your contract for clients.

Designing a cost model that fits your domain

Effective complexity models are domain-aware:

  • Pagination-aware multipliers: Multiply by first/last/limit. Provide defaults to avoid “free” unlimited costs when the client omits pagination.
  • Compound lists: For fields returning lists of lists, multiply across levels cautiously; cap at a sane upper bound to avoid explosive totals.
  • Expensive resolvers: Weight fields that hit secondary indexes, do regex filters, compute aggregates, or call external services (e.g., +50–200 each call).
  • Optional fields: Keep scalar default cost small (1–2) so clients don’t avoid legitimate fields.
  • Subscriptions: Apply a separate, tighter budget or whitelist because cost accrues over time.

A good heuristic: choose a ceiling that typical product queries stay under (e.g., <200), and set MAX_COMPLEXITY at 5–10× that number to allow room for power users while still catching pathological requests.

Combining controls: a layered policy

A pragmatic baseline for production GraphQL APIs:

  1. Depth limit: 6–10
  2. Complexity budget: 500–2000 (domain‑dependent)
  3. Rate limit: token- or IP-based (e.g., requests/minute and concurrent requests)
  4. Execution guardrails:
    • Timeouts per resolver and per request
    • Bounded concurrency for resolvers hitting backends
    • Circuit breakers for flaky dependencies
  5. Pagination rules: require first/last on connection fields and cap their maximums
  6. Persisted/allowlisted queries for public or high-traffic surfaces

These layers complement each other: depth blocks deep recursion, complexity reins in breadth and expensive fields, rate limiting dampens abuse bursts, and execution guardrails keep tail latencies in check.

Handling fragments, aliases, and introspection

  • Fragments and aliases: Your complexity and depth logic must traverse fragment spreads and count aliased duplicates. Attackers may replicate large subtrees via aliases to inflate work.
  • Introspection: Consider excluding introspection queries from budgets for developer tooling, or gate them behind authentication/roles. For public APIs, disable introspection in production or serve it from a separate, rate‑limited endpoint.
  • Directives: If you implement custom directives (e.g., @include), evaluate both branches for worst‑case cost or apply the directive semantics during estimation.

Error design and client guidance

When rejecting a query:

  • Return a clear message detailing measured depth/complexity and the limit.
  • Suggest concrete fixes: reduce nesting, request fewer fields, lower first/last, or split the query.
  • For authenticated clients, include their current per‑role budgets and hints to upgrade/adjust.

Example message: “Query complexity 1,487 exceeds plan budget 1,000. Reduce users.first from 200 to ≤50, or fetch posts in a follow‑up query.”

Monitoring and tuning in production

Track per-request:

  • Depth, complexity, resolver timings, database round‑trips
  • Query signature (AST hash), operationName, client identity
  • Rejection counts and reasons

Use this data to:

  • Tune defaults: adjust MAX_COMPLEXITY and depth caps based on p95/p99 usage
  • Identify heavy fields: rank by total cost contribution and add weights or caches
  • Spot regressions: alert when average complexity or DB calls spike after deployments

Testing and CI hardening

  • Golden queries: Maintain a corpus of representative queries from each client. Assert they remain under budget after schema changes.
  • Fuzz tests: Generate random nested selections to ensure depth/complexity enforcement is stable under adversarial shapes.
  • Performance budgets: Fail CI if a pull request increases estimated complexity of key operations beyond agreed thresholds.

Beyond limits: complementary patterns

  • DataLoader and batching: Collapse N+1 patterns without relaxing limits.
  • Caching: Per-field caching (short TTL) for expensive resolvers; whole‑query caching for persisted queries.
  • Persisted/allowlisted queries: For public traffic, only serve known query IDs. This drastically reduces attack surface area.
  • Field-level authorization: Combine complexity with auth to avoid “paying” for fields that will be rejected.

A quick policy checklist

  • Set a global depth cap and revisit quarterly
  • Enforce a complexity budget; annotate list fields with multipliers
  • Require pagination args and cap maximums
  • Add timeouts, concurrency limits, and circuit breakers
  • Log depth, complexity, and resolver timings
  • Provide actionable error messages and docs for clients
  • Consider persisted queries for high‑traffic/public endpoints

Putting it all together

Depth limiting and query complexity are not silver bullets—but together they create a robust, comprehensible defense-in-depth for GraphQL. Start simple: choose conservative defaults, instrument your server, and iterate. Over time, your cost model will mirror real backend work, giving you stable performance, predictable costs, and happier clients.

Related Posts