GraphQL: Subscriptions, Security & Plugins

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2), @nestjs/graphql 14.0.3, @nestjs/apollo 14.0.3, @nestjs/mercurius 14.0.3, Apollo Server 5.5.1, graphql 16.14, graphql-ws 6.3.0, graphql-subscriptions 3.0.0, graphql-redis-subscriptions 2.7.0, graphql-query-complexity 2.0.0, Mercurius 16.10, @nestjs/throttler 6.7.1, Node.js 22 and 24 LTS and TypeScript 6, all as of 2026-10-02. The examples use a Nest 12 project: the HTTP behaviour is tested with supertest, the subscriptions with a real graphql-ws client connected to a listening application. The Redis-backed PubSub is shown against an in-memory Redis mock rather than a real server (see Redis PubSub).

The page continues the Bookshelf example: readers post reviews of a book, and every client watching that book gets the new review pushed to it. It covers what comes after the basic resolvers of GraphQL: Code-First & Schema-First: real-time delivery, the Nest enhancers (guards, interceptors, filters) in a GraphQL context, error shapes, field middleware, server plugins and query cost limits. GraphQL theory is not repeated here; see Subscriptions, Security and Demand Control, Authorization and Response and Error Handling for the concepts.

Where each piece runs

Nest core features (guards, interceptors, filters, pipes, custom decorators) are platform independent and run around the resolver method, in the order described in Request Lifecycle. Everything else on this page belongs to the GraphQL layer or to the driver, and runs at a different point.

Piece Provided by Runs

Subscriptions transport

graphql-ws (Apollo driver) or Mercurius' own WebSocket support

Outside the HTTP request: a WebSocket connection with its own handshake (onConnect) and one subscribe message per operation

Guards, interceptors, filters, pipes

Nest core, with GqlExecutionContext / GqlArgumentsHost

Around each root resolver (query, mutation or subscription field); not around @ResolveField() methods unless fieldResolverEnhancers is set

Field middleware

@nestjs/graphql (code-first only)

Around the resolution of one object-type field, after the root-level enhancers

Apollo Server plugins

@nestjs/apollo (@Plugin())

Server and request lifecycle events (requestDidStart, didResolveOperation, willSendResponse)

Mercurius hooks and plugins

@nestjs/mercurius (hooks, plugins options)

Mercurius lifecycle (preParsing to onResolution, plus the subscription hooks)

Query complexity

graphql-query-complexity, wired as a plugin

After the operation is parsed and validated, before execution

Subscriptions over graphql-ws

A subscription is a third operation type: the client opens a long-lived operation and the server pushes a result every time an event occurs. In Nest the subscription resolver returns an AsyncIterator; each value it yields becomes one result sent to the client. With @nestjs/apollo the transport is the graphql-ws package (the graphql-transport-ws sub-protocol). The old subscriptions-transport-ws package and its 'subscriptions-transport-ws' option key were removed in @nestjs/graphql 14, so a Nest 12 server cannot talk to a client still using that protocol.

Enabling the transport

@nestjs/graphql already depends on graphql-ws; install it yourself when you import types from it (as the onConnect example below does), together with graphql-subscriptions for PubSub:

npm i graphql-ws graphql-subscriptions

The transport is switched on in the module configuration. The complete module of this page is shown once below and explained piece by piece in the following sections; the relevant part here is the subscriptions property:

GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  autoSchemaFile: true,
  subscriptions: { 'graphql-ws': true },
}),

Passing an object instead of true customizes the graphql-ws server. The path defaults to the GraphQL endpoint path, so the HTTP endpoint and the WebSocket endpoint share one URL (/graphql), and onConnect and the other graphql-ws server callbacks are set here too.

A shared PubSub provider

PubSub from graphql-subscriptions is an in-memory publish/subscribe hub. The documentation’s first examples create a new PubSub() at the top of a resolver file; that works until a second resolver or service needs to publish to the same hub, at which point the two instances do not see each other’s events. Register it once as a provider and inject it:

import { Global, Module } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';

export const PUB_SUB = 'PUB_SUB';

@Global()
@Module({
  providers: [{ provide: PUB_SUB, useValue: new PubSub() }],
  exports: [PUB_SUB],
})
export class PubSubModule {}

@Global() is optional; it saves importing the module into every feature module. The string token 'PUB_SUB' is what the documentation uses; an InjectionToken constant (as here) avoids typos. Because consumers only depend on the token, swapping the in-memory hub for a Redis-backed one later is a one-line provider change (see Redis PubSub).

The subscription resolver: asyncIterableIterator, filter and resolve

import { moderatorsOnly } from './field-middleware.js';
import { Field, ID, InputType, Int, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class Review {
  @Field(() => ID)
  id!: string;

  @Field(() => Int)
  bookId!: number;

  @Field({ nullable: true, middleware: [moderatorsOnly] })
  author?: string;

  @Field({ complexity: 3 })
  text!: string;
}

@InputType()
export class NewReviewInput {
  @Field(() => Int)
  bookId!: number;

  @Field()
  text!: string;
}
import { BadRequestException, Inject } from '@nestjs/common';
import { Args, Int, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql';
import { PubSub } from 'graphql-subscriptions';
import { CurrentUser } from './current-user.decorator.js';
import type { User } from './users.service.js';
import { NewReviewInput, Review } from './review.model.js';
import { ReviewTooLongError } from './domain-error.filter.js';
import { PUB_SUB } from './pubsub.module.js';

export const REVIEW_ADDED = 'reviewAdded';

@Resolver(() => Review)
export class ReviewsResolver {
  private readonly reviews: Review[] = [];

  constructor(@Inject(PUB_SUB) private readonly pubSub: PubSub) {}

  @Query(() => [Review])
  reviewsFor(@Args('bookId', { type: () => Int }) bookId: number) {
    return this.reviews.filter((review) => review.bookId === bookId);
  }

  @Mutation(() => Review)
  async addReview(@Args('input') input: NewReviewInput, @CurrentUser() user: User) {
    if (input.text.length > 200) {
      throw new ReviewTooLongError(200);
    }
    const review: Review = {
      id: String(this.reviews.length + 1),
      bookId: input.bookId,
      author: user.name,
      text: input.text,
    };
    this.reviews.push(review);
    // The payload key must match the subscription field name (no `resolve` here).
    await this.pubSub.publish(REVIEW_ADDED, { reviewAdded: review });
    return review;
  }

  @Subscription(() => Review, {
    filter(this: ReviewsResolver, payload: { reviewAdded: Review }, variables: { bookId: number }) {
      return payload.reviewAdded.bookId === variables.bookId;
    },
  })
  reviewAdded(@Args('bookId', { type: () => Int }) _bookId: number) {
    return this.pubSub.asyncIterableIterator(REVIEW_ADDED);
  }

  // Same event, but `resolve` unwraps the payload and can shape the value.
  @Subscription(() => String, {
    name: 'reviewTextAdded',
    resolve: (payload: Review) => payload.text.toUpperCase(),
  })
  reviewTextAdded() {
    return this.pubSub.asyncIterableIterator('reviewTextRaw');
  }

  @Query(() => String)
  explode(@Args('kind') kind: string): string {
    if (kind === 'http') {
      throw new BadRequestException('Unsupported kind');
    }
    throw new Error('connect ECONNREFUSED 10.0.0.5:5432');
  }

  @Mutation(() => Boolean)
  async shout(@Args('text') text: string) {
    await this.pubSub.publish('reviewTextRaw', { text });
    return true;
  }
}

The points that matter:

  • pubSub.asyncIterableIterator(trigger) is the current method name. asyncIterator() was removed from the PubSubEngine interface in graphql-subscriptions 3 (the TypeScript declarations only list asyncIterableIterator), although graphql-redis-subscriptions still exposes both; call asyncIterableIterator() on every PubSub implementation.

  • Without resolve, the published payload must have the shape of the GraphQL result: an object with one top-level key named after the subscription field. The mutation therefore publishes { reviewAdded: review }. Publishing the bare review makes the subscription fail when the event is resolved.

  • With resolve, the function receives the payload as published and returns the field value, so the payload is the unwrapped value (reviewTextAdded above publishes { text } and returns the uppercased text). This is also the place to map an internal event to a public type.

  • filter(payload, variables, context) returns true to deliver the event to that subscriber. It runs once per event per subscriber, so keep it cheap. Both functions can declare this: ReviewsResolver to reach injected providers, as the filter above does for illustration.

  • The subscription name defaults to the method name; name in the @Subscription() options decouples the two, as reviewTextAdded shows.

  • Schema-first applications use the same decorator with the field name: @Subscription('reviewAdded', { filter, resolve }).

Subscribing from a client

A client opens a WebSocket on the GraphQL path and sends the subscription. This is the graphql-ws client used to verify the page, with the ws package as the WebSocket implementation on Node.js (browsers use their built-in WebSocket):

import { createClient } from 'graphql-ws';
import WebSocket from 'ws';

const client = createClient({
  url: `ws://127.0.0.1:${port}/graphql`,
  webSocketImpl: WebSocket,
  retryAttempts: 0,
  // Sent once, in the connection_init message: this is what onConnect receives.
  connectionParams: { authorization: 'Bearer <session id>' },
});

client.subscribe(
  {
    query: 'subscription ($bookId: Int!) { reviewAdded(bookId: $bookId) { id bookId text author } }',
    variables: { bookId: 1 },
  },
  { next: (result) => console.log(result), error: console.error, complete: () => undefined },
);

With a client subscribed to book 1, a mutation for book 2 and then one for book 1 produce exactly one result, for book 1. That is the filter at work:

{ "data": { "reviewAdded": { "id": "2", "bookId": 1, "text": "great", "author": null } } }

The author is null here because the subscriber is not a moderator (see Field middleware), and the resolve variant delivered { "data": { "reviewTextAdded": "HELLO" } } for the published text hello.

Authenticating the connection with onConnect

A WebSocket has no per-message HTTP headers, so authentication happens once, when the connection is initialised. graphql-ws calls onConnect with the connection context; connectionParams carries what the client put in connectionParams. Returning false rejects the connection (the server closes the socket with code 4403, reason Forbidden); throwing closes it with 4500. Extra values for later use belong in ctx.extra, which Nest then passes to the context function:

import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { GraphQLModule } from '@nestjs/graphql';
import { GraphQLFormattedError } from 'graphql';
import type { Context } from 'graphql-ws';
import { ComplexityPlugin, RequestStats, RequestStatsPlugin } from './apollo-plugins.js';
import { ReviewTooLongFilter } from './domain-error.filter.js';
import { recordFields } from './field-middleware.js';
import { GqlAuthGuard } from './gql-auth.guard.js';
import { PubSubModule } from './pubsub.module.js';
import { ReviewsResolver } from './reviews.resolver.js';
import { TimingInterceptor } from './timing.interceptor.js';
import { UsersModule } from './users.module.js';
import { UsersService } from './users.service.js';

@Module({
  imports: [
    PubSubModule,
    UsersModule,
    GraphQLModule.forRootAsync<ApolloDriverConfig>({
      driver: ApolloDriver,
      imports: [UsersModule],
      inject: [UsersService],
      useFactory: (users: UsersService) => ({
        autoSchemaFile: true,
        subscriptions: {
          'graphql-ws': {
            // Runs once per WebSocket connection, before any subscribe message.
            onConnect: (ctx: Context) => {
              const { connectionParams, extra } = ctx as Context & { extra: { user?: unknown } };
              const user = users.fromAuthorization(connectionParams?.authorization as string | undefined);
              if (!user) {
                return false; // the server closes the socket with code 4403
              }
              extra.user = user;
            },
          },
        },
        // HTTP requests provide `req`; graphql-ws provides `extra` (set in onConnect).
        context: ({ req, extra }: { req?: { headers: Record<string, string> }; extra?: { user?: unknown } }) => ({
          req,
          user: extra?.user ?? users.fromAuthorization(req?.headers.authorization),
        }),
        buildSchemaOptions: { fieldMiddleware: [recordFields] },
        includeStacktraceInErrorResponses: false,
        formatError: (formatted: GraphQLFormattedError): GraphQLFormattedError => {
          const { code } = (formatted.extensions ?? {}) as { code?: string };
          if (code === 'INTERNAL_SERVER_ERROR') {
            return { message: 'Something went wrong', extensions: { code } };
          }
          return formatted;
        },
      }),
    }),
  ],
  providers: [
    ReviewsResolver,
    RequestStats,
    RequestStatsPlugin,
    ComplexityPlugin,
    { provide: APP_GUARD, useClass: GqlAuthGuard },
    { provide: APP_INTERCEPTOR, useClass: TimingInterceptor },
    { provide: APP_FILTER, useClass: ReviewTooLongFilter },
  ],
})
export class SubscriptionsAppModule {}

Reading this module top to bottom:

  • forRootAsync with inject lets onConnect use a provider (UsersService), which stands in for a session store or JWT verification. The service of this example looks sessions up in a Map; real code verifies a signed token and reads its secret from ConfigService (see Authentication).

  • The context function is called for both transports. For HTTP it receives { req, res }; for graphql-ws it receives { connectionParams, extra, …​ } and req is undefined. The function above normalises both into one user property, so guards and decorators never care which transport they are running on.

  • buildSchemaOptions.fieldMiddleware, the formatError function and the three APP_* providers are explained in the sections that follow.

A client connecting without credentials is refused before any operation is sent:

closed { code: 4403, reason: 'Forbidden' }

Customizing the subscriptions server

Besides onConnect, the graphql-ws option object accepts the other server callbacks (onSubscribe, onNext, onComplete, onClose, onError) and the path. A different path for the WebSocket endpoint is subscriptions: { 'graphql-ws': { path: '/subscriptions' } }. Each connection holds a server-side socket, so apply the same limits as for any WebSocket endpoint: a maximum payload size, an authentication deadline (connectionInitWaitTimeout) and rate limits at the proxy.

A Redis-backed PubSub for production

The in-memory PubSub only delivers events to subscribers connected to the same Node.js process. As soon as the application runs on two instances (or inside a cluster), a mutation handled by instance A never reaches a subscriber connected to instance B. The Apollo documentation warns that the default implementation is not for production; graphql-redis-subscriptions implements the same interface over Redis publish/subscribe. Because consumers inject the 'PUB_SUB' token, only the provider changes:

npm i graphql-redis-subscriptions ioredis
import { ConfigService } from '@nestjs/config';
import { RedisPubSub } from 'graphql-redis-subscriptions';
import { PUB_SUB } from './pubsub.module.js';

/** Drop-in replacement for the in-memory provider: same token, same API. */
export const redisPubSubProvider = {
  provide: PUB_SUB,
  inject: [ConfigService],
  useFactory: (config: ConfigService) =>
    // The library creates two connections itself: one to publish and one to
    // subscribe (a subscribed connection cannot run other commands).
    new RedisPubSub({ connection: config.getOrThrow<string>('REDIS_URL') }),
};

The provider builds from ConfigService (getOrThrow fails fast when REDIS_URL is missing), the result is a RedisPubSub with asyncIterableIterator(), and publishing and consuming one event works against an in-memory Redis mock; that round trip proves the API contract, not behaviour against a real server. Test your deployment against a real Redis (a container is enough) before relying on it.

Details worth knowing:

  • Let the library create its connections with the connection option (a URL or RedisOptions). It uses two, one to publish and one to subscribe, because an ioredis connection in subscriber mode cannot run other commands. Passing your own publisher and subscriber is supported, but they must be ioredis 5 instances: the package nests its own ioredis 5, and Redis objects from a top-level ioredis 6 are rejected by the TypeScript compiler because the types differ.

  • Payloads travel as JSON, so a Date arrives as a string unless you pass a reviver, and class instances arrive as plain objects. Publish plain data.

  • Events are fire-and-forget: a subscriber that is disconnected misses what was published meanwhile. For delivery guarantees use a log (Kafka, Redis Streams) and let the client re-query after reconnecting.

  • Subscribe filtering still happens in Node.js, per subscriber, after the event is delivered to every instance. For very high fan-out use one channel per topic (for example reviewAdded.42) and a triggerTransform, so instances only receive the channels they have subscribers for.

Subscriptions with Mercurius

With the Fastify-only @nestjs/mercurius driver, set subscription: true (or pass Mercurius' subscription options). The PubSub comes from the GraphQL context, not from the graphql-subscriptions package, so you inject it with @Context('pubsub'); the resolver subscribes with pubSub.subscribe(topic) and publishes { topic, payload }. The same graphql-ws client works, because Mercurius supports the graphql-transport-ws sub-protocol:

import { Module } from '@nestjs/common';
import { Args, Context, GraphQLModule, Int, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql';
import { MercuriusDriver, MercuriusDriverConfig } from '@nestjs/mercurius';
import type { PubSub } from 'mercurius';
import { Review } from './review.model.js';

export const hookLog: string[] = [];

@Resolver(() => Review)
class MercuriusReviewsResolver {
  @Query(() => String)
  status() {
    return 'ok';
  }

  @Mutation(() => Review)
  async addReview(
    @Args('bookId', { type: () => Int }) bookId: number,
    @Args('text') text: string,
    @Context('pubsub') pubSub: PubSub,
  ) {
    const review = { id: '1', bookId, text } as Review;
    await pubSub.publish({ topic: 'reviewAdded', payload: { reviewAdded: review } });
    return review;
  }

  @Subscription(() => Review, {
    filter: (payload: { reviewAdded: Review }, variables: { bookId: number }) =>
      payload.reviewAdded.bookId === variables.bookId,
  })
  reviewAdded(
    @Args('bookId', { type: () => Int }) _bookId: number,
    @Context('pubsub') pubSub: PubSub,
  ) {
    return pubSub.subscribe('reviewAdded');
  }
}

@Module({
  imports: [
    GraphQLModule.forRoot<MercuriusDriverConfig>({
      driver: MercuriusDriver,
      autoSchemaFile: true,
      subscription: true,
      hooks: {
        preExecution: async (_schema, document) => {
          const op = document.definitions[0];
          hookLog.push(`preExecution ${op.kind === 'OperationDefinition' ? op.operation : op.kind}`);
        },
        onResolution: async (execution) => {
          hookLog.push(`onResolution ${execution.errors ? 'errors' : 'data'}`);
        },
        preSubscriptionExecution: async () => {
          hookLog.push('preSubscriptionExecution');
        },
      },
    }),
  ],
  providers: [MercuriusReviewsResolver],
})
export class MercuriusSubscriptionsModule {}

The filter option works the same way (Nest wraps it in Mercurius' withFilter). A subscription over the graphql-ws client received { "data": { "reviewAdded": { "id": "1", "text": "fine" } } } after a mutation, on a Fastify application. Mercurius' default in-memory emitter has the same single-process limit as PubSub; its documentation describes a Redis emitter for production.

Guards, interceptors, filters and custom decorators

Nest enhancers are written against the abstract ExecutionContext and ArgumentsHost. In HTTP they wrap the request and response; in GraphQL the handler arguments are (root, args, context, info). GqlExecutionContext.create(context) and GqlArgumentsHost.create(host) (both from @nestjs/graphql) convert the generic objects, and expose getRoot(), getArgs(), getContext() and getInfo(). For the underlying mechanics see Execution Context, Reflector & Discovery.

A guard that works for queries, mutations and subscriptions

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';

@Injectable()
export class GqlAuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    // Works for queries, mutations and subscriptions: the user is put on the
    // GraphQL context by the `context` option, never read from `req` here.
    const { user } = GqlExecutionContext.create(context).getContext();
    if (!user) {
      throw new GraphQLError('You must be signed in', {
        extensions: { code: 'UNAUTHENTICATED' },
      });
    }
    return true;
  }
}

The guard reads the user from the GraphQL context, not from context.switchToHttp().getRequest(). For a query the context function filled user from the Authorization header; for a subscription, it came from extra.user, set in onConnect. Reading req would work for HTTP but fail on every subscription, where req is undefined. The module registers it with APP_GUARD, so it covers every resolver, including mutations. An anonymous query is answered with HTTP 200 (GraphQL puts errors in the body):

curl -s localhost:3000/graphql -H 'content-type: application/json' \
  -d '{"query":"{ reviewsFor(bookId: 1) { id } }"}'
{ "errors": [ { "message": "You must be signed in", "locations": [ { "line": 1, "column": 3 } ], "path": [ "reviewsFor" ], "extensions": { "code": "UNAUTHENTICATED" } } ], "data": null }

A guard may throw a Nest HttpException (for example UnauthorizedException) instead; Nest maps it to an error with extensions.code set from the status (for example BAD_REQUEST for a 400). A plain GraphQLError, as above, gives full control over the code but is not an HttpException, so Nest’s exception layer logs it at ERROR level in the console even though the client receives the error you chose. Throw HttpException subclasses (or filter the error) if that log noise is a concern. Authorization patterns (roles, policies, field-level rules) are in Authorization and Guards.

A custom decorator for the current user

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

export const CurrentUser = createParamDecorator((_data: unknown, context: ExecutionContext) =>
  GqlExecutionContext.create(context).getContext().user,
);

The addReview mutation above uses @CurrentUser() user: User to stamp the review with the author. The same decorator can be used on HTTP controllers only if it does not assume a GraphQL context, so a decorator that serves both transports branches on context.getType<GqlContextType>() ('graphql' or 'http').

An interceptor

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Observable, tap } from 'rxjs';

export const timings: string[] = [];

@Injectable()
export class TimingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const info = GqlExecutionContext.create(context).getInfo();
    const label = `${info.parentType.name}.${info.fieldName}`;
    const started = performance.now();
    return next.handle().pipe(
      tap(() => timings.push(`${label} ${(performance.now() - started).toFixed(1)}ms`)),
    );
  }
}

The info argument identifies the field being resolved (Query.reviewsFor, Mutation.addReview), which is how an interceptor can log or time GraphQL operations by name. Registered as APP_INTERCEPTOR, it recorded Query.reviewsFor 0.1ms for a plain query.

An exception filter

import { ArgumentsHost, Catch } from '@nestjs/common';
import { GqlArgumentsHost, GqlExceptionFilter } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';

export class ReviewTooLongError extends Error {
  constructor(readonly maxLength: number) {
    super(`A review may have at most ${maxLength} characters`);
  }
}

@Catch(ReviewTooLongError)
export class ReviewTooLongFilter implements GqlExceptionFilter {
  catch(exception: ReviewTooLongError, host: ArgumentsHost) {
    const info = GqlArgumentsHost.create(host).getInfo();
    // Returning (or throwing) a GraphQLError controls the `errors` entry.
    return new GraphQLError(exception.message, {
      extensions: {
        code: 'REVIEW_TOO_LONG',
        maxLength: exception.maxLength,
        field: info.fieldName,
      },
    });
  }
}

A GraphQL filter implements GqlExceptionFilter and must not write to a native response: GraphQL always answers the HTTP request itself. Returning or throwing a GraphQLError decides the entry of the errors array, including its extensions. A mutation with a 201-character text then fails like this, and the other fields of the response are untouched:

{ "errors": [ { "message": "A review may have at most 200 characters", "locations": [ { "line": 1, "column": 12 } ], "path": [ "addReview" ], "extensions": { "code": "REVIEW_TOO_LONG", "maxLength": 200, "field": "addReview" } } ], "data": null }

For the general filter mechanics (catch scope, ordering, hybrid applications) see Exception Filters & Error Handling.

Enhancers on field resolvers

By default Nest does not run guards, interceptors or filters for @ResolveField() methods, only for the root @Query(), @Mutation() and @Subscription() handlers. Setting fieldResolverEnhancers: ['guards', 'interceptors', 'filters'] (any subset) in the module options turns them on for field resolvers. Do that sparingly: a list of a thousand items with a field resolver on each runs the enhancer a thousand times. When you enable it, skip the enhancers that are not needed for fields by returning early when info.parentType.name is not Query or Mutation, which the documentation wraps in an isResolvingGraphQLField() helper. Field-level rules that only need the user are cheaper as field middleware.

Rate limiting GraphQL: ThrottlerGuard needs a GraphQL-aware subclass

ThrottlerGuard from @nestjs/throttler reads the HTTP request and response from the execution context. In a GraphQL resolver, context.switchToHttp().getRequest() returns undefined, so registering the stock guard on a GraphQL application makes every operation fail, even the first one. This happens on 6.7.1 with ThrottlerGuard registered as APP_GUARD and a trivial { ping } query:

{ "errors": [ { "message": "Cannot read properties of undefined (reading 'ip')", "locations": [ { "line": 1, "column": 3 } ], "path": [ "ping" ], "extensions": { "code": "INTERNAL_SERVER_ERROR" } } ], "data": null }

The fix is to override getRequestResponse() and take req and res from the GraphQL context, which also means the context option of the module must put them there:

import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { ExecutionContext, Injectable, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { GqlExecutionContext, GraphQLModule, Query, Resolver } from '@nestjs/graphql';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';

@Resolver()
class PingResolver {
  @Query(() => String)
  ping() {
    return 'pong';
  }
}

@Injectable()
export class GqlThrottlerGuard extends ThrottlerGuard {
  getRequestResponse(context: ExecutionContext) {
    const { req, res } = GqlExecutionContext.create(context).getContext();
    return { req, res };
  }
}

const graphql = GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  autoSchemaFile: true,
  context: ({ req, res }: { req: unknown; res: unknown }) => ({ req, res }),
});

@Module({
  imports: [ThrottlerModule.forRoot([{ ttl: 60_000, limit: 2 }]), graphql],
  providers: [PingResolver, { provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class PlainThrottlerModule {}

@Module({
  imports: [ThrottlerModule.forRoot([{ ttl: 60_000, limit: 2 }]), graphql],
  providers: [PingResolver, { provide: APP_GUARD, useClass: GqlThrottlerGuard }],
})
export class GqlThrottlerModule {}

With limit: 2 per minute, the first two { ping } operations return { "data": { "ping": "pong" } } and the third returns HTTP 200 with this body:

{ "errors": [ { "message": "ThrottlerException: Too Many Requests", "locations": [ { "line": 1, "column": 3 } ], "path": [ "ping" ], "extensions": { "code": "INTERNAL_SERVER_ERROR" } } ], "data": null }

Two caveats. First, the limit counts per operation, not per field, so a single query that selects many root fields counts once; combine the throttler with query complexity (below) for that. Second, the guard protects HTTP operations; a subscription connection is a single long-lived socket, so limit connections at the proxy and with onConnect. The full throttler options (several named throttlers, @SkipThrottle(), Redis storage) are in Rate Limiting & Encryption.

Error formatting with GraphQLError

GraphQL errors are entries of the errors array in a 200 response, not HTTP statuses. Three layers shape them, from the resolver outwards:

  1. What you throw. A GraphQLError (from graphql) with extensions.code is the portable way to give clients a stable, machine-readable error. A Nest HttpException is converted: the message is kept, extensions.code becomes a code derived from the status (BAD_REQUEST) and the Nest response body is attached as extensions.originalError. Anything else (a plain Error, a driver error) becomes INTERNAL_SERVER_ERROR with the original message.

  2. Exception filters (above), which can translate domain errors into GraphQLError.

  3. formatError in the module options, called by Apollo Server for every error just before it is sent, with the formatted error and the original error.

The two conversions (explode throws either a BadRequestException or a plain driver error):

{ "errors": [ { "message": "Unsupported kind", "locations": [ { "line": 1, "column": 3 } ], "path": [ "explode" ], "extensions": { "code": "BAD_REQUEST", "originalError": { "message": "Unsupported kind", "error": "Bad Request", "statusCode": 400 } } } ], "data": null }

Without a formatter, the second case would send connect ECONNREFUSED 10.0.0.5:5432 to the client, which leaks infrastructure details. The formatError function in the module above replaces any INTERNAL_SERVER_ERROR message with a generic one and the client receives only:

{ "errors": [ { "message": "Something went wrong", "extensions": { "code": "INTERNAL_SERVER_ERROR" } } ], "data": null }

The includeStacktraceInErrorResponses: false option in the same module removes extensions.stacktrace; Apollo includes stack traces unless NODE_ENV is production, so set it explicitly rather than relying on the environment. Log the original error in formatError (the second argument) before discarding its details. Validation failures from class-validator pipes arrive as BAD_REQUEST errors whose extensions.originalError.message is the array of messages; see Pipes & Validation.

Field middleware

Field middleware runs code before or after one field is resolved: it can change the value, replace it, or block it. It is available with the code-first approach and only on @ObjectType() classes. It receives the same { source, args, context, info } as a resolver and a next() function; the value it returns replaces the field value.

import { FieldMiddleware, MiddlewareContext, NextFn } from '@nestjs/graphql';

/** Field-level check: only moderators see the author's name. */
export const moderatorsOnly: FieldMiddleware = async (ctx: MiddlewareContext, next: NextFn) => {
  const user = ctx.context.user as { roles: string[] } | undefined;
  if (!user?.roles.includes('moderator')) {
    return null;
  }
  return next();
};

export const calls: string[] = [];

/** Global middleware: records every resolved field. */
export const recordFields: FieldMiddleware = async ({ info }, next) => {
  const value = await next();
  calls.push(`${info?.parentType.name}.${info?.fieldName}`);
  return value;
};

moderatorsOnly is attached to a field in the Review model shown earlier (@Field({ nullable: true, middleware: [moderatorsOnly] }), so the field must be nullable: a blocked field returns null). A non-moderator saw "author": null and a moderator "author": "Alice" in the subscription results above. recordFields is registered for every object-type field through buildSchemaOptions.fieldMiddleware in the module; it is called for Review.id, Review.text and so on.

What to know before using it:

  • Middleware cannot inject providers and has no access to the DI container. It is meant to be small and synchronous in spirit; fetch anything you need in a guard or interceptor and put it on the GraphQL context (as user was), then read it from ctx.context.

  • An array runs in order and the first entry is the outermost layer (it starts first and finishes last). Global middleware runs before middleware bound to a field.

  • Root fields (Query, Mutation) are not passed through the global middleware: recording produces Review.* entries but no Query.reviewsFor. Use enhancers for root operations.

  • It is not an authorization framework: hiding a field with null still reveals that the field exists in the schema. For rules that must return an error, throw from the middleware or use guards with fieldResolverEnhancers.

Apollo Server plugins

Apollo Server plugins react to server and request lifecycle events: serverWillStart, requestDidStart and, inside the returned listener, didResolveOperation, executionDidStart, willSendResponse and others. In Nest a plugin is an injectable class decorated with @Plugin() from @nestjs/apollo, implementing ApolloServerPlugin from @apollo/server for typing. Registering it as a provider is enough: Nest instantiates it with dependency injection and hands it to Apollo.

The package is Apollo Server 5 in the tested install (the surrounding material still speaks of Apollo Server 4; the plugin interface used here is the same), so the hook names are the ones of the current Apollo documentation. Plugins from @apollo/server itself (cache control, landing pages, usage reporting) go in the plugins array of the module options instead:

import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';

GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  autoSchemaFile: true,
  plugins: [ApolloServerPluginCacheControl({ defaultMaxAge: 5 })],
}),

Mercurius plugins and hooks

Mercurius is a Fastify plugin, so its extensions are Fastify plugins that must be registered after Mercurius. The MercuriusDriver exposes a plugins option, an array of { plugin, options } objects, for exactly that (for example mercurius-cache); mercurius-upload is the exception and is registered in main.ts.

Mercurius' own lifecycle hooks (preParsing, preValidation, preExecution, onResolution, and for subscriptions preSubscriptionParsing, preSubscriptionExecution, onSubscriptionResolution, onSubscriptionEnd) are the counterpart of Apollo plugins. The installed @nestjs/mercurius accepts a hooks object in the module options and registers each entry with app.graphql.addHook(); the option is typed (MercuriusHooksObject) but is not described on the official plugins page, so treat it as an implementation detail of the package rather than a documented contract. The mercurius.module.ts shown earlier registers three hooks; for one mutation with one subscriber they run in this order:

preSubscriptionExecution | preExecution mutation | onResolution data

Mercurius requires hooks to be async or to return a promise.

Query complexity

Because the client chooses the query, a single request can ask for enormous amounts of work. Query complexity assigns each field a cost and rejects operations over a budget before they execute. @nestjs/graphql integrates with graphql-query-complexity (code-first only); in Apollo it is a plugin that computes the cost in didResolveOperation and throws when the budget is exceeded:

npm i graphql-query-complexity
import { ApolloServerPlugin, BaseContext, GraphQLRequestListener } from '@apollo/server';
import { Plugin } from '@nestjs/apollo';
import { Injectable } from '@nestjs/common';
import { GraphQLSchemaHost } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
import { fieldExtensionsEstimator, getComplexity, simpleEstimator } from 'graphql-query-complexity';

@Injectable()
export class RequestStats {
  readonly operations: string[] = [];
  readonly complexities: number[] = [];
}

@Plugin()
export class RequestStatsPlugin implements ApolloServerPlugin {
  constructor(private readonly stats: RequestStats) {}

  async requestDidStart(): Promise<GraphQLRequestListener<BaseContext>> {
    return {
      didResolveOperation: async ({ operationName }) => {
        this.stats.operations.push(operationName ?? '(anonymous)');
      },
    };
  }
}

export const MAX_COMPLEXITY = 10;

@Plugin()
export class ComplexityPlugin implements ApolloServerPlugin {
  constructor(
    private readonly gqlSchemaHost: GraphQLSchemaHost,
    private readonly stats: RequestStats,
  ) {}

  async requestDidStart(): Promise<GraphQLRequestListener<BaseContext>> {
    const { schema } = this.gqlSchemaHost;
    return {
      didResolveOperation: async ({ request, document }) => {
        const complexity = getComplexity({
          schema,
          operationName: request.operationName,
          query: document,
          variables: request.variables,
          estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
        });
        this.stats.complexities.push(complexity);
        if (complexity > MAX_COMPLEXITY) {
          throw new GraphQLError(
            `Query is too complex: ${complexity}. Maximum allowed complexity: ${MAX_COMPLEXITY}`,
            { extensions: { code: 'QUERY_TOO_COMPLEX', http: { status: 400 } } },
          );
        }
      },
    };
  }
}

The RequestStatsPlugin shows a plugin with an injected provider; ComplexityPlugin receives GraphQLSchemaHost (the built schema is not available at construction time of the module, only through this host) and the same RequestStats. The two estimators run in order, and the first one that returns a number wins: fieldExtensionsEstimator reads the cost declared on the field (the complexity: 3 option on Review.text, shown in the model above), simpleEstimator gives every other field a cost of 1. The budget here is 10; the documentation uses 20.

A cheap query costs 2 (reviewsFor plus id). A query selecting text three times through aliases costs 1 + 1 + 3 * 3 = 11, and is rejected:

{ "errors": [ { "message": "Query is too complex: 11. Maximum allowed complexity: 10", "extensions": { "code": "QUERY_TOO_COMPLEX" } } ] }

An error thrown from a plugin is answered with HTTP 500 unless the error says otherwise. The example sets http: { status: 400 } in the GraphQLError extensions, which Apollo Server turns into the response status (here 400) and removes from the visible extensions. Without it the response is a 500 with the same body, which monitoring tools count as a server failure.

Per-field costs go in the decorators: @Field({ complexity: 3 }), or a function that uses the arguments and the cost of the children, for lists: @Query(() ⇒ [Review], { complexity: ({ args, childComplexity }) ⇒ args.count * childComplexity }). Complexity is one layer of demand control; depth limits, persisted queries and the rate limit above complement it (see Security and Demand Control). The batching and N+1 side is covered in Performance and N+1.

Pitfalls

  • new PubSub() per resolver. Two instances never see each other’s events; the symptom is a mutation that works and a subscription that stays silent. Use one provider.

  • A published payload of the wrong shape. Without resolve, publish { <subscriptionName>: value }; with resolve, publish the raw value. Mixing them makes the subscription fail at event time, not at publish time.

  • req in subscription guards. On graphql-ws, context.req is undefined; put the user on the context from onConnect (extra) and read that.

  • Authenticating only once, at connect. A token that expires while the socket is open stays valid for that socket. Close the socket on expiry, or re-check in filter/onSubscribe.

  • Stock ThrottlerGuard on GraphQL. It crashes on undefined.ip; use the subclass above.

  • Leaking errors. Plain errors reach the client with their message unless formatError masks them.

  • Field middleware on non-object types. It only applies to @ObjectType() classes, and the field must be nullable if the middleware can return null.

  • Throwing from didResolveOperation without a status. The client sees 500; add http: { status: 400 }.

  • Enhancers on every field. fieldResolverEnhancers multiplies enhancer calls by the size of every list.

  • Redis clients of another major version. Pass connection and let the package create its ioredis clients.

  • Clients still on subscriptions-transport-ws. The sub-protocols are incompatible; the connection fails. Upgrade the client to graphql-ws.

Where the book is outdated

The book’s social-network case study already configures graphql-ws, but its subscription code predates the current API in four ways. It calls GraphQLModule.forRoot() without a driver, which @nestjs/graphql 14 requires. It creates a module-level new PubSub() in each resolver file, so a notifications resolver and a posts resolver cannot see each other’s events and nothing can be swapped for Redis. It calls pubSub.asyncIterator(), which no longer exists on graphql-subscriptions 3 (asyncIterableIterator() replaces it). And its onConnect and onDisconnect handlers are empty placeholders: nothing authenticates the socket, so every client can subscribe to every recipient’s notifications unless the filter is the only protection. The book does not cover guards on subscriptions, rate limiting on GraphQL, error formatting, plugins or query complexity; that is what the sections above add.

What changed recently

  • @nestjs/graphql 14 removed support for subscriptions-transport-ws: the package is no longer a dependency and its option key is no longer accepted. Use 'graphql-ws': true or an options object, and update clients. The onConnect callback now receives the graphql-ws connection context, with extra context values stored in extra.

  • graphql-subscriptions 3 only offers asyncIterableIterator(); asyncIterator() is gone from the interface.

  • GraphiQL is the default IDE of the GraphQL module in Nest 12 (see GraphQL: Code-First & Schema-First).

  • Apollo Server is 5.x in the current install; Mercurius is 16.x and @nestjs/mercurius 14 exposes a typed hooks option.

  • The GraphQL documentation was reorganised in September 2026; the pages linked from this page (/graphql/subscriptions, /graphql/other-features, /graphql/field-middleware, /graphql/plugins, /graphql/complexity) are the current locations.

Federated setups (subgraphs, gateways, per-subgraph plugins) are covered in GraphQL: Federation; testing subscriptions with a real client is the subject of Testing GraphQL, WebSockets & Microservices; the non-GraphQL WebSocket gateway equivalents are in WebSockets.