Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
Node.jsDevOpsJavaScriptTutorial

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.

Cloudsoft Zone

Updated 14 min read

Grafana dashboard of RabbitMQ message rates and queue depths for MusicBrainz and ListenBrainz, including search.failed and search.retry queues over six hours
Photograph: Software: Grafana Labs Data: MetaBrainz Foundation and contributors Screenshot: VulcanSphere / CC0, via Wikimedia Commons
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:

TransportClient libraryWhen the connection dropsSettings to know
TCPNode.js netPending requests fail with Connection closed; the next send() reconnects. Nothing is retried for you.Server retryAttempts/retryDelay only retry listen() on EADDRINUSE
RedisioredisReconnects only if retryAttempts is set; otherwise it logs "retry attempts not specified" and stays downretryAttempts, retryDelay (5,000 ms default)
RabbitMQamqplib + amqp-connection-managerThe 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-nodeThe client reconnects on its own, by default 10 attempts 2 s apart, then gives upmaxReconnectAttempts (-1 for no limit), reconnectTimeWait, waitOnFirstConnect
KafkakafkajsRetries connections and calls with exponential backoff: 5 retries from 300 ms, capped at 30 sclient.retry
MQTTmqttReconnects every reconnectPeriod (1,000 ms)reconnectPeriod, connectTimeout
gRPC@grpc/grpc-jsThe channel reconnects with backoff; calls made meanwhile fail with UNAVAILABLEkeepalive, 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:

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

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

TypeScript
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 });
  }
}
Flow diagram of a NestJS client.send call with a per-attempt timeout, a transient-error check that retries with capped backoff, and an idempotency check in the handler
Timeouts bound each attempt, only transient errors are retried, and the handler makes retries harmless.

What happened in the test run

Terminal output of the scratch scenario: idempotent duplicate, rejected business error, Connection closed on a killed server, retries with ECONNREFUSED and a hung call
Our TCP scenario on NestJS 12.0.1, with the server killed and restarted by the script.
  • Duplicate command: sending the same idempotency key twice returned the same ch_1 result; the handler ran once.
  • Business error: an invalid amount produced an RpcException with a code and was not retried.
  • Server killed mid-request: the client status went to disconnected and the in-flight call failed with Connection 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:

TypeScript
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();
}
Diagram of a RabbitMQ topology for a NestJS worker: work queue, dead-letter exchange, a retry queue with 30 second TTL that returns messages, and a parking queue
Failed messages wait in a retry queue before returning; permanent failures go to a parking queue for people to inspect.

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:

TypeScript
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():

TypeScript
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 retryAttempts and NATS maxReconnectAttempts set deliberately; client.status logged and alerted on.
  • Manual acks, a retry queue and a parking queue for RabbitMQ consumers; prefetchCount bounded.
  • 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 ClientProxy and passing a ClientTCP: 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 if retryAttempts is 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 with retry(), 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, set deadLetterExchange and deadLetterRoutingKey in queueOptions, and reject failures with channel.nack(msg, false, false). Route the dead-lettered messages to a retry queue with a TTL, and move permanent failures to a parking queue.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Microservices: overview — NestJS documentation
  2. Microservices: RabbitMQ — NestJS documentation
  3. Health checks (Terminus) — NestJS documentation
  4. Release v12.0.0 — NestJS (GitHub), 2026-08-27
  5. Dead Letter Exchanges — RabbitMQ
  6. nats.js core: connection options — NATS (GitHub)
  7. KafkaJS configuration: default retry — KafkaJS
  8. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.