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.
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:
- Depth limit: 6–10
- Complexity budget: 500–2000 (domain‑dependent)
- Rate limit: token- or IP-based (e.g., requests/minute and concurrent requests)
- Execution guardrails:
- Timeouts per resolver and per request
- Bounded concurrency for resolvers hitting backends
- Circuit breakers for flaky dependencies
- Pagination rules: require first/last on connection fields and cap their maximums
- 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
Stop the N+1 Spiral: The GraphQL DataLoader Batching Pattern, Explained
A practical guide to GraphQL DataLoader batching: fix N+1, design robust loaders, TypeScript examples, caching, observability, and production pitfalls.
GraphQL Introspection: Risks, Abuse Patterns, and Production-Ready Mitigations
Understand GraphQL introspection risks and how to secure production APIs: restrict or disable introspection, enforce allowlists, rate limits, and robust auth.
Accelerating GraphQL with Persisted Queries: Caching, APQ, and Real-World Wins
Boost GraphQL performance with persisted queries: smaller payloads, CDN caching, observability, and security—with rollout tips and pitfalls.