Microservice Transports
|
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/microservices 12.1.2), Node.js 20.19+ or 22.12+, TypeScript 6
(6.0.3) and RxJS 7.8, all checked on 2026-10-02. A transporter is the pluggable layer that carries Nest’s two message
styles (request-response with @MessagePattern() and send(), event-based with @EventPattern() and emit()) over a
concrete protocol. The programming model is covered in
Microservices Fundamentals; this page is the reference for what
differs per transporter: the package to install, the options object, how patterns map to the wire, the context object,
record builders, wildcards, connection status, on() and unwrap(), and the failure modes. The six built-in network
transporters are covered (Redis, MQTT, NATS, RabbitMQ, Kafka, gRPC), followed by custom transporters and a comparison
table. The broker theory itself (Kafka partitions and consumer groups, RabbitMQ exchanges and queue types, delivery
guarantees) lives in the Messaging section and is linked rather than repeated; the
architecture of a split system is in
Microservices Architecture and Patterns.
What the examples are tested against
The examples are exercised in-process, without a Redis, MQTT, NATS, RabbitMQ or Kafka broker. Every snippet below
type-checks with tsc --noEmit under strict in a NestJS 12.1.2 project (ESM, Vitest), and its tests drive the real
@nestjs/microservices server and client classes. What stands in for the broker is stated per transporter:
| Transporter | What is exercised | Against what |
|---|---|---|
gRPC |
The real |
Nothing is substituted. TLS credentials are compile-checked only. |
MQTT |
The real |
An in-process Aedes broker (MQTT 3.1.1 only). MQTT 5 user properties are compile-checked only. |
Redis |
The real |
A small RESP stub (about 120 lines) that understands only |
NATS |
The real |
A small stub (about 85 lines) of the NATS wire protocol ( |
RabbitMQ |
The real |
An in-memory fake of the |
Kafka |
The real |
An in-memory fake of KafkaJS (one partition, consumer groups, subscribe by name or RegExp), not a broker. |
Custom transporter |
A complete in-process transporter wired with |
Nothing is substituted. |
Treat the RabbitMQ and Kafka sections as checked against Nest’s own code paths and the official documentation, but confirm broker-side behaviour (redelivery, offsets, rebalancing) on a real broker before relying on it.
What every transporter has in common
Running a transporter
A microservice is created with NestFactory.createMicroservice() and one options object: transport selects the
transporter (the Transport enum from @nestjs/microservices) and options is specific to it. The same object is
accepted by ClientsModule on the client side, with a name that becomes the injection token.
import { NestFactory } from '@nestjs/core';
import type { MicroserviceOptions } from '@nestjs/microservices';
import { ReviewsModule } from '../nats/reviews.module.js';
import { natsOptions } from './options.js';
export async function bootstrap() {
// A standalone microservice: no HTTP server, just the transporter.
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
ReviewsModule,
natsOptions,
);
app.enableShutdownHooks();
await app.listen();
}
The options object used here is built in the NATS section. When the options need injected providers such as
ConfigService, pass AsyncMicroserviceOptions (inject plus useFactory) instead, so the broker address comes from
configuration:
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { Transport } from '@nestjs/microservices';
import type { AsyncMicroserviceOptions } from '@nestjs/microservices';
import { ReviewsModule } from '../nats/reviews.module.js';
export async function bootstrapFromConfig() {
// Transport options that need injected providers: AsyncMicroserviceOptions.
const app = await NestFactory.createMicroservice<AsyncMicroserviceOptions>(
ReviewsModule,
{
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.NATS,
options: {
servers: [config.getOrThrow<string>('NATS_URL')],
queue: 'notifications',
},
}),
},
);
await app.listen();
}
Nothing in this page depends on the HTTP adapter: a
standalone microservice has no HTTP server at all, and in a hybrid application (an HTTP app that also calls
connectMicroservice(), covered in Microservices Fundamentals) the
Express 5.2 or Fastify 5.12 adapter and the transporter run side by side without interacting.
Registering clients from configuration
Every transporter is consumed through a ClientProxy (gRPC uses ClientGrpc, Kafka the ClientKafkaProxy type).
ClientsModule.registerAsync() builds them from ConfigService, so no address is hardcoded. Each entry’s useFactory
runs in ClientsModule’s own scope: either make `ConfigModule global, as below, or give each entry
imports: [ConfigModule]. Without that, Nest fails at start-up with Nest can’t resolve dependencies of the
REDIS_CLIENT (?).
import { join } from 'node:path';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
@Module({
imports: [
// registerAsync() factories live in ClientsModule's own scope: either make
// ConfigModule global (as here) or add `imports: [ConfigModule]` to each entry.
ConfigModule.forRoot({ isGlobal: true }),
ClientsModule.registerAsync([
{
name: 'REDIS_CLIENT',
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.REDIS,
options: {
host: config.getOrThrow<string>('REDIS_HOST'),
port: config.getOrThrow<number>('REDIS_PORT'),
},
}),
},
{
name: 'MQTT_CLIENT',
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.MQTT,
options: { url: config.getOrThrow<string>('MQTT_URL') },
}),
},
{
name: 'NATS_CLIENT',
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.NATS,
options: { servers: [config.getOrThrow<string>('NATS_URL')] },
}),
},
{
name: 'RMQ_CLIENT',
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.RMQ,
options: {
urls: [config.getOrThrow<string>('RABBITMQ_URL')],
queue: 'notifications_queue',
queueOptions: { durable: true },
},
}),
},
{
name: 'KAFKA_CLIENT',
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.KAFKA,
options: {
client: {
clientId: 'bookshelf',
brokers: config.getOrThrow<string>('KAFKA_BROKERS').split(','),
},
consumer: { groupId: 'bookshelf-consumer' },
},
}),
},
{
name: 'GRPC_CLIENT',
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.GRPC,
options: {
package: 'bookshelf',
protoPath: join(import.meta.dirname, '../grpc/books.proto'),
url: config.getOrThrow<string>('BOOKS_GRPC_URL'),
loader: { keepCase: true },
},
}),
},
]),
],
exports: [ClientsModule],
})
export class BookshelfClientsModule {}
Source: NestJS: Microservices - Client
Using a client is the same everywhere: send() returns a cold Observable that you bound with timeout() and turn into
a promise with firstValueFrom(); emit() is also cold, so subscribe to it or await it, otherwise nothing is sent.
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom, timeout } from 'rxjs';
@Injectable()
export class ReviewNotifier {
constructor(@Inject('NATS_CLIENT') private readonly client: ClientProxy) {}
// Request-response: always bound the wait with timeout().
countUnread(userId: number): Promise<number> {
return firstValueFrom(
this.client.send<number, number>('notifications.count', userId).pipe(timeout(5000)),
);
}
// Event: fire-and-forget. emit() is cold until subscribed, so await it
// (or subscribe) to make sure the event is actually dispatched.
notifyReviewCreated(bookId: number): Promise<void> {
return firstValueFrom(this.client.emit('reviews.created', { bookId }));
}
}
The common surface: context, status, on() and unwrap()
Each transporter provides the same four extension points, with transporter-specific types:
| Feature | What it gives you |
|---|---|
Context object |
|
|
An |
|
Subscribes to the driver’s own events (for example |
|
Returns the underlying driver: two |
Under strict the documented snippets for status, on() and unwrap() need three small adjustments, each needed for the code to
type-check with tsc. Type the injected client with the transporter’s events and status maps so that status and
on() are typed; do not annotate the status callback of a server (INestMicroservice.status is an
Observable<string>); and give on() on the server both type arguments so the callback parameters are inferred.
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy, RedisEvents, RedisStatus } from '@nestjs/microservices';
@Injectable()
export class RedisConnectionWatcher {
// Typing the client with the transport's events and status maps makes
// status and on() type-check under `strict`.
constructor(
@Inject('REDIS_CLIENT')
private readonly client: ClientProxy<RedisEvents, RedisStatus>,
) {}
watch() {
this.client.status.subscribe((status) => console.log('redis', status));
// Redis uses two connections, so the callback also receives which one failed.
this.client.on('error', (connection, err) =>
console.error(connection, err),
);
const [pub, sub] =
this.client.unwrap<[import('ioredis').Redis, import('ioredis').Redis]>();
return { pub, sub };
}
}
import { RedisEvents } from '@nestjs/microservices';
import type { INestMicroservice } from '@nestjs/common';
export function watchServer(app: INestMicroservice) {
// INestMicroservice.status is Observable<string>: do not annotate the
// parameter with the transport's status enum.
app.status.subscribe((status) => console.log('server', status));
// With only the first type argument the callback parameters are not inferred.
app.on<RedisEvents, 'error'>('error', (connection, err) =>
console.error(connection, err),
);
const [pub, sub] =
app.unwrap<[import('ioredis').Redis, import('ioredis').Redis]>();
return { pub, sub };
}
import { Inject, Injectable } from '@nestjs/common';
import {
ClientProxy,
MqttEvents,
MqttStatus,
NatsEvents,
NatsStatus,
RmqEvents,
RmqStatus,
} from '@nestjs/microservices';
import type { ClientKafkaProxy } from '@nestjs/microservices';
@Injectable()
export class DriverAccess {
constructor(
@Inject('MQTT_CLIENT') private readonly mqtt: ClientProxy<MqttEvents, MqttStatus>,
@Inject('NATS_CLIENT') private readonly nats: ClientProxy<NatsEvents, NatsStatus>,
@Inject('RMQ_CLIENT') private readonly rmq: ClientProxy<RmqEvents, RmqStatus>,
@Inject('KAFKA_CLIENT') private readonly kafka: ClientKafkaProxy,
) {}
inspect() {
this.mqtt.on('error', (err) => console.error(err));
const mqttClient = this.mqtt.unwrap<import('mqtt').MqttClient>();
this.nats.on('disconnect', (serverUrl) => console.error(`Disconnected from ${serverUrl}`));
const natsConnection =
this.nats.unwrap<import('@nats-io/transport-node').NatsConnection>();
this.rmq.on('error', (err) => console.error(err));
const manager =
this.rmq.unwrap<import('amqp-connection-manager').AmqpConnectionManager>();
// Kafka has no unwrap()/on(): the KafkaJS producer and consumer are properties.
const { producer, consumer } = this.kafka;
return { mqttClient, natsConnection, manager, producer, consumer };
}
}
A strict-mode pitfall with @EventPattern()
@MessagePattern() accepts handlers with any parameter list, but the first overload of @EventPattern() in 12.1.2
types the decorated method as (data, …args: unknown[]) ⇒ any. A handler whose second parameter is typed, as
@Ctx() context: RedisContext is, therefore fails with TS1241: Unable to resolve signature of method decorator when
called as an expression. Handlers with a single parameter compile. Passing an explicit type argument selects the plain
MethodDecorator overload, so every event handler on this page is written @EventPattern<string>('…'). With a
RegExp pattern (Kafka) the problem does not arise. The behaviour was reproduced with TypeScript 6.0.3.
The Bookshelf scenario
The running scenario is the Bookshelf application. Its notifications service reacts to review and follow events and
renders messages; the catalogue is reachable over gRPC; the examples below give each transporter the job it is usually
chosen for. They are not a recommendation to run six brokers: pick one transporter per boundary, as the comparison
table at the end explains.
Redis
The Redis transporter is built on Redis Pub/Sub (it needs the ioredis package). Messages are published to channels
without the publisher knowing who subscribes. They are fire-and-forget: a message published while nobody listens is
gone, and there is no guarantee that any service handles a given message. Every subscriber to a channel receives every
message, so Pub/Sub has no consumer groups: if you run three instances of a service, all three handle each event and
each answers each request. For durable or load-balanced delivery use RabbitMQ or Kafka instead, or Redis Streams outside
the Nest transporter (see Redis Streams and Pub/Sub).
npm install ioredis
Options
| Option | Meaning |
|---|---|
|
Connection address. |
|
Times to retry the connection. Default |
|
Delay between retries in milliseconds. Default |
|
Use |
All options of the ioredis client are accepted too. The option object (also used by the client):
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
export const redisOptions: MicroserviceOptions = {
transport: Transport.REDIS,
options: {
host: 'localhost',
port: 6379,
retryAttempts: 5,
retryDelay: 1000,
wildcards: true,
},
};
Source: NestJS: Redis - Options
Patterns, context and wildcards
A message pattern becomes a channel: a request published to notifications.count is answered on
notifications.count.reply (the client subscribes to that reply channel for the duration of the request). Event patterns
are used as the channel name directly. RedisContext.getChannel() returns the channel the message arrived on.
import { Controller } from '@nestjs/common';
import {
Ctx,
EventPattern,
MessagePattern,
Payload,
RedisContext,
} from '@nestjs/microservices';
@Controller()
export class NotificationsController {
readonly received: { channel: string; data: unknown }[] = [];
// Request-response: the reply travels on "notifications.count.reply".
@MessagePattern('notifications.count')
count(@Payload() userId: number, @Ctx() ctx: RedisContext) {
return { userId, unread: 3, channel: ctx.getChannel() };
}
// With wildcards: true the pattern is subscribed with PSUBSCRIBE.
@EventPattern<string>('notify.*')
onNotification(@Payload() data: unknown, @Ctx() ctx: RedisContext) {
this.received.push({ channel: ctx.getChannel(), data });
}
}
Source: NestJS: Redis - Context
With the server and a ClientProxy both using wildcards: true, sending notifications.count with the payload 7
receives { userId: 7, unread: 3, channel: 'notifications.count' }, and emitting notify.review and notify.follow
gets both handled with the matching getChannel() values. status emits connected on both sides, and
unwrap() returns two ioredis instances whose status is ready.
Redis globs are broader than they look: matches any run of characters, dots included. An event pattern
notifications. therefore also matches the request channel notifications.count and the reply channel
notifications.count.reply. With the controller below, the notifications. event handler runs for both of those
channels in addition to the real events, so the reply is handled as if it were an event. Keep event channels and request
channels in disjoint namespaces (notify. against notifications.count above).
@Controller()
class Overlap {
seen: string[] = [];
@MessagePattern('notifications.count')
count() {
return 1;
}
@EventPattern<string>('notifications.*')
any(@Ctx() ctx: RedisContext) {
this.seen.push(ctx.getChannel());
}
}
// seen contains 'notifications.count' and 'notifications.count.reply'
Source: NestJS: Redis - Wildcards
Connections, retries and listeners
The transporter keeps two connections, one to publish and one to subscribe (a subscribed Redis connection cannot issue
other commands), so the on() callback receives 'pub' or 'sub' first, and unwrap() returns the pair. In 12.1,
listeners registered on a client with on() stay registered until close(), so they are also attached to the new
connections the client creates after a failed connect; do not register the same listener again from a callback, because
registrations are not deduplicated. 12.1.0 also fixed the Redis client so that it retries after a failed connect
(PR 17828).
MQTT
MQTT is a lightweight publish/subscribe protocol for constrained devices and unreliable networks, with a broker in the
middle. The transporter uses the mqtt (MQTT.js) package. Unlike Redis, it offers three delivery levels (QoS 0, 1 and
2) negotiated per subscription and per publish.
npm install mqtt
Options
The options are the MQTT.js client options plus url, subscribeOptions (global subscription QoS) and userProperties
(headers sent on every request, MQTT 5). On the server maxConnectionAttempts limits the initial connection attempts
(default -1, unlimited; reconnections are not limited).
Topics, wildcards and QoS
MQTT topics use / as the separator. + matches exactly one level and # matches the rest of the topic. The default
subscription QoS is 0 for every @MessagePattern() and @EventPattern(); set subscribeOptions: { qos } for a global
value, or pass { qos } as the extras argument of one decorator to override it for that pattern. The effective QoS of
a delivery is the lower of the QoS the publisher used and the QoS granted to the subscription, which is MQTT’s rule and
is why both sides matter.
import { Controller } from '@nestjs/common';
import {
Ctx,
EventPattern,
MessagePattern,
MqttContext,
Payload,
} from '@nestjs/microservices';
@Controller()
export class ReadingsController {
readonly seen: { topic: string; qos: number; data: unknown }[] = [];
// '+' matches exactly one topic level, '#' matches the rest of the topic.
@MessagePattern('shelves/+/temperature/+')
temperature(@Payload() data: { celsius: number }, @Ctx() ctx: MqttContext) {
return { topic: ctx.getTopic(), celsius: data.celsius };
}
// Per-pattern QoS overrides the global subscribeOptions.qos.
@EventPattern<string>('books/+/reviews', { qos: 1 })
review(@Payload() data: { text: string }, @Ctx() ctx: MqttContext) {
this.seen.push({
topic: ctx.getTopic(),
qos: ctx.getPacket().qos ?? 0,
data,
});
}
@EventPattern<string>('audit/#')
audit(@Payload() data: unknown, @Ctx() ctx: MqttContext) {
this.seen.push({ topic: ctx.getTopic(), qos: ctx.getPacket().qos ?? 0, data });
}
}
Source: NestJS: MQTT - Per-pattern QoS
Against an MQTT broker (Aedes, in-process), a request to shelves/a1/temperature/top is matched by the
shelves//temperature/ pattern and answered with { topic: 'shelves/a1/temperature/top', celsius: 21 }; events on
books/42/reviews and audit/login/ok are delivered with getTopic() returning the concrete topic, and getPacket().qos
is 0 by default.
Record builders and user properties
MqttRecordBuilder sets per-message options: setQoS(), the retain and DUP flags and, with MQTT 5, setProperties()
for user properties. A record built with .setQoS(1) arrives with getPacket().qos === 1.
User properties require protocol version 5 on the connection; Aedes speaks only MQTT 3.1.1, so the following is
compile-checked only:
import { ClientProxyFactory, MqttRecordBuilder, Transport } from '@nestjs/microservices';
// MQTT 5 user properties (the in-process broker used for the runnable tests is MQTT 3.1.1 only).
export const mqttRecord = new MqttRecordBuilder('payload')
.setProperties({ userProperties: { 'x-version': '1.0.0' } })
.setQoS(1)
.build();
export const mqttV5Client = ClientProxyFactory.create({
transport: Transport.MQTT,
options: {
url: 'mqtt://localhost:1883',
protocolVersion: 5,
userProperties: { 'x-version': '1.0.0' },
subscribeOptions: { qos: 1 },
},
});
Source: NestJS: MQTT - Record builders
On the server read them through MqttContext.getPacket(): const { properties: { userProperties } } = context.getPacket();.
To send the same user properties on every request of a client, pass userProperties in the client’s options.
NATS v3
NATS is a lightweight, high-performance messaging system written in Go. The Nest 12 transporter targets NATS v3 and
uses the @nats-io/transport-node driver. The legacy nats package and the StringCodec/JSONCodec helpers are gone;
if you upgrade, npm uninstall nats and install the new package (nest upgrade does the swap for you).
npm install @nats-io/transport-node
# only when your own code imports NATS helpers such as headers():
npm install @nats-io/nats-core
Options
| Option | Meaning |
|---|---|
|
Connection URLs (a NATS connection option). |
|
Queue group the server subscribes with; leave it unset for plain subscriptions. |
|
First unsubscribe from all subjects, then wait |
|
Milliseconds to wait after unsubscribing when |
|
Headers added to every message a client sends; headers on a record take precedence. |
All NATS connection options are accepted as well:
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
export const natsOptions: MicroserviceOptions = {
transport: Transport.NATS,
options: {
servers: ['nats://localhost:4222'],
queue: 'reviews-workers',
gracefulShutdown: true,
gracePeriod: 10000,
headers: { 'x-version': '1.0.0' },
},
};
Source: NestJS: NATS - Options
Patterns, request-response and event semantics
The transporter does not use the NATS built-in request-reply API. The client publishes the request on the subject with a
unique reply subject, and the responder publishes to that reply subject, which is routed back dynamically wherever each
party is. Events use plain NATS publish-subscribe: every active subscriber on the subject receives the event
(fan-out). Subject wildcards work in patterns: * for one token, > for one or more.
import { Controller } from '@nestjs/common';
import {
Ctx,
EventPattern,
MessagePattern,
NatsContext,
Payload,
} from '@nestjs/microservices';
@Controller()
export class ReviewsController {
readonly handled: { subject: string; data: unknown }[] = [];
// Subject wildcards: '*' matches one token, '>' matches one or more tokens.
@MessagePattern('time.us.*')
time(@Payload() zone: string, @Ctx() ctx: NatsContext) {
return { subject: ctx.getSubject(), zone };
}
@MessagePattern('replace-emoji')
replaceEmoji(@Payload() text: string, @Ctx() ctx: NatsContext) {
const headers = ctx.getHeaders();
return headers?.get('x-version') === '1.0.0' ? '🐱' : '🐈';
}
@EventPattern<string>('reviews.created')
created(@Payload() data: unknown, @Ctx() ctx: NatsContext) {
this.handled.push({ subject: ctx.getSubject(), data });
}
}
Source: NestJS: NATS - Wildcards
Queue groups
A queue group load-balances: of all subscribers that share a queue name, NATS delivers each message to exactly one.
With two instances of the same service and queue: 'reviews-workers' on both, ten reviews.created events are split
between them (every event handled once, both instances get some). With no queue, each event is handled by both
instances.
const app = await NestFactory.createMicroservice<MicroserviceOptions>(ReviewsModule, {
transport: Transport.NATS,
options: {
servers: ['nats://localhost:4222'],
queue: 'reviews-workers',
},
});
Source: NestJS: NATS - Queue groups
Record builders and headers
NatsRecordBuilder sends headers with a request or event; headers() now comes from @nats-io/nats-core. Headers
given in the client’s options apply to every message and are overridden per key by the record’s headers. With a client
created with a default x-version of 0.9.0, a plain send() reaches the handler without 1.0.0 and gets the
🐈 reply, and a send of a record carrying x-version: 1.0.0 gets 🐱. On the server NatsContext.getHeaders() returns
them and getSubject() the concrete subject (time.us.east for a time.us.* handler).
import { headers } from '@nats-io/nats-core';
import { NatsRecordBuilder } from '@nestjs/microservices';
const h = headers();
h.set('x-version', '1.0.0');
const record = new NatsRecordBuilder(':cat:').setHeaders(h).build();
this.client.send('replace-emoji', record).subscribe();
Source: NestJS: NATS - Record builders
A custom serializer or deserializer written for NATS 2 must change: packets are JSON strings now and a custom
deserializer receives the whole NATS message, so read the payload with msg.json(). Events on the client are
disconnect, reconnect and update (servers added or removed), reachable through on().
RabbitMQ
RabbitMQ is a message broker speaking AMQP. The transporter uses amqplib and amqp-connection-manager (which
reconnects the connection and re-runs the channel setup after a disconnect). Broker concepts (exchanges, queue types,
dead lettering, TTL) are in RabbitMQ Architecture and the AMQP Model, RabbitMQ Routing Patterns and
RabbitMQ Consuming; this section is only the Nest side.
npm install amqplib amqp-connection-manager
Options
| Option | Meaning |
|---|---|
|
Connection URLs, tried in order. |
|
Queue the server consumes from (and the client sends to). |
|
Channel prefetch; the second flag applies it per channel instead of per consumer. |
|
Default |
|
Consumer tag; usually omit it and let the broker generate one. |
|
Options of |
|
Extra socket options. |
|
Headers sent with every message (client only). |
|
Reply queue of the producer. Default |
|
Messages survive a broker restart, provided the queue does too. |
|
Skip asserting the queue before consuming. Default |
|
Route through a topic exchange so patterns may use |
|
Exchange name (defaults to the queue name when |
|
An additional routing key to bind with. |
|
Consumer only; default |
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
export const rmqOptions: MicroserviceOptions = {
transport: Transport.RMQ,
options: {
urls: ['amqp://localhost:5672'],
queue: 'bookshelf_queue',
prefetchCount: 10,
isGlobalPrefetchCount: false,
noAck: false,
queueOptions: { durable: true },
persistent: true,
exchange: 'bookshelf',
exchangeType: 'topic',
wildcards: true,
routingKey: 'reviews.#',
maxConnectionAttempts: 5,
},
};
Source: NestJS: RabbitMQ - Options
Manual acknowledgement and durable queues
With the default noAck: true RabbitMQ considers a message done the moment it is delivered, so a crash loses it. Set
noAck: false and the handler must acknowledge, on the channel taken from RmqContext.getChannelRef(), with the original
message from getMessage(). If the consumer dies before acknowledging, the broker re-queues the message. Pair this with a
durable queue (queueOptions: { durable: true }) and persistent messages (persistent: true) for messages that must
survive a broker restart; as the official page puts it, persistent messages survive a restart only in a queue that survives one too.
import { Controller } from '@nestjs/common';
import {
Ctx,
EventPattern,
MessagePattern,
Payload,
RmqContext,
} from '@nestjs/microservices';
@Controller()
export class RmqNotificationsController {
readonly handled: { pattern: string; priority?: number; version?: string }[] = [];
// noAck: false -> the handler MUST acknowledge, otherwise RabbitMQ keeps the
// message unacknowledged (and redelivers it when the channel closes).
@MessagePattern('notifications.render')
render(@Payload() data: { user: string }, @Ctx() context: RmqContext) {
const channel = context.getChannelRef();
const original = context.getMessage();
const { headers, priority } = original.properties;
this.handled.push({
pattern: context.getPattern(),
priority,
version: headers?.['x-version'],
});
channel.ack(original);
return `Hello ${data.user}`;
}
// Events are acknowledged the same way.
@EventPattern<string>('notifications.sent')
sent(@Payload() data: unknown, @Ctx() context: RmqContext) {
this.handled.push({ pattern: context.getPattern() });
context.getChannelRef().ack(context.getMessage());
}
}
A request built with RmqRecordBuilder, with x-version and priority: 3, sent to a server using noAck: false
and queueOptions: { durable: true }: the handler sees priority 3 and the header on getMessage().properties, the
reply Hello ana arrives, the channel records one acknowledgement and no pending unacknowledged messages, and
assertQueue is called with { durable: true }. What happens to an unacknowledged message when the consumer dies is
the broker’s documented behaviour (redelivery), which an in-memory channel fake cannot show.
A handler path that does not acknowledge (an exception thrown before ack(), for instance) leaves the message
unacknowledged; the channel also has nack() and reject() (amqplib methods) for deliberate rejection.
import { RmqRecordBuilder } from '@nestjs/microservices';
const record = new RmqRecordBuilder({ user: 'ana' })
.setOptions({
headers: { 'x-version': '1.0.0' },
priority: 3,
})
.build();
this.client.send('notifications.render', record).subscribe();
Topic exchanges and wildcards
With wildcards: true the transporter asserts a topic exchange (named like the queue unless exchange is set), binds the
queue with each handler pattern as routing key, and the client publishes to the exchange with the pattern as routing
key. In a routing key matches exactly one word and zero or more: reviews. matches reviews,
reviews.created and reviews.created.eu, while books. matches books.42 but not books.42.cover.
import { Controller } from '@nestjs/common';
import {
Ctx,
EventPattern,
MessagePattern,
Payload,
RmqContext,
} from '@nestjs/microservices';
@Controller()
export class RmqTopicController {
readonly routingKeys: string[] = [];
// '#' matches zero or more words, '*' exactly one word.
@EventPattern<string>('reviews.#')
onReviews(@Payload() data: unknown, @Ctx() context: RmqContext) {
this.routingKeys.push(context.getPattern());
}
@MessagePattern('books.*')
book(@Payload() data: { id: number }, @Ctx() context: RmqContext) {
return { routingKey: context.getPattern(), id: data.id };
}
}
Source: NestJS: RabbitMQ - Wildcards
Run against the in-memory fake, an exchange named bookshelf_queue of type topic was created and the three
reviews.* events reached the handler (getPattern() returned the concrete routing key each time), while books.42 as a
request was answered with { routingKey: 'books.42', id: 42 }.
Kafka
Kafka is a distributed, durable log with consumer groups and replay (see
Kafka Architecture and
Kafka Consumers). The transporter uses kafkajs.
npm install kafkajs
Options
| Option | Meaning |
|---|---|
|
KafkaJS client configuration ( |
|
KafkaJS consumer options ( |
|
Options for the consumer’s |
|
Options for |
|
KafkaJS producer options. |
|
Options for the producer’s |
|
Client only: skip consumer-group registration so the client only produces. |
|
Suffix appended to |
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
export const kafkaOptions: MicroserviceOptions = {
transport: Transport.KAFKA,
options: {
client: { clientId: 'bookshelf', brokers: ['localhost:9092'] },
consumer: { groupId: 'bookshelf-consumer' },
subscribe: { fromBeginning: true },
run: { autoCommit: false },
send: { acks: -1 },
postfixId: '-server',
},
};
Source: NestJS: Kafka - Options
Naming conventions
The transporter appends -server to the server’s clientId and groupId and -client to the client’s, so a client and
a server using the same configuration never collide: a consumer groupId of bookshelf-consumer becomes
bookshelf-consumer-server for the microservice and bookshelf-consumer-client for the client (both asserted in the
test). Change the suffix with postfixId, or extend ClientKafka or ServerKafka and override the constructor for
more control. For request-response the reply topic is the request topic plus .reply (books.get is answered on
books.get.reply); override getResponsePatternName() in a subclass of ClientKafka to change it.
Request-response over reply topics
Kafka has no built-in request-reply, so send() builds one: the client sends the request with a correlation id, a reply
topic and a reply partition in the message headers, and the server replies to that topic and partition with the same
correlation id. The client must therefore be subscribed to the reply topic, and assigned a partition of it, before it
sends. Three consequences follow. Call subscribeToResponseOf('<request topic>') for every request topic, before
connect() if you connect yourself. Provision at least one reply-topic partition for every running instance of
the client application: with four instances and a three-partition reply topic, one instance fails to send. And a
rebalance can strand replies, which Nest avoids with a built-in partition assigner that lets existing clients keep the
reply partition they had.
import { Inject, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import type { ClientKafkaProxy } from '@nestjs/microservices';
import { firstValueFrom, timeout } from 'rxjs';
@Injectable()
export class BooksKafkaGateway implements OnModuleInit, OnModuleDestroy {
constructor(@Inject('KAFKA_CLIENT') private readonly client: ClientKafkaProxy) {}
async onModuleInit() {
// Required for request-response: subscribe to "books.get.reply" BEFORE connecting.
this.client.subscribeToResponseOf('books.get');
await this.client.connect();
}
async onModuleDestroy() {
await this.client.close();
}
getBook(id: number) {
return firstValueFrom(
this.client.send<{ id: number; title: string }, { id: number }>('books.get', { id }).pipe(timeout(5000)),
);
}
// Events need no reply topic.
reviewCreated(bookId: number) {
return firstValueFrom(this.client.emit('reviews.created', { bookId }));
}
}
import { Controller } from '@nestjs/common';
import {
Ctx,
EventPattern,
KafkaContext,
KafkaRetriableException,
MessagePattern,
Payload,
} from '@nestjs/microservices';
@Controller()
export class BooksKafkaController {
readonly log: string[] = [];
private flakyCalls = 0;
// Request-response: the answer goes to "books.get.reply". Returning
// { key, value, headers } keys and annotates the reply message.
@MessagePattern('books.get')
get(@Payload() message: { id: number }) {
return {
key: String(message.id),
value: { id: message.id, title: 'Dune' },
headers: { kafka_nestRealm: 'bookshelf' },
};
}
// A KafkaRetriableException is not turned into an error reply: it bubbles up
// to KafkaJS, which redelivers the message (the offset is not committed).
@MessagePattern('books.flaky')
flaky(@Payload() message: { id: number }) {
this.flakyCalls += 1;
if (this.flakyCalls < 3) throw new KafkaRetriableException('try again');
return { id: message.id, attempts: this.flakyCalls };
}
// A RegExp pattern subscribes to every matching topic (Nest 12).
@EventPattern(/^reviews\..+$/)
onReview(@Payload() data: unknown, @Ctx() context: KafkaContext) {
this.log.push(`${context.getTopic()}#${context.getPartition()}`);
}
}
Source: NestJS: Kafka - Outgoing
Incoming messages reach the handler as key, value and headers buffers that Nest turns into strings, parsing
anything that starts with { or [ as JSON. Outgoing values that are not strings or buffers are serialized with
JSON.stringify(); to key a reply (so that it lands on a stable partition) or add headers, return
{ key, value, headers }, with header values as strings or buffers. The reply to books.get
arrives as { id: 7, title: 'Dune' }, the reply message carries the key 7 and the header
kafka_nestRealm: bookshelf, and the reply is produced on books.get.reply.
RegExp patterns
@MessagePattern() and @EventPattern() accept a RegExp on Kafka since Nest 12: the pattern is passed to KafkaJS’s
subscribe() and, when Nest resolves a handler for an incoming topic, it falls back to regular-expression matching
(resetting lastIndex, so /g and /y expressions behave). In books.controller.ts above,
@EventPattern(/^reviews\..+$/) handled reviews.created and reviews.deleted but not other.topic, and
context.getTopic() returned the concrete topic. Other transporters still match patterns by exact value.
KafkaContext, heartbeats and offsets
KafkaContext gives getTopic(), getPartition(), getMessage() (the original IncomingMessage with offset,
headers, timestamp, …), getConsumer() (the KafkaJS consumer) and getHeartbeat(). Call the heartbeat from a slow
handler between steps so the session timeout does not expire and trigger a rebalance.
By default KafkaJS commits offsets automatically. To commit only after the work is done, set run: { autoCommit: false }
and commit the next offset to consume (the current offset plus one) from the handler:
import { Controller } from '@nestjs/common';
import {
Ctx,
EventPattern,
KafkaContext,
Payload,
} from '@nestjs/microservices';
@Controller()
export class ManualCommitController {
readonly processed: unknown[] = [];
// Pair with `run: { autoCommit: false }` in the server options.
@EventPattern<string>('payments.settled')
async settled(@Payload() data: unknown, @Ctx() context: KafkaContext) {
this.processed.push(data); // business logic first ...
const { offset } = context.getMessage();
const consumer = context.getConsumer();
// ... then commit the NEXT offset to consume (current offset + 1).
await consumer.commitOffsets([
{
topic: context.getTopic(),
partition: context.getPartition(),
offset: (Number(offset) + 1).toString(),
},
]);
}
}
Source: NestJS: Kafka - Commit offsets
With autoCommit: false, the consumer receives a commit for payments.settled, partition 0, with an
offset one greater than the message’s.
Retriable exceptions and retry filters
An unhandled exception in a message handler is normally turned into an error reply. To make KafkaJS retry instead,
throw KafkaRetriableException: it bypasses the reply, bubbles up to KafkaJS’s eachMessage callback (even with
interceptors or an Observable response) and the offset is not committed, so the message is redelivered. A
books.flaky handler that throws it twice and then succeeds answers { id: 1, attempts: 3 }: the request is
redelivered twice. Events
are different: all unhandled exceptions in event handlers are already treated as retriable.
An unbounded retry loop blocks the partition on a poison message. A bounded alternative is an exception filter that
republishes the event with a retry-count header and, when the limit is reached, skips it by committing its offset. This
filter is adapted from the official one:
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { KafkaContext } from '@nestjs/microservices';
import type { Producer } from 'kafkajs';
const toWire = (v: unknown) =>
v === null || typeof v === 'string' || Buffer.isBuffer(v)
? v
: JSON.stringify(v);
/**
* Retries a failing event a bounded number of times by republishing it with a
* retry-count header, then skips it (committing its offset) so the partition
* is not blocked by a poison message.
*/
@Catch()
export class KafkaMaxRetryExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(KafkaMaxRetryExceptionFilter.name);
constructor(
private readonly producer: Producer,
private readonly maxRetries: number,
private readonly skipHandler?: (message: unknown) => Promise<void>,
) {}
async catch(exception: unknown, host: ArgumentsHost) {
const context = host.switchToRpc().getContext<KafkaContext>();
const message = context.getMessage();
const retries = this.retryCount(context);
if (retries >= this.maxRetries) {
this.logger.warn(`Max retries (${this.maxRetries}) exceeded, skipping`);
await this.skipHandler?.(message);
await this.commit(context);
return; // stop propagating: KafkaJS sees a successful handler
}
try {
await this.producer.send({
topic: context.getTopic(),
messages: [
{
key: message.key,
// Nest has already JSON-parsed the value; KafkaJS only accepts
// strings and Buffers, so serialize it again.
value: toWire(message.value),
headers: { ...message.headers, 'retry-count': String(retries + 1) },
},
],
});
await this.commit(context);
} catch (republishError) {
this.logger.error('Failed to republish for retry', republishError);
throw exception; // let KafkaJS redeliver the original message
}
}
private retryCount(context: KafkaContext): number {
const header = context.getMessage().headers?.['retry-count'];
return header ? parseInt(String(header), 10) || 0 : 0;
}
private async commit(context: KafkaContext) {
await context.getConsumer().commitOffsets([
{
topic: context.getTopic(),
partition: context.getPartition(),
// commit the NEXT offset: current offset + 1
offset: (Number(context.getMessage().offset) + 1).toString(),
},
]);
}
}
Nest hands the filter a message whose value it has already parsed from JSON into an object, and KafkaJS only accepts
strings and buffers as values. Republishing message.value as is therefore fails (KafkaJS requires a string or Buffer, and an object
is rejected with The first argument must be of type string or an instance of Buffer), the filter
rethrows, and the message is redelivered unchanged instead of being retried by the filter, which is why toWire()
serializes the value again. The filter needs a Kafka producer; register
it as a provider (the factory reads the broker list from ConfigService) and apply it with @UseFilters():
import { Controller, Inject, Injectable, UseFilters } from '@nestjs/common';
import { Payload, EventPattern } from '@nestjs/microservices';
import type { Producer } from 'kafkajs';
import { KafkaMaxRetryExceptionFilter } from './kafka-max-retry.filter.js';
@Injectable()
export class AppKafkaRetryFilter extends KafkaMaxRetryExceptionFilter {
constructor(@Inject('KAFKA_PRODUCER') producer: Producer) {
super(producer, 2); // maxRetries = 2
}
}
@Controller()
@UseFilters(AppKafkaRetryFilter)
export class RetryController {
readonly attempts: number[] = [];
@EventPattern<string>('emails.send')
async send(@Payload() data: { n: number }) {
this.attempts.push(data.n);
throw new Error('SMTP down'); // always fails -> retried twice, then skipped
}
}
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Kafka } from 'kafkajs';
import { AppKafkaRetryFilter, RetryController } from './retry.controller.js';
import { BooksKafkaController } from './books.controller.js';
import { ManualCommitController } from './manual-commit.controller.js';
@Module({
imports: [ConfigModule.forRoot()],
controllers: [BooksKafkaController, ManualCommitController, RetryController],
providers: [
AppKafkaRetryFilter,
{
provide: 'KAFKA_PRODUCER',
inject: [ConfigService],
useFactory: async (config: ConfigService) => {
const kafka = new Kafka({
brokers: config.getOrThrow<string>('KAFKA_BROKERS').split(','),
});
const producer = kafka.producer();
await producer.connect();
return producer;
},
},
],
})
export class KafkaAppModule {}
With a handler that always fails and maxRetries of 2, the test saw the event handled three times (the original and two
republications with retry-count 1 and 2), three offset commits, and then no further delivery. In a test the
KAFKA_PRODUCER provider is replaced with overrideProvider().
Status and the KafkaJS instances
The status stream emits connected, disconnected, rebalancing, crashed and stopped. Subscribe to it on the client
or on the server returned by connectMicroservice(). For the producer and consumer of a client use the producer and
consumer properties of the ClientKafkaProxy (shown in driver-access.ts above).
gRPC
gRPC is an HTTP/2 RPC framework in which services and message types are defined in .proto files. Unlike the other
transporters there is no broker: client and server talk directly, @GrpcMethod() replaces @MessagePattern(), and the
client is a ClientGrpc whose getService() returns an object with one method per RPC. The same idea in other
stacks: Spring Boot gRPC APIs and Quarkus gRPC.
npm install @grpc/grpc-js @grpc/proto-loader
Options
| Option | Meaning |
|---|---|
|
Protobuf package name (or an array of names). Required. |
|
Path to the |
|
|
|
Name of the package that loads |
|
Options of |
|
Server credentials (channel credentials on a client). Default insecure. |
|
Hook called with the loaded package and the server, used for reflection and health checks below. |
Nest copies nothing for you: .proto files are not TypeScript, so tell the Nest CLI to ship them with the build in
nest-cli.json ("assets": ["*/.proto"], "watchAssets": true under compilerOptions), as the official page shows.
The examples here load the .proto from src/, so they do not depend on the asset copy.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { ServerCredentials } from '@grpc/grpc-js';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
// gRPC over TLS: certificate paths come from configuration, never from literals.
export const grpcTls = (dir: string): MicroserviceOptions => ({
transport: Transport.GRPC,
options: {
package: 'bookshelf',
protoPath: join(dir, 'books.proto'),
url: '0.0.0.0:50051',
credentials: ServerCredentials.createSsl(
readFileSync(join(dir, 'ca.pem')),
[{ cert_chain: readFileSync(join(dir, 'server.pem')), private_key: readFileSync(join(dir, 'server.key')) }],
false,
),
},
});
Source: NestJS: gRPC - Options
The service definition
The Bookshelf catalogue service has a unary lookup and create, plus one streaming RPC of each kind. Nest binds
handlers to the service and rpc names in this file.
syntax = "proto3";
package bookshelf;
service BooksService {
// unary
rpc FindOne (BookById) returns (Book) {}
rpc Create (Book) returns (Book) {}
// server streaming
rpc ListReviews (BookById) returns (stream Review) {}
// client streaming
rpc RateMany (stream Rating) returns (RatingSummary) {}
// bidirectional streaming
rpc Chat (stream ChatMessage) returns (stream ChatMessage) {}
// client streaming handled with @GrpcStreamCall (callback style)
rpc CountRatings (stream Rating) returns (RatingSummary) {}
// bidirectional streaming handled with @GrpcStreamCall (duplex stream)
rpc Echo (stream ChatMessage) returns (stream ChatMessage) {}
}
message BookById {
int32 id = 1;
}
message Book {
int32 id = 1;
string title = 2;
}
message Review {
int32 book_id = 1;
string text = 2;
}
message Rating {
int32 book_id = 1;
int32 stars = 2;
}
message RatingSummary {
int32 count = 1;
double average = 2;
}
message ChatMessage {
string text = 1;
}
Handlers: unary, server streaming, client streaming and bidirectional
@GrpcMethod(service, rpc) handles unary and server-streaming RPCs. Both arguments are optional: without the RPC name the
method name with its first letter capitalised is used (create handles Create), and without the service name the
controller’s class name is used. A handler receives the request data, the request Metadata and the call object (for
example sendMetadata()).
A server-streaming handler returns an Observable: each emitted value is a stream message. Returning an array is wrong:
it fails with 13 INTERNAL: Error serializing response: … expected object with Review structure, got array
instead; wrap it with from([…]).
Request streams use @GrpcStreamMethod() (the handler receives an Observable of requests; for a client-streaming RPC the
last value of the returned Observable is the response, and for a bidirectional RPC every value is a response message
and the handler must return an Observable) or @GrpcStreamCall() (the handler gets the raw duplex stream, writes with
write(), ends with end(), and for a client-streaming RPC responds through a callback). The controller below uses
both strategies:
import { Controller } from '@nestjs/common';
import {
GrpcAlreadyExistsException,
GrpcMethod,
GrpcNotFoundException,
GrpcStreamCall,
GrpcStreamMethod,
} from '@nestjs/microservices';
import { Metadata } from '@grpc/grpc-js';
import type { ServerDuplexStream, ServerUnaryCall } from '@grpc/grpc-js';
import { Observable, Subject, from, map, reduce } from 'rxjs';
export interface Book {
id: number;
title: string;
}
export interface BookById {
id: number;
}
export interface Review {
book_id: number;
text: string;
}
export interface Rating {
book_id: number;
stars: number;
}
export interface RatingSummary {
count: number;
average: number;
}
export interface ChatMessage {
text: string;
}
@Controller()
export class BooksController {
private readonly books = new Map<number, Book>([
[1, { id: 1, title: 'Dune' }],
[2, { id: 2, title: 'Emma' }],
]);
// Both arguments given: service name and rpc name.
@GrpcMethod('BooksService', 'FindOne')
findOne(
data: BookById,
metadata: Metadata,
call: ServerUnaryCall<BookById, Book>,
): Book {
// Echo the request id back as initial (header) metadata.
const out = new Metadata();
out.add('x-request-id', String(metadata.get('x-request-id')[0] ?? 'none'));
call.sendMetadata(out);
const book = this.books.get(data.id);
if (!book) throw new GrpcNotFoundException(`Book ${data.id} not found`);
return book;
}
// Only the service name given: the rpc is derived from the method name
// (create -> Create).
@GrpcMethod('BooksService')
create(data: Book): Book {
if (this.books.has(data.id)) {
throw new GrpcAlreadyExistsException('Book already exists');
}
this.books.set(data.id, data);
return data;
}
// Server streaming: return an Observable, every value is a stream message.
@GrpcMethod('BooksService', 'ListReviews')
listReviews(data: BookById): Observable<Review> {
return from([
{ book_id: data.id, text: 'Great' },
{ book_id: data.id, text: 'Long' },
{ book_id: data.id, text: 'Classic' },
]);
}
// Client streaming with the RxJS strategy: the last value emitted is the
// single response.
@GrpcStreamMethod('BooksService', 'RateMany')
rateMany(ratings: Observable<Rating>): Observable<RatingSummary> {
return ratings.pipe(
reduce(
(acc, r) => ({ count: acc.count + 1, sum: acc.sum + r.stars }),
{ count: 0, sum: 0 },
),
map(({ count, sum }) => ({
count,
average: count === 0 ? 0 : sum / count,
})),
);
}
// Bidirectional streaming with the Subject strategy.
@GrpcStreamMethod('BooksService', 'Chat')
chat(messages: Observable<ChatMessage>): Observable<ChatMessage> {
const subject = new Subject<ChatMessage>();
messages.subscribe({
next: (m) => subject.next({ text: `echo: ${m.text}` }),
complete: () => subject.complete(),
});
return subject.asObservable();
}
// Client streaming with the call-stream handler: respond through the callback.
@GrpcStreamCall('BooksService', 'CountRatings')
countRatings(
requestStream: ServerDuplexStream<Rating, RatingSummary>,
callback: (err: unknown, value: RatingSummary) => void,
) {
let count = 0;
let sum = 0;
requestStream.on('data', (r: Rating) => {
count += 1;
sum += r.stars;
});
requestStream.on('end', () =>
callback(null, { count, average: count === 0 ? 0 : sum / count }),
);
}
// Bidirectional streaming with the call-stream handler: a plain Duplex.
@GrpcStreamCall('BooksService', 'Echo')
echo(call: ServerDuplexStream<ChatMessage, ChatMessage>) {
call.on('data', (m: ChatMessage) => call.write({ text: `echo: ${m.text}` }));
call.on('end', () => call.end());
}
}
Source: NestJS: gRPC - Subject strategy
The server is created with GrpcExceptionFilter, reflection and health checks (all described below):
import { join } from 'node:path';
import { NestFactory } from '@nestjs/core';
import {
GrpcExceptionFilter,
MicroserviceOptions,
Transport,
} from '@nestjs/microservices';
import { ReflectionService } from '@grpc/reflection';
import {
HealthImplementation,
protoPath as healthProtoPath,
} from 'grpc-health-check';
import { BooksModule } from './books.module.js';
export async function startBooksGrpc(url = '0.0.0.0:50151') {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
BooksModule,
{
transport: Transport.GRPC,
options: {
package: 'bookshelf',
protoPath: [healthProtoPath, join(import.meta.dirname, 'books.proto')],
url,
loader: { keepCase: true },
onLoadPackageDefinition: (pkg, server) => {
new ReflectionService(pkg).addToServer(server);
const health = new HealthImplementation({ '': 'UNKNOWN' });
health.addToServer(server);
health.setStatus('', 'SERVING');
},
},
},
);
app.useGlobalFilters(new GrpcExceptionFilter());
await app.listen();
return app;
}
Source: NestJS: gRPC - gRPC Reflection
Fields containing an underscore (book_id here) are renamed to camel case by the default proto loader and, per the
docs, a gRPC client does not send them unless keepCase: true is set under loader. Both server and client above set
it. The failure mode: with keepCase: false on both ends, the Review message arrives without its
book_id, because the handler returns book_id while the loader expects bookId. Keep the loader options identical
on both sides.
The client
Register the package with ClientsModule, inject the ClientGrpc with the registration name and call getService() once
the module is initialised. The returned object is typed by an interface you write; methods are lower camel case
(FindOne becomes findOne), unary calls return an Observable, and a client-streaming or bidirectional call takes an
Observable of requests (a ReplaySubject is convenient). Under the Nest template (isolatedModules together with
emitDecoratorMetadata) the ClientGrpc interface must be imported with import type in a decorated constructor,
otherwise TS1272.
import { Inject, Injectable, Module, OnModuleInit } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import type { ClientGrpc } from '@nestjs/microservices';
import { join } from 'node:path';
import { Metadata } from '@grpc/grpc-js';
import { Observable, ReplaySubject, lastValueFrom, toArray } from 'rxjs';
import type {
Book,
BookById,
ChatMessage,
Rating,
RatingSummary,
Review,
} from './books.controller.js';
interface BooksService {
findOne(data: BookById, metadata?: Metadata): Observable<Book>;
create(data: Book): Observable<Book>;
listReviews(data: BookById): Observable<Review>;
rateMany(upstream: Observable<Rating>): Observable<RatingSummary>;
chat(upstream: Observable<ChatMessage>): Observable<ChatMessage>;
}
@Injectable()
export class BooksClient implements OnModuleInit {
private books!: BooksService;
constructor(@Inject('BOOKS_PACKAGE') private readonly client: ClientGrpc) {}
onModuleInit() {
this.books = this.client.getService<BooksService>('BooksService');
}
findOne(id: number) {
const metadata = new Metadata();
metadata.add('x-request-id', 'req-42');
return lastValueFrom(this.books.findOne({ id }, metadata));
}
create(book: Book) {
return lastValueFrom(this.books.create(book));
}
reviews(id: number) {
return lastValueFrom(this.books.listReviews({ id }).pipe(toArray()));
}
rate(stars: number[]) {
const upstream = new ReplaySubject<Rating>();
stars.forEach((s) => upstream.next({ book_id: 1, stars: s }));
upstream.complete();
return lastValueFrom(this.books.rateMany(upstream));
}
chat(texts: string[]) {
const upstream = new ReplaySubject<ChatMessage>();
texts.forEach((text) => upstream.next({ text }));
upstream.complete();
return lastValueFrom(this.books.chat(upstream).pipe(toArray()));
}
}
@Module({
imports: [
ClientsModule.register([
{
name: 'BOOKS_PACKAGE',
transport: Transport.GRPC,
options: {
package: 'bookshelf',
protoPath: join(import.meta.dirname, 'books.proto'),
url: 'localhost:50151',
loader: { keepCase: true },
},
},
]),
],
providers: [BooksClient],
exports: [BooksClient],
})
export class BooksClientModule {}
Source: NestJS: gRPC - Client
The test called all of this against the real server: findOne(1) returned { id: 1, title: 'Dune' }; rate([5, 4, 3])
returned { count: 3, average: 4 }; chat(['a', 'b']) returned echo: a and echo: b; and the server stream returned
three reviews.
Errors and GrpcExceptionFilter
Before Nest 12 a gRPC error reached the client as UNKNOWN unless you built the error object yourself. Nest 12 ships
GrpcException (with a GrpcStatus) and a class per status code (GrpcNotFoundException, GrpcAlreadyExistsException,
GrpcInvalidArgumentException, GrpcPermissionDeniedException, GrpcUnauthenticatedException, GrpcUnavailableException
and so on for every code of GrpcStatus), and GrpcExceptionFilter turns them into proper gRPC errors. Register the
filter with useGlobalFilters() on the microservice, as main.ts does. The resulting status codes:
| Thrown by the handler | Without the filter | With GrpcExceptionFilter |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
code 3 ( |
code 3, |
|
|
|
Without the filter every Grpc*Exception was reported as UNKNOWN with a generic message and only an RpcException with a
numeric code produced a specific status, so registering the filter is not optional; with it the status-specific
exceptions work and an RpcException whose error object has a numeric code or status uses that value. Any other
exception is reported as UNKNOWN with a generic message, so internal details are not leaked. The exception hierarchy
and filters in general are in
Exception Filters and Error Handling.
import { GrpcException, GrpcStatus } from '@nestjs/microservices';
throw new GrpcException('Rate limit exceeded', GrpcStatus.RESOURCE_EXHAUSTED);
Metadata
gRPC metadata is a list of key-value pairs attached to a call, typically authentication tokens, request ids and trace
context. A handler reads the request metadata from its second parameter and sends header metadata back with
call.sendMetadata(); findOne() above echoes the x-request-id request header. The raw @grpc/grpc-js client in the
test received the metadata event with the echoed value. In a @GrpcStreamCall() handler read request metadata by
listening for the metadata event on the request stream. On the client, pass a Metadata instance as the second
argument of the proxy method (books.client.ts sends x-request-id). Keys are lower-cased by gRPC (Metadata.add('X-Foo', …) is read back with get('x-foo')). The metadata event listener below is taken from the official page and was not exercised.
requestStream.on('metadata', (metadata: Metadata) => {
const meta = metadata.get('x-meta');
});
Source: NestJS: gRPC - gRPC Metadata
Reflection and health checks
Reflection lets tools such as grpcurl, Postman or grpc-ui discover the API without the .proto file. Install
@grpc/reflection and attach ReflectionService in onLoadPackageDefinition. The gRPC Health Checking protocol lets an
orchestrator probe the service (the grpc-health-check package plus the health .proto listed in protoPath); the
grpc-health-probe CLI is the usual client in Kubernetes (see the Kubernetes section).
Both are wired in main.ts above. A reflection client lists bookshelf.BooksService and
grpc.health.v1.Health among the services, and a health Check for the empty service name returns status 1, which is
SERVING in the numeric enum (set the loader option enums: String to get names).
npm install @grpc/reflection grpc-health-check
Custom transporters
When no built-in transporter fits (a cloud queue, a proprietary bus, an in-process test double) you write a strategy
and, if you want Nest clients, a client class. The server side extends Server and implements
CustomTransportStrategy (listen() and close() are required; on() and unwrap() may throw if you do not support
them). Server provides addHandler()/getHandlerByPattern(), handleEvent(), handleRequest() and the
messageHandlers map, so the transporter only has to receive packets from the wire and call into them. The client
extends ClientProxy and implements connect(), close(), unwrap(), publish() (request-response) and
dispatchEvent() (events). publish() returns a teardown function that runs when the caller unsubscribes, and the
response callback’s isDisposed flag marks the last value.
This minimal in-process transporter is complete and works end to end; a real one replaces the direct call with its network I/O:
import {
BaseRpcContext,
ClientProxy,
CustomTransportStrategy,
ReadPacket,
Server,
WritePacket,
} from '@nestjs/microservices';
/** A process-local "broker": the client hands packets straight to the server. */
export class MemoryBus {
server?: MemoryServer;
}
export class MemoryServer extends Server implements CustomTransportStrategy {
constructor(private readonly bus: MemoryBus) {
super();
}
/** Triggered by app.listen(). */
listen(callback: () => void) {
this.bus.server = this;
callback();
}
/** Triggered on application shutdown. */
close() {
this.bus.server = undefined;
}
on(): never {
throw new Error('Method not implemented.');
}
unwrap<T = never>(): T {
return this.bus as T;
}
/** What a real transporter calls when a message arrives from the wire. */
receive(pattern: string, data: unknown, respond?: (p: WritePacket) => void) {
const context = new BaseRpcContext([pattern]);
if (!respond) {
return this.handleEvent(pattern, { pattern, data }, context);
}
const handler = this.getHandlerByPattern(pattern);
if (!handler) {
return respond({ err: `No handler for ${pattern}` });
}
return this.handleRequest(
context,
async () => this.transformToObservable(await handler(data, context)),
respond,
);
}
}
export class MemoryClient extends ClientProxy {
constructor(private readonly options: { bus: MemoryBus }) {
super();
}
async connect() {}
async close() {}
unwrap<T = never>(): T {
return this.options.bus as T;
}
protected publish(
packet: ReadPacket,
callback: (packet: WritePacket) => void,
): () => void {
this.options.bus.server?.receive(packet.pattern, packet.data, callback);
return () => undefined; // teardown when the caller unsubscribes
}
protected async dispatchEvent(packet: ReadPacket): Promise<any> {
await this.options.bus.server?.receive(packet.pattern, packet.data);
}
}
The server side is plugged in with strategy instead of transport/options, and the client with customClass
(pass the class, not an instance; Nest instantiates it with the options you provide):
const bus = new MemoryBus();
const app = moduleRef.createNestMicroservice({ strategy: new MemoryServer(bus) });
await app.listen();
// client side: ClientsModule.register([{ name: 'MEMORY', customClass: MemoryClient, options: { bus } }])
With it, send('echo', { hello: 'world' }) round-trips the object, emit('seen', …) reaches the event handler,
an unknown pattern rejects with the error string passed to respond(), and closing the app unregisters the server.
Two details are specific to the real ServerKafka-style transports: set propagatesEventHandlerErrors = true on a
custom Server when your client library already reports a failed event handler (as KafkaJS does), so Nest does not log
the failure a second time, and remember that with interceptors the handler result may be an Observable that must be
subscribed to (handleRequest() and transformToObservable() do this for you). If you do not need
@EventPattern()/@MessagePattern() at all, a standalone application managing the client library itself
(see Platform Agnosticism and Standalone Apps)
is often simpler than a custom transporter.
Comparison
| Redis | MQTT | NATS | RabbitMQ | Kafka | gRPC | |
|---|---|---|---|---|---|---|
Package |
|
|
|
|
|
|
Model |
Pub/Sub channels |
Pub/Sub topics via a broker |
Pub/Sub subjects, optional queue groups |
Queues and exchanges |
Partitioned log, consumer groups |
Direct HTTP/2 RPC, no broker |
Request-response |
Reply channel |
Reply topic per request |
Reply subject per request |
Reply queue ( |
Reply topics |
Native unary and streaming calls |
Load balancing between instances |
None: every instance receives every message |
Per the broker and protocol version (shared subscriptions are an MQTT 5 broker feature, not set up by Nest) |
Queue groups |
One queue with competing consumers |
Consumer groups |
Client-side (gRPC name resolution and balancing policy) |
Durability and replay |
None |
Broker retained messages and QoS 1/2 sessions |
None in the Nest transporter (core NATS) |
Durable queues, persistent messages, acks |
Retained log; replay from offsets |
None (calls are synchronous) |
Wildcards |
Glob ( |
|
|
|
|
Not applicable: methods are named in the |
Delivery in Nest |
At most once |
QoS 0, 1 or 2 |
At most once |
At most once with |
At least once when offsets are committed after processing (manual commits or retriable exceptions) |
Synchronous: one response (or stream) or one error per call |
Context object |
|
|
|
|
|
|
Typed contract |
No |
No |
No |
No |
No |
Yes, from the |
Best for |
Cheap fan-out notifications, caches invalidation, when loss is acceptable |
IoT and device fleets, constrained or flaky networks |
Low-latency service messaging, simple scaling with queue groups |
Reliable work queues, routing, task processing |
Event streaming, audit logs, replay, high throughput |
Low-latency service-to-service calls with strong contracts and streaming |
How to choose, in short: use gRPC for synchronous calls between services you own (a typed contract, streaming, deadlines), and a broker for asynchronous work and events. Among brokers, Kafka when events must be retained and replayed, RabbitMQ when you need queues with acknowledgements and flexible routing, NATS when you want a small, fast bus, MQTT when the producers are devices, and Redis only when losing a message is acceptable. The delivery semantics shown here (what the broker promises) are explained in Delivery Guarantees, the Kafka versus RabbitMQ decision in Kafka vs RabbitMQ, and the models behind them in Messaging Models and Patterns. Reliability on top of any transporter (idempotent consumers, the outbox, dead-letter queues) is in Microservices Architecture and Patterns and Resilience, Idempotency and Locks; probes for a running service are in Health Checks and Observability.
What changed recently, and where older material is outdated
-
NATS v3 (12.0). The transporter uses
@nats-io/transport-node; thenatspackage,StringCodecandJSONCodecare gone, packets are JSON strings and custom deserializers receive the whole message (msg.json()). Older tutorials that importnatsorJSONCodecno longer work. -
GrpcExceptionFilterand the gRPC status exceptions (12.0). Older material shows hand-built{ code, message }objects thrown throughRpcException, or says every error isUNKNOWN. -
RegExp patterns on Kafka (12.0). Before 12 every topic had to be registered explicitly.
-
Microservices pre-request hook (12.0). A hook runs before a message handler (see Microservices Fundamentals).
-
12.1. The Redis client retries after a failed connect (PR 17828), a throwing callback no longer leaves other pending requests hanging (PR 17810), and
on()listeners on a client survive untilclose(). Release notes: v12.1.0 and v12.0.0. -
Strict TypeScript. The official snippets for
status,on()and@EventPattern()with a context parameter were written withoutstrictin mind; the adjusted forms on this page compile under it. -
Books and tutorials written for Nest 9 and 10 predate all of the above, show the
ClientProxytype for Kafka rather thanClientKafkaProxy, and treatnatsv2 as the NATS driver.
Pitfalls
-
Calling
emit()and never subscribing: the observable is cold, nothing is dispatched. -
Calling
send()withouttimeout(): with a broker transport a missing responder leaves the request pending forever. -
Redis (and any pattern-based wildcard): overlapping event and request patterns turn replies into events.
-
Running several instances on Redis Pub/Sub or on NATS without a queue group: every instance handles every message.
-
RabbitMQ with
noAck: falseand a handler path that forgets to acknowledge: the queue fills with unacknowledged messages and prefetch stalls the consumer. -
Kafka request-response without
subscribeToResponseOf()for every request topic, or with fewer reply-topic partitions than client instances. -
Kafka retries without a limit: a poison message blocks its partition; bound it, as the retry filter does.
-
Republishing a Nest-parsed Kafka
valuewithout serializing it again. -
gRPC
.protofiles missing fromdist/because the Nest CLIassetsentry is absent. -
gRPC loader options that differ between client and server (
keepCase,defaults,enums): fields vanish or change type. -
gRPC server-streaming handler that returns an array or a promise of one instead of an
Observable. -
ClientsModule.registerAsync()factories that cannot resolveConfigServicebecauseConfigModuleis not global. -
Treating the tests on this page as broker tests: the RabbitMQ and Kafka fakes do not model redelivery, rebalancing or persistence.