Depth, Complexity and Cost

One pair of cyclic fields lets a client nest a query forever, so depth and complexity limits are the whole defence.

SyntaxGraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, validationRules: [depthLimit(7)] })

A REST endpoint costs what it costs. A GraphQL query costs whatever the client asked for. If Author.posts returns posts and Post.author returns the author, a client can nest that pair twenty times in one small document and ask your database for millions of rows. Nothing in the spec prevents it, and Apollo Server 5 enforces no depth limit by default.

Where the checks run

These limits belong before execution. Apollo parses the document, validates it against the schema, calls its plugins' didResolveOperation hook, and only then executes. Nest's own guards, interceptors and pipes run per resolver, inside execution — too late to refuse a query that is expensive because of its shape. So depth goes in validationRules, which run in the validation phase, and cost goes in an Apollo plugin at didResolveOperation.

Two different limits

Depth caps nesting, and nothing else. Complexity caps total cost: graphql-query-complexity walks the document and sums a score per field, which you set with the complexity option on @Field(), @Query() or @ResolveField(). Complexity is what catches items(count: 10000) — depth 2, cost enormous — and what catches two hundred aliases of one cheap field, which has depth 1.

The rest of the checklist

Turn introspection off in production so the schema is not a published map. Set includeStacktraceInErrorResponses: false. Leave Apollo's csrfPrevention on. maxRecursiveSelections takes boolean | number and is off until you set it: true installs Apollo's own ceiling of 10,000,000 selections, and a number sets your own. And rate limiting still applies: one route, so count operations, not paths.

Example

Example · typescript
// src/security/complexity.plugin.ts
import { Plugin } from '@nestjs/apollo';
import { GraphQLSchemaHost } from '@nestjs/graphql';
import {
  ApolloServerPlugin,
  BaseContext,
  GraphQLRequestListener,
} from '@apollo/server';
import { GraphQLError } from 'graphql';
import {
  fieldExtensionsEstimator,
  getComplexity,
  simpleEstimator,
} from 'graphql-query-complexity';

// @Plugin() marks this as an Apollo Server plugin AND as a Nest provider, so
// it can inject. It must still appear in a module's providers array.
@Plugin()
export class ComplexityPlugin implements ApolloServerPlugin {
  // In code-first the schema only exists after startup; this host hands you
  // the built GraphQLSchema object.
  constructor(private readonly gqlSchemaHost: GraphQLSchemaHost) {}

  async requestDidStart(): Promise<GraphQLRequestListener<BaseContext>> {
    const maxComplexity = 200;
    const { schema } = this.gqlSchemaHost;

    return {
      // Runs after parse and validate, before any resolver. Throwing here
      // costs one document walk and no database work.
      async didResolveOperation({ request, document }) {
        const complexity = getComplexity({
          schema,
          operationName: request.operationName,
          query: document,
          variables: request.variables,
          // Tried in order. The first reads `complexity` from the field
          // extensions the Nest decorators wrote; the second is the fallback
          // for every field that set nothing.
          estimators: [
            fieldExtensionsEstimator(),
            simpleEstimator({ defaultComplexity: 1 }),
          ],
        });

        if (complexity > maxComplexity) {
          throw new GraphQLError(
            `Query is too complex: ${complexity}. Maximum allowed complexity: ${maxComplexity}`,
            { extensions: { code: 'QUERY_TOO_COMPLEX' } },
          );
        }
      },
    };
  }
}

When to use it

  • Put a hard ceiling on nesting before opening the endpoint to a public mobile client.
  • Price a paginated field by its count argument so first: 10000 is rejected up front.
  • Turn introspection and GraphiQL off in production without touching resolver code.
  • Reject an expensive query during validation, before a single database connection is taken.

More examples

Pricing fields and paginated queries

The piece most people skip. Without the multiplier, a page of 10,000 rows and a page of 10 score identically, and a complexity limit only ever rejects deeply nested documents. Add an estimator function to every field that takes a count, first, last or limit argument.

Example · typescript
// src/items/items.resolver.ts
import {
  Args,
  ComplexityEstimatorArgs,
  Field,
  Int,
  ObjectType,
  Query,
  Resolver,
} from '@nestjs/graphql';
import { ItemsService } from './items.service.js';

@ObjectType()
export class Item {
  @Field()
  name: string; // costs the default 1

  // A field that renders Markdown on every read. Three times the price of a
  // column read, so asking for 500 of them costs what it costs.
  @Field({ complexity: 3 })
  renderedHtml: string;
}

@Resolver(() => Item)
export class ItemsResolver {
  constructor(private readonly items: ItemsService) {}

  @Query(() => [Item], {
    // childComplexity is the summed cost of the fields the client selected on
    // one Item. Multiplying by the count argument is what makes
    // items(count: 10000) expensive instead of free.
    complexity: (options: ComplexityEstimatorArgs) =>
      options.args.count * options.childComplexity,
  })
  items(@Args('count', { type: () => Int }) count: number) {
    return this.items.getItems({ count });
  }
}

Depth limit and production flags

The configuration half of the job, and the one place where all four knobs are visible at once. graphql-depth-limit ships no types, so install @types/graphql-depth-limit alongside it. Set these before the endpoint is reachable from the internet, not after the first incident.

Example · typescript
// src/app.module.ts (GraphQL section only)
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import depthLimit from 'graphql-depth-limit';
import { ComplexityPlugin } from './security/complexity.plugin.js';

const isProd = process.env.NODE_ENV === 'production';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: true,
      // Runs in the validation phase, before didResolveOperation. Pick the
      // number from your deepest real client query plus one, not from taste.
      validationRules: [depthLimit(7)],
      // Default follows NODE_ENV, but say it out loud: introspection is a
      // published map of every type and argument you have.
      introspection: !isProd,
      graphiql: !isProd,
      includeStacktraceInErrorResponses: false,
      // Off unless you set it; `true` would use Apollo's own 10,000,000
      // ceiling. Caps total selections after fragments are expanded, which is
      // the fragment-bomb defence.
      maxRecursiveSelections: 10_000,
      csrfPrevention: true, // Apollo's default; do not turn it off
    }),
  ],
  // Without this line the plugin above is dead code.
  providers: [ComplexityPlugin],
})
export class AppModule {}

The query a depth limit will not stop

Keep this to hand as the argument for why depth alone is not enough. Both documents are trivially shallow and both are expensive, so only a complexity estimator that counts repeated fields and reads pagination arguments will reject them. It is also the test case to send at your own endpoint once the plugin is registered.

Example · graphql
# Depth 2. A depth limit of 7 lets this through untouched.
# 200 aliases of one field: GraphQL allows the same field many times under
# different response keys, and each one is resolved separately.
query AliasBomb {
  a1: expensiveReport(year: 2026) { total }
  a2: expensiveReport(year: 2026) { total }
  a3: expensiveReport(year: 2026) { total }
  # ... 197 more
}

# Depth 2 again, but one page of 50,000 rows, each rendering Markdown.
query WidePage {
  items(count: 50000) {
    renderedHtml
  }
}

Discussion

  • Be the first to comment on this lesson.