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 @grpc/grpc-js server and client over TCP: unary, server streaming, client streaming, bidirectional streaming, metadata, GrpcExceptionFilter, health and reflection.

Nothing is substituted. TLS credentials are compile-checked only.

MQTT

The real mqtt client library: wildcards, request-response, per-pattern QoS, record builder QoS, status, unwrap().

An in-process Aedes broker (MQTT 3.1.1 only). MQTT 5 user properties are compile-checked only.

Redis

The real ioredis client library: request-response, wildcard events, status, on(), unwrap(), and the overlapping-pattern pitfall.

A small RESP stub (about 120 lines) that understands only SUBSCRIBE, PSUBSCRIBE and PUBLISH. It is test scaffolding, not Redis.

NATS

The real @nats-io/transport-node client: queue groups, fan-out without a queue, wildcards, headers, status, unwrap().

A small stub (about 85 lines) of the NATS wire protocol (PUB, HPUB, SUB, wildcards, queue groups). Not nats-server.

RabbitMQ

The real ServerRMQ and ClientRMQ classes: manual acknowledgement, record builder options, durable queue assertion, topic exchange wildcards, request-response through the reply queue.

An in-memory fake of the amqplib channel and amqp-connection-manager connection, not a RabbitMQ broker.

Kafka

The real ServerKafka and ClientKafka classes: reply topics, key and headers on replies, RegExp patterns, KafkaRetriableException redelivery, manual offset commits, the retry filter.

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 strategy and customClass.

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.

bootstrap.ts
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:

bootstrap-from-config.ts
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 (?).

clients.module.ts
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 {}

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.

review-notifier.ts
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

@Ctx() injects RedisContext (getChannel()), MqttContext (getTopic(), getPacket()), NatsContext (getSubject(), getHeaders()), RmqContext (getPattern(), getMessage(), getChannelRef()), KafkaContext (getTopic(), getPartition(), getMessage(), getConsumer(), getHeartbeat()). gRPC handlers instead receive the Metadata and call objects as plain arguments.

status

An Observable of connection states on both the client and the server: connected, disconnected and reconnecting for Redis, NATS and MQTT (MQTT adds closed); connected, disconnected, blocked and unblocked for RabbitMQ; rebalancing, crashed and stopped for Kafka.

on()

Subscribes to the driver’s own events (for example error). Supported on the Redis, MQTT, NATS and RabbitMQ clients and servers. On Kafka it throws Method is not supported for Kafka server (and the equivalent for the client), which is why Kafka exposes its KafkaJS producer and consumer instead.

unwrap()

Returns the underlying driver: two ioredis instances (publisher, subscriber) for Redis, an MqttClient, a NatsConnection, an AmqpConnectionManager. Prefer not to need it.

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.

redis-connection-watcher.ts
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 };
  }
}
watch-server.ts
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 };
}
driver-access.ts
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

host, port

Connection address.

retryAttempts

Times to retry the connection. Default 0 (no retries).

retryDelay

Delay between retries in milliseconds. Default 5000.

wildcards

Use psubscribe/pmessage so patterns may contain glob wildcards. Default false. Set it on the client as well as the server.

All options of the ioredis client are accepted too. The option object (also used by the client):

redis options
import { MicroserviceOptions, Transport } from '@nestjs/microservices';

export const redisOptions: MicroserviceOptions = {
  transport: Transport.REDIS,
  options: {
    host: 'localhost',
    port: 6379,
    retryAttempts: 5,
    retryDelay: 1000,
    wildcards: true,
  },
};

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.

notifications.controller.ts
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 });
  }
}

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).

overlap pitfall
@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'

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.

sensors.controller.ts
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 });
  }
}

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:

mqtt-record.ts
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 },
  },
});

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

servers

Connection URLs (a NATS connection option).

queue

Queue group the server subscribes with; leave it unset for plain subscriptions.

gracefulShutdown

First unsubscribe from all subjects, then wait gracePeriod before closing. Default false.

gracePeriod

Milliseconds to wait after unsubscribing when gracefulShutdown is on. Default 10000.

headers

Headers added to every message a client sends; headers on a record take precedence.

All NATS connection options are accepted as well:

nats options
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' },
  },
};

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.

reviews.controller.ts
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 });
  }
}

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.

queue group
const app = await NestFactory.createMicroservice<MicroserviceOptions>(ReviewsModule, {
  transport: Transport.NATS,
  options: {
    servers: ['nats://localhost:4222'],
    queue: 'reviews-workers',
  },
});

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).

record with headers
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();

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

urls

Connection URLs, tried in order.

queue

Queue the server consumes from (and the client sends to).

prefetchCount, isGlobalPrefetchCount

Channel prefetch; the second flag applies it per channel instead of per consumer.

noAck

Default true. Set false for manual acknowledgement.

consumerTag

Consumer tag; usually omit it and let the broker generate one.

queueOptions

Options of assertQueue() (for example durable).

socketOptions

Extra socket options.

headers

Headers sent with every message (client only).

replyQueue

Reply queue of the producer. Default amq.rabbitmq.reply-to.

persistent

Messages survive a broker restart, provided the queue does too.

noAssert

Skip asserting the queue before consuming. Default false.

wildcards

Route through a topic exchange so patterns may use * and #.

exchange, exchangeType, exchangeArguments

Exchange name (defaults to the queue name when wildcards is on), type (topic by default; also direct, fanout, headers, or a custom type) and assert arguments.

routingKey

An additional routing key to bind with.

maxConnectionAttempts

Consumer only; default -1 (unlimited).

rabbitmq options
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,
  },
};

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.

notifications.controller.ts
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.

record builder
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.

topic.controller.ts
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 };
  }
}

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 }.

Status, events and the driver

The status stream emits connected, disconnected, blocked (the broker applied flow control, for example under a memory alarm) and unblocked. on('error', …​) and unwrap() (the AmqpConnectionManager) are available on both sides (see the common surface above).

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

client

KafkaJS client configuration (brokers, clientId, SSL/SASL, …​).

consumer

KafkaJS consumer options (groupId, sessionTimeout, …​).

run

Options for the consumer’s run() (for example autoCommit).

subscribe

Options for subscribe() except topics, which Nest derives from the registered patterns (for example fromBeginning).

producer

KafkaJS producer options.

send

Options for the producer’s send() except topic and messages (for example acks).

producerOnlyMode

Client only: skip consumer-group registration so the client only produces.

postfixId

Suffix appended to clientId and groupId. Default -server on the server and -client on the client.

kafka options
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',
  },
};

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.

sequenceDiagram autonumber participant C as ClientKafka (group bookshelf-consumer-client) participant Q as topic books.get participant S as ServerKafka (group bookshelf-consumer-server) participant R as topic books.get.reply C->>C: subscribeToResponseOf('books.get') and connect() C->>Q: send() with correlation id, reply topic, reply partition Q->>S: delivered to the consumer group S->>R: reply with the same correlation id (and optional key and headers) R->>C: delivered to the partition the client was assigned
kafka-client.ts
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 }));
  }
}
books.controller.ts
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()}`);
  }
}

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:

manual-commit.controller.ts
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(),
      },
    ]);
  }
}

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:

kafka-max-retry.filter.ts
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():

retry.controller.ts
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
  }
}
kafka.module.ts
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

package

Protobuf package name (or an array of names). Required.

protoPath

Path to the .proto file or an array of paths, absolute or relative to the working directory. Required unless a preloaded packageDefinition is passed.

url

host:port to bind (server) or connect to (client). Default localhost:5000; use 0.0.0.0:<port> in a container.

protoLoader

Name of the package that loads .proto files. Default @grpc/proto-loader.

loader

Options of @grpc/proto-loader (for example keepCase, defaults, enums).

credentials

Server credentials (channel credentials on a client). Default insecure.

onLoadPackageDefinition

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.

grpc tls (compile-checked only)
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,
    ),
  },
});

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.

books.proto
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:

books.controller.ts
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());
  }
}

The server is created with GrpcExceptionFilter, reflection and health checks (all described below):

main.ts
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;
}

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.

books.client.ts
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 {}

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

GrpcNotFoundException('Book 5 not found')

UNKNOWN (2), Internal server error

NOT_FOUND (5), Book 5 not found

GrpcAlreadyExistsException('Book already exists')

UNKNOWN (2), Internal server error

ALREADY_EXISTS (6), Book already exists

GrpcException('Rate limit exceeded', GrpcStatus.RESOURCE_EXHAUSTED)

UNKNOWN (2), Internal server error

RESOURCE_EXHAUSTED (8), Rate limit exceeded

RpcException('plain')

UNKNOWN, plain

UNKNOWN, plain

RpcException({ code: 3, message: 'bad id' })

code 3 (INVALID_ARGUMENT), bad id

code 3, bad id

new Error('boom')

UNKNOWN, Internal server error

UNKNOWN, Internal server error

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.

status exceptions
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.

reading call metadata
requestStream.on('metadata', (metadata: Metadata) => {
  const meta = metadata.get('x-meta');
});

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:

memory-transport.ts
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):

using the transporter
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

ioredis

mqtt

@nats-io/transport-node

amqplib, amqp-connection-manager

kafkajs

@grpc/grpc-js, @grpc/proto-loader

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 <pattern>.reply

Reply topic per request

Reply subject per request

Reply queue (amq.rabbitmq.reply-to)

Reply topics <topic>.reply with correlation id

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 (wildcards: true)

+ and #

* and >

* and # on a topic exchange (wildcards: true)

RegExp patterns (Nest 12)

Not applicable: methods are named in the .proto

Delivery in Nest

At most once

QoS 0, 1 or 2

At most once

At most once with noAck: true; at least once with manual acks

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

RedisContext

MqttContext

NatsContext

RmqContext

KafkaContext

Metadata and call arguments

Typed contract

No

No

No

No

No

Yes, from the .proto

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; the nats package, StringCodec and JSONCodec are gone, packets are JSON strings and custom deserializers receive the whole message (msg.json()). Older tutorials that import nats or JSONCodec no longer work.

  • GrpcExceptionFilter and the gRPC status exceptions (12.0). Older material shows hand-built { code, message } objects thrown through RpcException, or says every error is UNKNOWN.

  • 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 until close(). 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 without strict in 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 ClientProxy type for Kafka rather than ClientKafkaProxy, and treat nats v2 as the NATS driver.

Pitfalls

  • Calling emit() and never subscribing: the observable is cold, nothing is dispatched.

  • Calling send() without timeout(): 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: false and 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 value without serializing it again.

  • gRPC .proto files missing from dist/ because the Nest CLI assets entry 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 resolve ConfigService because ConfigModule is not global.

  • Treating the tests on this page as broker tests: the RabbitMQ and Kafka fakes do not model redelivery, rebalancing or persistence.