NestJS microservices: reliable reconnection, retries and failure handling
How each NestJS transport behaves when a connection drops, and the timeouts, safe retries, idempotent handlers, dead letters and health checks that keep services reliable.
Updated 14 min read

In this article
A NestJS microservice that works on a developer laptop can fail in production in three quiet ways: a request waits forever because the other side never answers, a broker restart leaves a client disconnected for good, or a retry charges a customer twice. This guide shows what each built-in transport actually does when a connection drops, then builds the four defences that matter: a timeout on every call, retries only for transient errors with capped backoff, idempotent handlers, and dead-letter handling for messages that keep failing, plus health checks so your orchestrator knows when a dependency is gone. The TCP scenario was run end to end; the RabbitMQ and health check code was compiled and the health check was run against unreachable services.
Tested with: Node.js 24.19.0, NestJS 12.0.1 (@nestjs/microservices), @nestjs/terminus 12.0.0, RxJS 7.8.2, amqplib 2.0.1 with amqp-connection-manager 5.0.0, TypeScript 5.9.3.
Transports and what they do when the connection drops
NestJS ships seven transports, listed in the Transport enum: TCP, Redis, NATS, MQTT, gRPC, RabbitMQ and Kafka, plus custom strategies (microservices overview). Reconnection is handled differently by each, mostly by the underlying client library, and the defaults are not the same. We read the 12.0.1 transport source and the libraries' documentation to fill in this table:
| Transport | Client library | When the connection drops | Settings to know |
|---|---|---|---|
| TCP | Node.js net | Pending requests fail with Connection closed; the next send() reconnects. Nothing is retried for you. | Server retryAttempts/retryDelay only retry listen() on EADDRINUSE |
| Redis | ioredis | Reconnects only if retryAttempts is set; otherwise it logs "retry attempts not specified" and stays down | retryAttempts, retryDelay (5,000 ms default) |
| RabbitMQ | amqplib + amqp-connection-manager | The manager reconnects; while it does, client sends fail fast with "Connection lost. Trying to reconnect..." | socketOptions.reconnectTimeInSeconds, heartbeatIntervalInSeconds, consumer maxConnectionAttempts (-1, infinite) |
| NATS | @nats-io/transport-node | The client reconnects on its own, by default 10 attempts 2 s apart, then gives up | maxReconnectAttempts (-1 for no limit), reconnectTimeWait, waitOnFirstConnect |
| Kafka | kafkajs | Retries connections and calls with exponential backoff: 5 retries from 300 ms, capped at 30 s | client.retry |
| MQTT | mqtt | Reconnects every reconnectPeriod (1,000 ms) | reconnectPeriod, connectTimeout |
| gRPC | @grpc/grpc-js | The channel reconnects with backoff; calls made meanwhile fail with UNAVAILABLE | keepalive, channelOptions |
Two conclusions follow. First, reconnecting is not the same as delivering: when a connection drops mid-request, every transport fails or loses the in-flight request rather than replaying it. Second, the NATS and Redis defaults give up, so a broker outage longer than about 20 seconds (NATS) or any outage at all (Redis without retryAttempts) leaves the client broken until the process restarts. Set those options explicitly, and subscribe to client.status so you see it happen.
The test setup
To see the failure modes rather than guess, we built a small payments service on the TCP transport and a script that starts, kills and restarts it while a client calls it. The handler stores results by idempotency key, so a repeated command returns the first result instead of running twice:
import 'reflect-metadata';
import { Controller, Logger, Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { MessagePattern, Payload, RpcException, Transport, type MicroserviceOptions } from '@nestjs/microservices';
import { setTimeout as sleep } from 'node:timers/promises';
interface ChargeCommand { idempotencyKey: string; orderId: string; amountCents: number; delayMs?: number }
interface ChargeResult { chargeId: string; orderId: string; amountCents: number }
/** In production this is a table with a unique key or Redis SET NX with a TTL, not process memory. */
const processed = new Map<string, Promise<ChargeResult>>();
let executions = 0;
@Controller()
class PaymentsController {
private readonly logger = new Logger('Payments');
@MessagePattern('payments.charge')
charge(@Payload() cmd: ChargeCommand): Promise<ChargeResult> {
if (!cmd?.idempotencyKey || !Number.isInteger(cmd.amountCents) || cmd.amountCents <= 0) {
throw new RpcException({ code: 'INVALID_CHARGE', message: 'idempotencyKey and a positive amountCents are required' });
}
const existing = processed.get(cmd.idempotencyKey);
if (existing) {
this.logger.log(`duplicate ${cmd.idempotencyKey}: returning the stored result`);
return existing;
}
const work = (async () => {
executions++;
if (cmd.delayMs) await sleep(cmd.delayMs);
return { chargeId: `ch_${executions}`, orderId: cmd.orderId, amountCents: cmd.amountCents };
})();
processed.set(cmd.idempotencyKey, work);
work.catch(() => processed.delete(cmd.idempotencyKey)); // a failed attempt may be retried
return work;
}
@MessagePattern('payments.executions')
count() { return executions; }
}
@Module({ controllers: [PaymentsController] })
class PaymentsModule {}
const app = await NestFactory.createMicroservice<MicroserviceOptions>(PaymentsModule, {
transport: Transport.TCP,
options: { host: '127.0.0.1', port: Number(process.env.PORT ?? 4721) },
logger: ['log', 'error', 'warn'],
});
app.enableShutdownHooks();
await app.listen();
console.log('READY');The in-memory Map keeps the example short and shows the failure clearly; in production use a table with a unique constraint on the key, or Redis SET key value NX EX 86400, so the record survives restarts and is shared by every instance.
Defence 1 and 2: a timeout on every call, and retries only for transient errors
ClientProxy.send() returns an Observable with no timeout. If the handler hangs, so does your HTTP request, holding a socket and memory until the client gives up. RxJS provides both pieces: timeout() and retry(). Because retry() resubscribes, the timeout applies to each attempt, not to the whole call. The delay callback decides whether an error is worth retrying; anything that is not a timeout or a connection error is thrown straight to the caller.
import type { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom, retry, timeout, timer, TimeoutError } from 'rxjs';
/** Errors that mean "the message may not have arrived": safe to retry an idempotent command. */
export function isTransientError(err: unknown): boolean {
if (err instanceof TimeoutError) return true;
const e = err as { code?: string; message?: string };
return ['ECONNREFUSED', 'ECONNRESET', 'EPIPE'].includes(e?.code ?? '') || e?.message === 'Connection closed';
}
export function send<T>(client: Pick<ClientProxy, 'send'>, pattern: string, data: unknown, opts = { timeoutMs: 2000, retries: 6, baseDelayMs: 200, maxDelayMs: 2000 }) {
return firstValueFrom(
client.send<T>(pattern, data).pipe(
timeout({ first: opts.timeoutMs }),
retry({
count: opts.retries,
delay: (err, attempt) => {
if (!isTransientError(err)) throw err; // business errors (RpcException) are not retried
const backoff = Math.min(opts.baseDelayMs * 2 ** (attempt - 1), opts.maxDelayMs);
const jitter = Math.random() * backoff * 0.2;
console.log(` retry ${attempt} in ${Math.round(backoff + jitter)} ms after: ${(err as Error).message ?? (err as { code?: string }).code}`);
return timer(backoff + jitter);
},
}),
),
);
}Register the clients once and call through the helper. Note the Redis client: without retryAttempts it would never reconnect.
import { Inject, Injectable, Module, type OnApplicationBootstrap } from '@nestjs/common';
import { ClientsModule, Transport, type ClientProxy } from '@nestjs/microservices';
import { send } from './call.js';
@Module({
imports: [
ClientsModule.register([
{ name: 'PAYMENTS', transport: Transport.TCP, options: { host: process.env.PAYMENTS_HOST ?? '127.0.0.1', port: 4721 } },
// Without retryAttempts the Redis transport does not reconnect after the connection drops.
{ name: 'EVENTS', transport: Transport.REDIS, options: { host: process.env.REDIS_HOST ?? '127.0.0.1', port: 6379, retryAttempts: 20, retryDelay: 1000 } },
]),
],
exports: [ClientsModule],
})
export class ClientsConfigModule {}
@Injectable()
export class CheckoutService implements OnApplicationBootstrap {
constructor(@Inject('PAYMENTS') private readonly payments: ClientProxy) {}
async onApplicationBootstrap() {
this.payments.status.subscribe((s) => console.log(`payments client: ${s}`));
}
charge(orderId: string, amountCents: number) {
// One key per business operation, reused on every retry of that operation.
const idempotencyKey = `charge:${orderId}`;
return send<{ chargeId: string }>(this.payments, 'payments.charge', { idempotencyKey, orderId, amountCents });
}
}
What happened in the test run

- Duplicate command: sending the same idempotency key twice returned the same
ch_1result; the handler ran once. - Business error: an invalid amount produced an
RpcExceptionwith a code and was not retried. - Server killed mid-request: the client status went to
disconnectedand the in-flight call failed withConnection closed. Whether that charge happened is unknown to the caller, which is exactly why the retry must carry the same key. - Server down, then back: four retries saw
ECONNREFUSED, the server came back, and the fifth attempt succeeded after 3.7 seconds. - No timeout: a call to a handler that sleeps for a minute was still waiting when we stopped watching.
Two real lessons came out of it. Our first version allowed four retries with uncapped doubling (200, 400, 800, 1,600 ms). On a loaded machine the restarted service took up to 5.4 seconds to print its ready line, the retry budget ran out at about 3 seconds, and the call failed even though the service came back. Size the retry budget to how long a restart really takes in your environment, and cap each delay so a long outage does not produce one huge wait. Second, look at step 5 in the output: after the restart the service returned ch_1 for order 1002, because its in-memory key store and counter started from zero. Had the first attempt completed before the crash, a fresh store would have charged again. A durable key store is not optional. Our earlier guide to timeouts and retries for fetch in Node.js applies the same rules to HTTP calls.
Defence 3: idempotent handlers
Every transport here gives at-most-once or at-least-once delivery, never exactly-once. A handler is safe to retry when running it twice has the same effect as running it once. In practice:
- The caller generates the key from the business operation (
charge:order-1001), not per attempt, and reuses it on every retry. - The handler records the key and the result in the same transaction as the side effect, or uses the downstream provider's idempotency feature (payment providers have one) with the same key.
- Concurrent duplicates are handled: storing the in-flight promise, as above, or a unique constraint that makes the second insert fail and read the first result.
- Keys expire after the longest time a retry could arrive, typically a day.
Getting idempotency right across services, retries and payment providers is the part teams most often under-estimate. If you are designing a service boundary like this, our API development and integration team can review the message flow before it reaches production.
Defence 4: dead letters for messages that keep failing
Fire-and-forget events (emit() and @EventPattern) need a different safety net, because there is no caller waiting to retry. With RabbitMQ, turn on manual acknowledgement (noAck: false, see the RabbitMQ transport documentation), let failures dead-letter to a delayed retry queue, and park messages that fail permanently or too often. Declare the topology once, so the queue arguments are explicit:
import amqp from 'amqplib';
/** Run once per environment (or keep in your broker definitions file). */
export async function declareInvoiceTopology(url: string) {
const conn = await amqp.connect(url);
const ch = await conn.createChannel();
await ch.assertExchange('invoices.dlx', 'direct', { durable: true });
// Work queue: rejected messages go to the dead-letter exchange.
await ch.assertQueue('invoices', {
durable: true,
deadLetterExchange: 'invoices.dlx',
deadLetterRoutingKey: 'invoices.retry',
});
// Retry queue: no consumers; after 30 s a message dead-letters back to the work queue.
await ch.assertQueue('invoices.retry', {
durable: true,
messageTtl: 30_000,
deadLetterExchange: '', // the default exchange routes by queue name
deadLetterRoutingKey: 'invoices',
});
await ch.bindQueue('invoices.retry', 'invoices.dlx', 'invoices.retry');
// Parking queue: messages a human has to look at.
await ch.assertQueue('invoices.parked', { durable: true });
await ch.close();
await conn.close();
}
The worker acknowledges on success, rejects without requeue on a transient failure (the message waits 30 seconds in invoices.retry, then returns), and copies the message to invoices.parked on a permanent error or the fifth failure, counted from RabbitMQ's x-death header:
import 'reflect-metadata';
import { Controller, Logger, Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Ctx, EventPattern, Payload, RmqContext, Transport, type MicroserviceOptions } from '@nestjs/microservices';
interface InvoiceRequested { idempotencyKey: string; invoiceId: string }
class PermanentError extends Error {}
const MAX_DELIVERIES = 5;
@Controller()
class InvoicesController {
private readonly logger = new Logger('Invoices');
@EventPattern<string>('invoice.requested')
async handle(@Payload() event: InvoiceRequested, @Ctx() ctx: RmqContext) {
const channel = ctx.getChannelRef();
const msg = ctx.getMessage();
try {
await this.process(event); // idempotent: redelivery must be harmless
channel.ack(msg);
} catch (err) {
// RabbitMQ counts dead-lettering per queue and reason in the x-death header.
const xDeath = (msg.properties.headers?.['x-death'] ?? []) as Array<{ queue: string; reason: string; count: number }>;
const deaths = xDeath.find((d) => d.queue === 'invoices' && d.reason === 'rejected')?.count ?? 0;
if (err instanceof PermanentError || deaths + 1 >= MAX_DELIVERIES) {
this.logger.error(`parking ${event?.invoiceId}: ${(err as Error).message}`);
channel.sendToQueue('invoices.parked', msg.content, {
persistent: true,
headers: { ...msg.properties.headers, 'x-error': (err as Error).message },
});
channel.ack(msg); // remove it from the work queue
} else {
channel.nack(msg, false, false); // requeue=false: dead-letter to invoices.retry
}
}
}
private async process(event: InvoiceRequested) {
if (!event?.invoiceId) throw new PermanentError('missing invoiceId');
// ... look up event.idempotencyKey in a durable store, do the work, record the key ...
}
}
@Module({ controllers: [InvoicesController] })
class WorkerModule {}
const app = await NestFactory.createMicroservice<MicroserviceOptions>(WorkerModule, {
transport: Transport.RMQ,
options: {
urls: [process.env.AMQP_URL ?? 'amqp://localhost:5672'],
queue: 'invoices',
noAck: false, // manual acknowledgement
prefetchCount: 10, // at most 10 unacknowledged messages per consumer
queueOptions: { // must match the declared queue exactly
durable: true,
deadLetterExchange: 'invoices.dlx',
deadLetterRoutingKey: 'invoices.retry',
},
socketOptions: { heartbeatIntervalInSeconds: 30, reconnectTimeInSeconds: 5 },
},
});
app.enableShutdownHooks();
await app.listen();A typing quirk we hit: with a plain string, @EventPattern('invoice.requested') selects the typed-event overload, and adding an @Ctx() ctx: RmqContext parameter fails to compile with "Unable to resolve signature of method decorator". Writing @EventPattern<string>('invoice.requested') selects the untyped overload and compiles. The queueOptions must match the declared queue exactly; RabbitMQ refuses to redeclare a queue with different arguments (PRECONDITION_FAILED). Why not channel.nack(msg, false, true)? Requeueing puts the message straight back at the head of the queue, so a poison message is redelivered in a tight loop and blocks others behind it. Kafka has no dead-letter feature in the broker; the equivalent is publishing to a separate retry or dead-letter topic from the consumer.
Health checks for dependencies
@nestjs/terminus can ping a microservice transport. Put these checks on a readiness endpoint, not liveness: an unreachable broker should take an instance out of the load balancer, not get it restarted in a loop. In terminus 12 the per-check timeout option is deprecated in favour of chaining .withTimeout():
import { Controller, Get, Module } from '@nestjs/common';
import { Transport, type RmqOptions, type TcpClientOptions } from '@nestjs/microservices';
import { HealthCheck, HealthCheckService, MicroserviceHealthIndicator, TerminusModule } from '@nestjs/terminus';
@Controller('health')
class HealthController {
constructor(private readonly health: HealthCheckService, private readonly ms: MicroserviceHealthIndicator) {}
/** Readiness: can this instance reach the services it depends on? */
@Get('ready')
@HealthCheck()
ready() {
return this.health.check([
() => this.ms.pingCheck<TcpClientOptions>('payments', {
transport: Transport.TCP,
options: { host: process.env.PAYMENTS_HOST ?? '127.0.0.1', port: 4721 },
}).withTimeout(1500),
() => this.ms.pingCheck<RmqOptions>('rabbitmq', {
transport: Transport.RMQ,
options: { urls: [process.env.AMQP_URL ?? 'amqp://localhost:5672'] },
}).withTimeout(1500),
]);
}
}
@Module({ imports: [TerminusModule], controllers: [HealthController] })
export class HealthModule {}Run with nothing listening, the endpoint returned 503 with "payments": {"message": "connect ECONNREFUSED 127.0.0.1:4721", "status": "down"} and "rabbitmq": {"message": "rabbitmq is not available", "status": "down"}, in about 140 ms. Keep these checks cheap and cached if your orchestrator polls every few seconds.
Production checklist
- Every
send()has a timeout shorter than the HTTP request that triggered it. - Retries only on transient errors, with capped exponential backoff and jitter, and a total budget longer than a typical restart.
- Idempotency keys stored durably, one per business operation.
- Redis
retryAttemptsand NATSmaxReconnectAttemptsset deliberately;client.statuslogged and alerted on. - Manual acks, a retry queue and a parking queue for RabbitMQ consumers;
prefetchCountbounded. - Dead-letter and parking queue depth on a dashboard, with an alert when it grows.
app.enableShutdownHooks()so in-flight work finishes or is rejected cleanly on deploys.
Common mistakes
- Retrying every error, including validation errors, which multiplies load during an incident.
- Generating a new idempotency key per attempt, which defeats it.
- Using
emit()where the caller needs to know the result; events have no reply and no built-in retry. - Requeueing failed RabbitMQ messages immediately, creating a hot loop.
- Pointing liveness probes at broker health, so a broker outage restarts every instance.
- Typing the helper parameter as
ClientProxyand passing aClientTCP: TypeScript rejects it because the status and event types differ.Pick<ClientProxy, 'send'>accepts any client.
When not to use NestJS microservices transports
If two services are deployed together and owned by one team, a plain HTTP call with a timeout, or simply a module boundary in one application, is easier to operate. For long-running background jobs that need scheduling, priorities and visible retries, a job queue such as BullMQ fits better than request-response messaging. And if you need durable, replayable event streams, design around Kafka or NATS JetStream semantics directly rather than the request-response abstraction.
Building services that have to survive broker restarts and retries without double-charging anyone? Cloudsoft Zone designs and builds NestJS APIs and service integrations, including timeouts, idempotency and dead-letter handling from the first release.
Frequently asked questions
Does a NestJS microservice client reconnect automatically?
It depends on the transport. TCP reconnects on the next
send(); RabbitMQ, NATS, Kafka and MQTT clients reconnect through their libraries; the Redis transport only reconnects ifretryAttemptsis set. None of them replays a request that was in flight when the connection dropped.Is there a default timeout for ClientProxy.send()?
No. Add RxJS
timeout()to every call. Combined withretry(), the timeout applies to each attempt.Should I retry RpcException errors?
Usually not. They represent errors the handler chose to return, such as validation or business rule failures, and retrying will fail the same way. Retry timeouts and connection errors, and only for idempotent operations.
How do I build a dead-letter queue with the NestJS RabbitMQ transport?
Use
noAck: false, setdeadLetterExchangeanddeadLetterRoutingKeyinqueueOptions, and reject failures withchannel.nack(msg, false, false). Route the dead-lettered messages to a retry queue with a TTL, and move permanent failures to a parking queue.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Microservices: overview — NestJS documentation
- Microservices: RabbitMQ — NestJS documentation
- Health checks (Terminus) — NestJS documentation
- Release v12.0.0 — NestJS (GitHub), 2026-08-27
- Dead Letter Exchanges — RabbitMQ
- nats.js core: connection options — NATS (GitHub)
- KafkaJS configuration: default retry — KafkaJS
- RxJS retry operator — RxJS
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

ComparisonNode.js
Express 5 vs Fastify 5: which Node.js framework to pick in 2026
Express 5 and Fastify 5 compared on support status, validation, error handling, upgrades and performance, with the same API built and tested in both.
By Cloudsoft Zone

TutorialNode.js
How to test a Node.js API with the built-in test runner (node:test)
Write, run, mock and measure coverage for a small Node.js HTTP API using only node:test and node:assert. Covers port-0 servers, fetch(), spies, t.mock.method(), fake dates and coverage thresholds.
By Cloudsoft Zone

TutorialNode.js
Node.js streams in practice: pipeline(), backpressure and async generators
Process files of any size in Node.js with flat memory use. A hands-on guide to stream/promises pipeline(), backpressure, async generator transforms, AbortSignal timeouts and streaming downloads over HTTP.
By Cloudsoft Zone
