The Practical Developer

GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver

Depth limiting does not stop expensive GraphQL queries. A shallow query with wide list arguments can still exhaust your database and OOM your API. Here is a practical complexity-scoring implementation that rejects abusive queries before they touch a resolver, plus the adversarial test cases that prove it works.

Lines of code glowing on a laptop screen in a dark workspace

Your GraphQL API was fine at launch. Three months later, a client shipped a reporting dashboard. The query looked innocent: three levels of nesting, well under your depth limit of 10. But it requested 1,000 users, each with 1,000 orders, each with 1,000 items. The database connection pool exhausted in four seconds. Node.js heap hit 1.8 GB and the container was killed. Someone added IP-based rate limiting, but the query ran once per hour from a legitimate internal service, so rate limiting did nothing.

The problem was not query depth. It was query cost. GraphQL gives clients unlimited expressive power, which means they can also express unlimited work. Without a complexity budget, your API is a denial-of-service machine that waits for the first creative client.

This post is the fix: a field-level complexity calculator that totals the estimated cost of a query before execution starts. It runs in validation, so abusive queries are rejected with a 400 before they reach a resolver or a database connection. The implementation is about 80 lines, works with any GraphQL server, and includes the adversarial test suite you need to trust it.

Why depth limiting is not enough

Most teams add a depth limit first. It is easy to implement and catches obviously ridiculous nesting:

query Evil {
  user {
    orders {
      items {
        product {
          reviews {
            author {
              orders {
                items {
                  product {
                    reviews {
                      author {
                        name
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

A depth limit of 8 or 10 rejects this cleanly. But depth says nothing about width. Here is a query with depth 3 that is far more dangerous:

query Worse {
  users(limit: 1000) {
    orders(limit: 1000) {
      items(limit: 1000) {
        name
        price
      }
    }
  }
}

That is one billion object resolutions. Your database does not have an index that saves you from a billion rows. Your Node.js event loop does not survive allocating intermediate arrays for a billion items. Depth limits do not catch this because the nesting is shallow.

You need a cost model that accounts for both depth and, more importantly, multipliers from list arguments.

The cost model in plain English

Complexity analysis assigns a cost score to every field in the query. The rules are simple:

  1. Every scalar field costs 1.
  2. Every object field costs 1 plus the cost of its selections.
  3. Every list field multiplies the cost of its selections by its limit argument (or a default).
  4. The total score is the sum of the root selections.

For the query above:

  • users(limit: 1000) = 1000 * (cost of orders(limit: 1000))
  • orders(limit: 1000) = 1000 * (cost of items(limit: 1000))
  • items(limit: 1000) = 1000 * (cost of name + price)
  • name + price = 2

Total: 1000 * 1000 * 1000 * 2 = 2,000,000,000

That is an obvious reject. A reasonable complexity limit for a public API is somewhere between 500 and 5,000 depending on your schema and infrastructure. Internal APIs with trusted clients can go higher, but there should still be a ceiling.

The implementation

The best place to run this is after parsing but before execution. That means the calculator works in Apollo Server, graphql-yoga, or any server built on graphql-js. The function below walks the AST, resolves field types against the schema, and accumulates the score with list multipliers.

// complexity-calculator.ts
import {
  type GraphQLSchema,
  type DocumentNode,
  type OperationDefinitionNode,
  type FieldNode,
  type SelectionSetNode,
  type InlineFragmentNode,
  type FragmentDefinitionNode,
} from 'graphql';

type ComplexityConfig = {
  maxComplexity: number;
  defaultListLimit: number;
};

export function assertComplexity(
  schema: GraphQLSchema,
  document: DocumentNode,
  operationName: string | undefined,
  config: ComplexityConfig
): number {
  const op = operationName
    ? document.definitions.find(
        (d): d is OperationDefinitionNode =>
          d.kind === 'OperationDefinition' && d.name?.value === operationName
      )
    : document.definitions.find(
        (d): d is OperationDefinitionNode => d.kind === 'OperationDefinition'
      );

  if (!op) throw new Error('Operation not found');

  const fragments = new Map<string, FragmentDefinitionNode>();
  for (const def of document.definitions) {
    if (def.kind === 'FragmentDefinition') {
      fragments.set(def.name.value, def);
    }
  }

  const rootType =
    op.operation === 'mutation'
      ? schema.getMutationType()
      : op.operation === 'subscription'
        ? schema.getSubscriptionType()
        : schema.getQueryType();

  if (!rootType) throw new Error('Root type not found');

  const score = selectionSetCost(
    op.selectionSet,
    rootType.name,
    schema,
    fragments,
    config,
    1
  );

  if (score > config.maxComplexity) {
    throw new Error(
      `Query complexity ${score} exceeds maximum ${config.maxComplexity}`
    );
  }

  return score;
}

function selectionSetCost(
  set: SelectionSetNode,
  parentTypeName: string,
  schema: GraphQLSchema,
  fragments: Map<string, FragmentDefinitionNode>,
  config: ComplexityConfig,
  multiplier: number
): number {
  let total = 0;

  for (const sel of set.selections) {
    if (sel.kind === 'Field') {
      total += fieldCost(sel, parentTypeName, schema, fragments, config, multiplier);
    } else if (sel.kind === 'InlineFragment') {
      const typeName = sel.typeCondition?.name.value ?? parentTypeName;
      total += selectionSetCost(sel.selectionSet, typeName, schema, fragments, config, multiplier);
    } else if (sel.kind === 'FragmentSpread') {
      const fragment = fragments.get(sel.name.value);
      if (fragment) {
        total += selectionSetCost(
          fragment.selectionSet,
          fragment.typeCondition.name.value,
          schema,
          fragments,
          config,
          multiplier
        );
      }
    }
  }

  return total;
}

function fieldCost(
  node: FieldNode,
  parentTypeName: string,
  schema: GraphQLSchema,
  fragments: Map<string, FragmentDefinitionNode>,
  config: ComplexityConfig,
  multiplier: number
): number {
  const parentType = schema.getType(parentTypeName);
  if (!parentType || !('getFields' in parentType)) {
    return 1 * multiplier;
  }

  const fieldDef = parentType.getFields()[node.name.value];
  if (!fieldDef) return 1 * multiplier;

  const typeStr = fieldDef.type.toString();
  const isList = typeStr.startsWith('[');
  const fieldMultiplier = isList
    ? multiplier * getLimit(node, config.defaultListLimit)
    : multiplier;

  const selfCost = 1 * fieldMultiplier;

  if (!node.selectionSet) {
    return selfCost;
  }

  const childTypeName = typeStr
    .replace(/^\[/, '')
    .replace(/\]$/, '')
    .replace(/!$/, '');

  const childrenCost = selectionSetCost(
    node.selectionSet,
    childTypeName,
    schema,
    fragments,
    config,
    fieldMultiplier
  );

  return selfCost + childrenCost;
}

function getLimit(node: FieldNode, defaultLimit: number): number {
  const arg = node.arguments?.find(
    (a) => a.name.value === 'limit' || a.name.value === 'first'
  );
  if (arg?.value.kind === 'IntValue') {
    return parseInt(arg.value.value, 10);
  }
  return defaultLimit;
}

Wire it into Apollo Server as a plugin, which runs after the operation is resolved but before resolvers execute:

// apollo-complexity-plugin.ts
import type { ApolloServerPlugin, BaseContext } from '@apollo/server';
import { assertComplexity } from './complexity-calculator';

export function complexityPlugin(
  config: { maxComplexity: number; defaultListLimit: number }
): ApolloServerPlugin<BaseContext> {
  return {
    async requestDidStart() {
      return {
        async didResolveOperation({ request, document, schema }) {
          assertComplexity(
            schema,
            document,
            request.operationName ?? undefined,
            config
          );
        },
      };
    },
  };
}

And attach it when you build the server:

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { complexityPlugin } from './apollo-complexity-plugin';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    complexityPlugin({ maxComplexity: 1000, defaultListLimit: 50 }),
  ],
});

const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });

Any query that scores above 1,000 is rejected with a validation error before resolvers run, before database connections are checked out, and before your event loop spends a millisecond on the request.

What a reasonable limit looks like

Setting the limit is part science and part observation. Start by measuring your existing traffic.

For a typical SaaS API, these are realistic reference points:

  • A simple query { me { name email } } scores about 3.
  • A standard page query fetching a user, their recent 10 orders, and 5 items per order scores around 80.
  • An analytics export fetching 200 records with moderate nesting scores around 400.
  • The billion-row query above scores 2,000,000,000.

Set your initial limit to 1,000. Run it in log-only mode (collect scores but do not reject) for a week. Look at the p95 and p99. If your heaviest legitimate query is 800, set the limit to 1,200 and enable rejection. Tune it up if you break real clients. Tune it down if you still see abuse.

The default list limit matters too. If a client omits limit on a list field, you cannot assume they want one row. The calculator falls back to defaultListLimit. Set this to whatever your resolver would actually return when no limit is given. If your resolver defaults to 100, make the calculator default to 100. Do not pretend an unbounded list is cheap.

Testing with adversarial queries

A complexity calculator that you have not attacked is a calculator you do not trust. Here is a test suite that exercises the edges:

// complexity.test.ts
import { buildSchema, parse } from 'graphql';
import { test, expect } from 'vitest';
import { assertComplexity } from './complexity-calculator';

const schema = buildSchema(`
  type Query {
    users(limit: Int): [User]
    me: User
  }
  type User {
    id: ID
    name: String
    orders(limit: Int): [Order]
  }
  type Order {
    id: ID
    items(limit: Int): [Item]
  }
  type Item {
    name: String
    price: Int
  }
`);

function score(doc: string, operationName?: string) {
  return assertComplexity(schema, parse(doc), operationName, {
    maxComplexity: Infinity,
    defaultListLimit: 50,
  });
}

function assertRejects(doc: string) {
  expect(() =>
    assertComplexity(schema, parse(doc), undefined, {
      maxComplexity: 1000,
      defaultListLimit: 50,
    })
  ).toThrow(/exceeds maximum/);
}

test('simple query is cheap', () => {
  expect(score('query { me { name } }')).toBe(2);
});

test('moderate list query is acceptable', () => {
  expect(score('query { users(limit: 10) { name orders(limit: 5) { id } } }')).toBe(260);
});

test('deep wide query is rejected', () => {
  assertRejects(`
    query {
      users(limit: 100) {
        orders(limit: 100) {
          items(limit: 100) {
            name
            price
          }
        }
      }
    }
  `);
});

test('fragments multiply correctly', () => {
  const doc = `
    query {
      users(limit: 10) {
        ...UserFrag
      }
    }
    fragment UserFrag on User {
      orders(limit: 10) {
        items(limit: 10) { name }
      }
    }
  `;
  expect(score(doc)).toBe(5520);
});

Run these tests against your real schema, not a toy example. If you use interface types or union types, add test cases that spread fragments across them. The calculator handles InlineFragment nodes, but the multiplier behavior across union members is worth verifying explicitly.

Edge cases and production tweaks

Named operations. The calculator accepts an operationName because documents can contain multiple operations. If you do not pass the name, it uses the first operation it finds. In most servers, the operation name comes from the request body, so the plugin passes request.operationName through correctly.

Custom field costs. The current calculator treats every field as cost 1. If some fields are genuinely expensive (a full-text search, a geospatial aggregation, a third-party API call), you should add a per-field cost map. Pass it into fieldCost and add a multiplier before selfCost:

const fieldCosts: Record<string, number> = {
  'Query.searchUsers': 10,
  'Order.shippingEstimate': 5,
};

Look up the cost by ${parentTypeName}.${node.name.value} and multiply selfCost by that factor.

Variables. The current implementation only reads integer literals in limit and first arguments. If clients pass limits as variables (limit: $pageSize), the calculator cannot know the runtime value at validation time. You have three options:

  1. Reject variable limits on list fields and require literals.
  2. Use the default limit whenever a variable is present.
  3. Inspect the variable values at runtime (Apollo’s didResolveOperation receives request.variables) and pass them into the calculator.

Option 3 is the most practical. Modify assertComplexity to accept a variables map, and resolve variable values in getLimit:

function getLimit(
  node: FieldNode,
  defaultLimit: number,
  variables?: Record<string, unknown>
): number {
  const arg = node.arguments?.find(
    (a) => a.name.value === 'limit' || a.name.value === 'first'
  );
  if (!arg) return defaultLimit;

  if (arg.value.kind === 'IntValue') {
    return parseInt(arg.value.value, 10);
  }

  if (arg.value.kind === 'Variable' && variables) {
    const val = variables[arg.value.name.value];
    if (typeof val === 'number') return val;
  }

  return defaultLimit;
}

Then pass request.variables from the Apollo plugin into assertComplexity.

Persisted queries for known heavy operations. Some legitimate operations are inherently expensive. A monthly admin export that walks 5,000 records with deep nesting might genuinely need a complexity score of 10,000. Instead of raising the global limit, use persisted queries or an operation allowlist. Sign the heavy operation at build time, and bypass complexity checks only for those known hashes. Every ad-hoc query still faces the 1,000 limit.

Monitoring and incident response

Log two metrics in production:

  1. Query complexity histogram per operation name. This tells you where your traffic actually lives and whether your limit is correctly calibrated.
  2. Rejected query count with the score and the operation name. A spike here means either an attack or a client that needs education.

Alert on rejected query spikes that correlate with increased latency or error rates. If rejections spike but latency is flat, you are under attack and the calculator is doing its job. If rejections spike and latency also spikes, a client changed its query shape and is now tripping the limit under real load. That is a signal to tune the limit or optimize the resolver.

When a legitimate query is rejected, the error message includes the score. Document the limit in your API docs, and tell clients how to estimate their own scores. If they understand that limit: 100 on three nested lists is expensive, they will usually fix it themselves.

Closing

GraphQL’s power is also its risk. A single query can express more work than your entire infrastructure can deliver. Depth limits catch the obvious cases, but width is where production APIs die. Complexity analysis is the defensive layer that rejects impossible work before it becomes an incident.

The implementation above is not a library. It is a pattern. Adapt the cost model to your schema, add per-field weights for expensive resolvers, and plug it into your validation pipeline. Then run the adversarial tests, turn it on, and sleep through the next dashboard launch.

A note from Yojji

Defensive patterns like query complexity analysis are the kind of production-hardening work that separates internal prototypes from APIs that survive real traffic. Yojji’s engineering teams apply these same controls when building scalable backend services and GraphQL APIs for clients across Europe, the US, and the UK.