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 |
|
Outside the HTTP request: a WebSocket connection with its own handshake ( |
Guards, interceptors, filters, pipes |
Nest core, with |
Around each root resolver (query, mutation or subscription field); not around |
Field middleware |
|
Around the resolution of one object-type field, after the root-level enhancers |
Apollo Server plugins |
|
Server and request lifecycle events ( |
Mercurius hooks and plugins |
|
Mercurius lifecycle ( |
Query complexity |
|
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 thePubSubEngineinterface ingraphql-subscriptions3 (the TypeScript declarations only listasyncIterableIterator), althoughgraphql-redis-subscriptionsstill exposes both; callasyncIterableIterator()on everyPubSubimplementation. -
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 barereviewmakes 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 (reviewTextAddedabove 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)returnstrueto deliver the event to that subscriber. It runs once per event per subscriber, so keep it cheap. Both functions can declarethis: ReviewsResolverto reach injected providers, as thefilterabove does for illustration. -
The subscription name defaults to the method name;
namein the@Subscription()options decouples the two, asreviewTextAddedshows. -
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:
-
forRootAsyncwithinjectletsonConnectuse a provider (UsersService), which stands in for a session store or JWT verification. The service of this example looks sessions up in aMap; real code verifies a signed token and reads its secret fromConfigService(see Authentication). -
The
contextfunction is called for both transports. For HTTP it receives{ req, res }; forgraphql-wsit receives{ connectionParams, extra, … }andreqisundefined. The function above normalises both into oneuserproperty, so guards and decorators never care which transport they are running on. -
buildSchemaOptions.fieldMiddleware, theformatErrorfunction and the threeAPP_*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
connectionoption (a URL orRedisOptions). It uses two, one to publish and one to subscribe, because anioredisconnection in subscriber mode cannot run other commands. Passing your ownpublisherandsubscriberis supported, but they must beioredis5 instances: the package nests its ownioredis5, andRedisobjects from a top-levelioredis6 are rejected by the TypeScript compiler because the types differ. -
Payloads travel as JSON, so a
Datearrives as a string unless you pass areviver, 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 atriggerTransform, 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 {}
Source: NestJS: Rate limiting, "GraphQL"
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:
-
What you throw. A
GraphQLError(fromgraphql) withextensions.codeis the portable way to give clients a stable, machine-readable error. A NestHttpExceptionis converted: the message is kept,extensions.codebecomes a code derived from the status (BAD_REQUEST) and the Nest response body is attached asextensions.originalError. Anything else (a plainError, a driver error) becomesINTERNAL_SERVER_ERRORwith the original message. -
Exception filters (above), which can translate domain errors into
GraphQLError. -
formatErrorin 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
userwas), then read it fromctx.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 producesReview.*entries but noQuery.reviewsFor. Use enhancers for root operations. -
It is not an authorization framework: hiding a field with
nullstill reveals that the field exists in the schema. For rules that must return an error, throw from the middleware or use guards withfieldResolverEnhancers.
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 }; withresolve, publish the raw value. Mixing them makes the subscription fail at event time, not at publish time. -
reqin subscription guards. Ongraphql-ws,context.reqisundefined; put the user on the context fromonConnect(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
ThrottlerGuardon GraphQL. It crashes onundefined.ip; use the subclass above. -
Leaking errors. Plain errors reach the client with their message unless
formatErrormasks them. -
Field middleware on non-object types. It only applies to
@ObjectType()classes, and the field must be nullable if the middleware can returnnull. -
Throwing from
didResolveOperationwithout a status. The client sees500; addhttp: { status: 400 }. -
Enhancers on every field.
fieldResolverEnhancersmultiplies enhancer calls by the size of every list. -
Redis clients of another major version. Pass
connectionand let the package create itsioredisclients. -
Clients still on
subscriptions-transport-ws. The sub-protocols are incompatible; the connection fails. Upgrade the client tographql-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/graphql14 removed support forsubscriptions-transport-ws: the package is no longer a dependency and its option key is no longer accepted. Use'graphql-ws': trueor an options object, and update clients. TheonConnectcallback now receives thegraphql-wsconnection context, with extra context values stored inextra. -
graphql-subscriptions3 only offersasyncIterableIterator();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/mercurius14 exposes a typedhooksoption. -
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.