Skip to main content

Migration Guide

From @nestjs/microservices NATS transport

If your ClientProxy already talks to NATS, switching to JetStream is mostly configuration. Here are the five steps.

What changes

AspectBuilt-in NATSnestjs-jetstream
DeliveryAt-most-once (fire-and-forget)At-least-once (persistent)
RetentionNone — messages lost if no subscriberStream-based — messages survive restarts
ReplayNot supportedNew consumers catch up on history
Fan-outAll subscribers get every messageWorkqueue (one handler) or Broadcast (all)
RPCCore request/replyCore (default) or JetStream-backed
DLQNot supportedDedicated DLQ stream with tracking headers, or onDeadLetter callback
Ack/NakNot applicableExplicit acknowledgment per message

Step 1 — Install the library

pnpm add @horizon-republic/nestjs-jetstream

The @nats-io/* client packages come in as direct dependencies — you don't have to install them yourself. Only your NestJS peer dependencies matter: make sure your project meets the runtime requirements (Node.js, NestJS, TypeScript versions).

Step 2 — Replace module registration

Before (built-in):

// main.ts
app.connectMicroservice({
transport: Transport.NATS,
options: { servers: ['nats://localhost:4222'] },
});

After (nestjs-jetstream):

// app.module.ts
@Module({
imports: [
JetstreamModule.forRoot({
name: 'my-service',
servers: ['nats://localhost:4222'],
}),
],
})
export class AppModule {}

Then connect the transport in your bootstrap file, just like in the Quick Start:

const app = await NestFactory.create(AppModule);
app.connectMicroservice({ strategy: app.get(JetstreamStrategy) }, { inheritAppConfig: true });
await app.startAllMicroservices();

Step 3 — Keep your handlers

Your existing @EventPattern() and @MessagePattern() handlers work as-is. The decorators are the same — only the underlying transport changes.

// Works with both transports — no code changes needed
@EventPattern('user.created')
async handleUserCreated(@Payload() data: UserDto) {
await this.userService.process(data);
}

@MessagePattern('user.get')
async getUser(@Payload() id: string) {
return this.userService.findById(id);
}

Step 4 — Replace client injection

Before (built-in):

@Inject('NATS_SERVICE') private readonly client: ClientProxy

After (nestjs-jetstream):

// Register in the module
JetstreamModule.forFeature({ name: 'users' })

// Inject with the service name as the token
@Inject('users') private readonly client: ClientProxy

Step 5 — Adjust for acknowledgment semantics

The key behavioral difference: messages are now acknowledged explicitly. If your handler throws, the message is retried (up to max_deliver times, default 3).

Idempotency matters now. If a handler is called twice with the same message, the second call should produce the same result. Use message deduplication or idempotent operations.

What you gain

After migration, you get for free:

  • Messages survive NATS server restarts
  • Failed messages are automatically retried
  • Dead letter handling for exhausted retries
  • Health checks with RTT monitoring
  • Graceful shutdown with message drain
  • Broadcast fan-out to all service instances
  • Ordered sequential delivery mode

Upgrading between versions

v2.9 → v2.10

New features:

  • Distributed tracing with OpenTelemetry — every publish, consume, and RPC round-trip now produces an OpenTelemetry span. W3C Trace Context propagates through NATS message headers, so a single trace flows end-to-end across services. Activates automatically the moment your application registers an OpenTelemetry SDK (Sentry, Datadog, NodeSDK, etc.); zero runtime cost when no SDK is registered. Configurable through forRoot({ otel: ... }).
  • Header contract — formal documentation of the NATS message headers the transport reads and writes. Use this as the integration spec when publishing to (or consuming from) the transport from other languages (Go, Python, etc.).

Peer dependency (optional):

  • @opentelemetry/api is now declared as an optional peer dependency. Applications that already register an OpenTelemetry SDK (@sentry/node, @datadog/tracer, @opentelemetry/sdk-node, etc.) bring it in transitively — no action needed. Applications that want to use the library's distributed-tracing feature and do not already have an OTel SDK must install @opentelemetry/api at the same major version (^1.9.0). This avoids the silent "no-op tracer" trap where two copies of the API live in node_modules and the global tracer singleton refuses the mismatched version.

Behavior changes (non-breaking API, observable in logs/APM):

  • Reduced internal logging. Several logger.error sites that duplicated existing TransportHooks events have been removed. The hook is now the single observability channel for those events. If you relied on those log lines for monitoring, register the relevant hook (error, rpcTimeout, deadLetter).
  • Error classification on OTel spans. When OpenTelemetry is enabled, handler throws of RpcException and HttpException produce OK spans with jetstream.rpc.reply.has_error and jetstream.rpc.reply.error.code attributes — they are treated as expected business outcomes per the RPC contract. Bare Error throws produce ERROR spans with recordException. This keeps APM error rates clean for known business denials while loud-failing on real bugs.
  • TransportEvent.Error now fires on every handler throw, both event and RPC paths. Previously only some paths emitted it. If you have an error hook registered, it will now receive these (which is what most users want).
  • RpcConfig.timeout in JetStream mode now bounds connect + RPC. Previously the JetStream-mode per-request deadline only started after await connect() resolved, which meant a permanent NATS outage could accumulate pending RPCs indefinitely. The deadline is now armed immediately so callers always see a timeout. Core-mode RPC still relies on nats.js's own nc.request({ timeout }), which starts after connect() resolves; operators running permanent-outage scenarios against Core mode should configure maxReconnectAttempts to stop the retry loop at the protocol layer.

No breaking API changes. Existing applications upgrade by bumping the dependency.

v2.8 → v2.9

Notable change:

Broadcast max_age reduced: 1 day → 1 hour

Broadcast messages (config propagation, cache invalidation, feature flags) are relevant for minutes, not days. The new default provides a sufficient catch-up window while reducing storage. This is a mutable property — existing streams update automatically on next application startup. If you need a longer retention window, override it explicitly:

broadcast: { stream: { max_age: toNanos(1, 'days') } }

New features:

  • Built-in Dead Letter Queue stream — add the dlq: { stream } option to forRoot() and the library provisions a dedicated DLQ stream on startup. Exhausted messages are automatically republished with tracking headers (x-dead-letter-reason, x-original-subject, x-original-stream, x-failed-at, x-delivery-count). The onDeadLetter callback remains available as a standalone option or as a notification/safety-net hook when combined with the DLQ stream.
  • Per-message TTLJetstreamRecordBuilder.ttl(duration) sets the Nats-TTL header (NATS 2.11+), allowing individual messages to expire independently of the stream's max_age. Requires allow_msg_ttl: true on the target stream.
  • Handler metadata registry (NATS KV) — when enabled via the metadata option, the library publishes all registered @EventPattern / @MessagePattern handlers to a NATS KV bucket at startup, enabling cross-service discovery without a separate service registry.
  • Stream migration — automatic blue-green stream recreation for immutable property changes (storage, retention, etc.). Enable with allowDestructiveMigration: true on a per-stream basis.
  • Consumer self-healing auto-recreation — consumers deleted externally (via NATS CLI, cluster issues) are automatically recreated on the next poll. Migration-aware: waits during active stream migrations.
  • StreamConfigOverrides type — prevents users from overriding retention (transport-controlled).
  • NatsErrorCode enum for NATS JetStream API error codes, so error handling code can switch on typed constants instead of magic strings.

No breaking changes.

v2.7 → v2.8

Breaking change: migrated from nats package to @nats-io/* scoped packages (v3.x).

This is an internal change — the library re-exports everything users need. If you import types directly from nats in your own code, update them:

- import { JsMsg, NatsConnection } from 'nats';
+ import { JsMsg } from '@nats-io/jetstream';
+ import { NatsConnection } from '@nats-io/transport-node';

New features:

  • Message scheduling — one-shot delayed delivery via scheduleAt() (requires NATS >= 2.12)
  • allow_msg_schedules stream config option

v2.6 → v2.7

v2.7 shipped handler-controlled settlement — a way to ack, nak, or terminate a message without throwing.

New features:

  • Handler-controlled settlement via ctx.retry() and ctx.terminate() — control message acknowledgment without throwing errors
  • Metadata getters on RpcContext: getDeliveryCount(), getStream(), getSequence(), getTimestamp(), getCallerName()

No breaking changes.

v2.5 → v2.6

v2.6 was the performance release — concurrency control, ack extension, and production-ready defaults for reconnection and compression.

New features:

  • Configurable concurrency for event/broadcast/RPC processing
  • Ack extension (ackExtension: true) for long-running handlers
  • Consume options passthrough for advanced prefetch tuning
  • Heartbeat monitoring with automatic consumer restart
  • S2 stream compression enabled by default (see Stream Defaults)
  • Performance connection defaults (unlimited reconnect, 1s interval)

No breaking changes.

v2.4 → v2.5

Breaking change: nanos() renamed to toNanos().

- import { nanos } from '@horizon-republic/nestjs-jetstream';
+ import { toNanos } from '@horizon-republic/nestjs-jetstream';

consumer: {
- ack_wait: nanos(30, 'seconds'),
+ ack_wait: toNanos(30, 'seconds'),
}

v2.3 → v2.4

New features:

  • Ordered events (ordered: prefix, DeliverPolicy options)
  • Custom message IDs via setMessageId() for publish-side deduplication
  • Documentation site (Docusaurus)

No breaking changes.

v2.1 → v2.2

New features:

  • Dead letter queue support via onDeadLetter callback
  • DeadLetterInfo interface with full message context

No breaking changes.

See also