graphql-query-complexity-esm
v2.0.3
Published
GraphQL query complexity analysis validation rule with estimator chains, directive support, and iterative engine
Maintainers
Readme
graphql-query-complexity-esm
A deeply nested or fan-out GraphQL query can burn through resources that simple rate limits won't catch. graphql-query-complexity-esm scores every field and rejects queries over budget before a single resolver runs.
- Validation rule (
complexityLimit): plugs into any server's validate pipeline - Programmatic API (
getComplexity,getComplexityBreakdown): analyze costs outside validation - Estimator chains: first estimator returning a finite number wins; return
undefinedto defer - Built-in estimators:
simpleEstimator(flat cost) andfieldExtensionsEstimator(@complexitydirective /field.extensions.complexity) - Directive-aware: honors
@skipand@include - Fragment support: named fragments, inline fragments, per-path cycle protection
- Node-count guard: configurable
maxNodes(default10_000) prevents AST explosion - Typed error codes:
ESTIMATOR_ERROR,NODE_LIMIT_EXCEEDED,QUERY_TOO_COMPLEX - TypeScript: ships
.d.tsdeclarations, works with plain JS too - ESM + CJS dual publish
Interactive Demo
Try it live or run locally:
cd examples/visualization
npm install
npm run devPreset queries (simple lookups through exponential fan-out), an animated scan showing per-field costs, and a detail panel for inspecting cost formulas.
Requirements
- Node.js
>=20.0.0 - Peer dependency:
graphql ^16.0.0
Installation
npm install graphql-query-complexity-esm graphqlpnpm add graphql-query-complexity-esm graphqlyarn add graphql-query-complexity-esm graphqlQuickstart
import { buildSchema, parse, specifiedRules, validate } from "graphql";
import { complexityLimit, simpleEstimator } from "graphql-query-complexity-esm";
const schema = buildSchema(`
type Query {
users(limit: Int): [User!]!
}
type User {
id: ID!
name: String!
}
`);
const rule = complexityLimit(1000, {
estimators: [simpleEstimator({ defaultComplexity: 1 })],
variables: {},
});
const document = parse(`query { users(limit: 10) { id name } }`);
const errors = validate(schema, document, [...specifiedRules, rule]);
if (errors.length > 0) {
console.error("Query rejected:", errors[0].message);
}API Reference
complexityLimit(maxComplexity, options?, callback?)
Returns a validation rule that rejects queries over the given complexity score.
maxComplexity: required, positive integer.
Options:
| Option | Type | Default | Validation |
|---|---|---|---|
| defaultComplexity | number | 1 | Non-negative integer |
| estimators | ComplexityEstimator[] | [simpleEstimator({ defaultComplexity })] | Non-empty array of functions |
| maxNodes | number | 10_000 | Positive integer |
| variables | Record<string, unknown> | {} | Plain object |
Callback:
Optional ComplexityCallback, called on document leave when no error was reported. Receives a ComplexityByOperation map (operation name to score).
Anonymous operations get deterministic keys: "[anonymous]", then "[anonymous:2]", "[anonymous:3]", etc.
const rule = complexityLimit(
1000,
{
estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
variables: request.variables ?? {},
},
(complexities) => {
for (const [name, cost] of Object.entries(complexities)) {
console.log(`${name}: ${cost}`);
}
},
);getComplexity(options) / getComplexityBreakdown(options)
Calculate complexity outside the validation pipeline.
| Option | Type | Required | Default | Validation |
|---|---|---|---|---|
| estimators | ComplexityEstimator[] | yes | — | Non-empty array of functions |
| query | string \| DocumentNode | yes | — | String or DocumentNode |
| schema | GraphQLSchema | yes | — | GraphQL schema instance |
| maxNodes | number | no | 10_000 | Positive integer |
| variables | Record<string, unknown> | no | {} | Plain object |
getComplexity()returns the highest score across all operations.getComplexityBreakdown()returns a frozenComplexityByOperationmap.
Both throw QueryComplexityValidationError on parse/validation failures:
import {
getComplexity,
QueryComplexityValidationError,
simpleEstimator,
} from "graphql-query-complexity-esm";
try {
const cost = getComplexity({
estimators: [simpleEstimator({ defaultComplexity: 1 })],
query: `{ users { id name } }`,
schema,
});
console.log("Query cost:", cost);
} catch (error) {
if (error instanceof QueryComplexityValidationError) {
// error.errors: readonly GraphQLError[]
// error.message: all messages joined with newline
console.error(error.errors);
}
}fieldExtensionsEstimator()
Reads cost from field.extensions.complexity or the @complexity directive.
Resolution order (first match wins):
field.extensions.complexityas a finite number →value + childComplexityfield.extensions.complexityas{ value: number, multipliers?: string[] }→ cost formula@complexitydirective on the field definition → cost formula- Returns
undefined(defers to the next estimator)
Cost formula:
cost = value + (product of multiplier argument values, default 1) * childComplexity// Programmatic extensions (code-first schemas)
field.extensions = { complexity: 10 }; // flat number
field.extensions = { complexity: { value: 2, multipliers: ["limit"] } }; // with multipliers# Directive (SDL-first schemas) - add complexityDirectiveTypeDefs to your schema
type Query {
users(limit: Int): [User!]! @complexity(value: 2, multipliers: ["limit"])
}simpleEstimator(options?)
Fixed base cost per field, plus child complexity.
defaultComplexity: base cost per field (default1)- Formula:
cost + childComplexity
Note: This estimator does not account for list multipliers. Fields returning lists (e.g.
users(limit: 100)) receive the same cost as scalar fields. UsefieldExtensionsEstimatoror a custom estimator for accurate list costing.
Custom Estimators
An estimator receives field context and returns a cost (number) or undefined to defer. Evaluated in order: first finite number wins.
import type { ComplexityEstimator } from "graphql-query-complexity-esm";
/** Assigns a higher base cost to fields that return list types. */
const listPenaltyEstimator: ComplexityEstimator = ({
childComplexity,
field,
type,
}) => {
const returnType = field.type;
const isList = returnType.toString().startsWith("[");
if (!isList) return undefined; // defer to next estimator
return 5 + childComplexity;
};
// Chain with built-in estimators (first match wins):
const estimators = [
fieldExtensionsEstimator(), // check extensions/directives first
listPenaltyEstimator, // then apply list penalty
simpleEstimator(), // fallback: 1 per field
];complexityDirectiveTypeDefs
SDL string for the @complexity directive. Include in your schema when using directive-based costs with fieldExtensionsEstimator:
directive @complexity(value: Int!, multipliers: [String!]) on FIELD_DEFINITIONERROR_CODES
Frozen object with GraphQL error extension codes:
| Code | Trigger |
|---|---|
| ESTIMATOR_ERROR | An estimator threw during evaluation |
| NODE_LIMIT_EXCEEDED | Query exceeded maxNodes |
| QUERY_TOO_COMPLEX | Query exceeded maxComplexity |
Exports
Runtime:
| Export | Description |
|---|---|
| complexityDirectiveTypeDefs | SDL for the @complexity directive |
| complexityLimit | Validation rule factory |
| ERROR_CODES | Error extension codes |
| fieldExtensionsEstimator | Extension/directive-based estimator |
| getComplexity | Programmatic max complexity |
| getComplexityBreakdown | Programmatic per-operation breakdown |
| QueryComplexityValidationError | Error class for validation failures |
| simpleEstimator | Fixed-cost estimator |
Types:
| Export | Description |
|---|---|
| CoercionErrorInfo | Coercion error details for directive arguments |
| ComplexityByOperation | Operation-name → complexity map |
| ComplexityCallback | Callback signature |
| ComplexityEstimator | Estimator function signature |
| ComplexityEstimatorArgs | Arguments passed to estimators |
| ComplexityExtensionConfig | { value, multipliers? } shape |
| ComplexityLimitFunction | Overloaded complexityLimit signature |
| ComplexityLimitOptions | Options for complexityLimit |
| ComplexityResult | Result object from the complexity engine |
| ComplexityViolation | Violation details for over-budget operations |
| GetComplexityOptions | Options for getComplexity* |
Integration Examples
Apollo Server
Checks complexity in didResolveOperation and rejects before execution. Full example
import { GraphQLError } from "graphql";
import {
fieldExtensionsEstimator,
getComplexity,
simpleEstimator,
} from "graphql-query-complexity-esm";
const MAX_COMPLEXITY = 1000;
// Inside ApolloServer config:
const server = new ApolloServer({
plugins: [
{
async requestDidStart({ schema }) {
return {
async didResolveOperation({ document, request }) {
const complexity = getComplexity({
estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
query: document,
schema,
variables: request.variables ?? {},
});
if (complexity > MAX_COMPLEXITY) {
throw new GraphQLError(
`Query complexity ${complexity} exceeds maximum of ${MAX_COMPLEXITY}.`,
{
extensions: {
code: "QUERY_TOO_COMPLEX",
complexity,
maximumComplexity: MAX_COMPLEXITY,
},
},
);
}
},
};
},
},
],
// ...
});GraphQL Yoga
Passes complexityLimit() as a validation rule through onValidate. Full example
import {
complexityLimit,
fieldExtensionsEstimator,
simpleEstimator,
} from "graphql-query-complexity-esm";
const MAX_COMPLEXITY = 1000;
// Inside createYoga config:
const yoga = createYoga({
plugins: [
{
onValidate({ addValidationRule, params }) {
const variables = (params.variables as Record<string, unknown> | undefined) ?? {};
addValidationRule(
complexityLimit(MAX_COMPLEXITY, {
estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
variables,
}),
);
},
},
],
// ...
});Performance
Complexity analysis uses an iterative DFS engine with explicit stack management — no recursion, no stack overflow risk, constant overhead regardless of query depth.
Benchmark thresholds enforced in CI (Node 22, single core):
| Scenario | Query shape | Max allowed | |---|---|---| | Small | 2-field flat query | ≤ 6 ms | | Medium | Multi-level nested with list arguments | ≤ 6 ms | | Deep | 18 levels of recursive nesting (complexity 262 144) | ≤ 9 ms | | Wide | 250 aliased field selections | ≤ 15 ms | | Heavy | Users → Posts → Comments → Author hierarchy | ≤ 6 ms |
Run benchmarks locally:
pnpm run bench # table output
pnpm run bench:check # regression check against thresholds
pnpm run bench:json # JSON output for CI integrationTroubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| RangeError: maxComplexity must be a positive integer | Invalid first argument to complexityLimit | Pass a positive integer |
| RangeError / TypeError from option validation | Invalid option types | Verify estimators, maxNodes, variables shapes |
| QueryComplexityValidationError thrown | Parse or validation failure in getComplexity* | Inspect error.errors for GraphQL error details |
| Extension code QUERY_TOO_COMPLEX | Complexity exceeded maxComplexity | Increase limit or tune estimators |
| Extension code NODE_LIMIT_EXCEEDED | Query exceeded maxNodes | Increase maxNodes or reduce query breadth/depth |
| Extension code ESTIMATOR_ERROR | An estimator threw | Guard estimator logic against unexpected arguments |
| @skip/@include with missing variables | Directive coercion failure | Nodes are treated as included; pass all required variables |
Development
Build:
| Script | Command |
|---|---|
| build | tsup |
| dev | tsc --watch |
Lint:
| Script | Command |
|---|---|
| lint | biome check && tsc --noEmit |
| lint:fix | biome check --write && tsc --noEmit |
Test:
| Script | Command |
|---|---|
| test | vitest run |
| test:coverage | vitest run --coverage |
| test:ui | vitest --ui |
| test:watch | vitest |
Benchmark:
| Script | Command |
|---|---|
| bench | tsx scripts/benchmark.ts |
| bench:check | tsx scripts/benchmark-check.ts |
| bench:json | tsx scripts/benchmark.ts --json |
Examples:
| Script | Command |
|---|---|
| example:apollo | tsx examples/servers/apollo-server.ts |
| example:yoga | tsx examples/servers/yoga-server.ts |
| Module | Purpose |
|---|---|
| src/index.ts | Public runtime and type exports |
| src/complexity-rule.ts | complexityLimit factory, input validation, callback dispatch, error reporting |
| src/complexity-engine.ts | Iterative traversal engine, estimator execution, fragment processing, node counting |
| src/get-complexity.ts | Programmatic wrapper around validate() + complexityLimit() |
| src/estimators.ts | simpleEstimator and fieldExtensionsEstimator |
| src/directives.ts | complexityDirectiveTypeDefs, shouldSkipNode (@skip/@include) |
| src/constants.ts | DEFAULT_MAX_NODES, ERROR_CODES |
| src/types.ts | Public interfaces, types, and QueryComplexityValidationError |
| src/utils.ts | Internal shared helpers (null-prototype records, type guards, value descriptions) |
| src/__tests__/ | Behavior tests for all modules |
Build output: src/index.ts → tsup → dist/ (ESM + CJS + .d.ts + sourcemaps)
pnpm benchCLI arguments (scripts/benchmark.ts):
| Argument | Description |
|---|---|
| --json | Output results as JSON |
| --output <path> | Write results to file |
| --scale <number> | Iteration scale factor |
| --warmup <integer> | Number of warmup runs |
Regression check (pnpm bench:check) env vars:
| Variable | Default | Purpose |
|---|---|---|
| BENCHMARK_THRESHOLDS_PATH | benchmarks/thresholds.json | Path to thresholds JSON |
| BENCH_ITERATIONS_SCALE | Thresholds file value, then 0.45 | Iteration scale override |
| BENCH_WARMUP_RUNS | Thresholds file value, then 30 | Warmup runs override |
Related Packages
This package is part of a suite of GraphQL security tools that work independently or together to protect your API:
| Package | Purpose |
|---|---|
| graphql-query-depth-limit-esm | Rejects deeply nested queries before execution |
| graphql-rate-limit-redis-esm | Redis-backed per-field rate limiting via @rateLimit directive |
Recommended layering: Use depth limiting as a fast, cheap first gate, complexity analysis for fine-grained cost control, and rate limiting for per-client throttling.
