How the NestJS microservice transport abstraction works

A transport in NestJS is the layer that moves serialized messages between a caller and a handler. The @nestjs/microservices package splits it into two halves that you configure separately but must keep compatible.

The caller side: ClientProxy

On the calling service you register a client, typically through ClientsModule, by picking a value from the Transport enum and supplying connection options. For TCP these are a host and port. For NATS they are server URLs. For RabbitMQ they are broker URLs and a queue name. What you get back is a ClientProxy, and its interface is the same whichever transport sits underneath. Application code calls the proxy's methods and never touches a socket, a subject or a channel directly.

The consumer side: server options

The receiving service starts a microservice server, either standalone or attached to an HTTP app, with a matching transport and options. Handlers are bound with @MessagePattern or @EventPattern, and the framework routes each incoming message to the handler whose pattern matches. Because the routing and serialization contract is shared, a handler for a given pattern looks the same under TCP, NATS or RabbitMQ.

Why the uniformity is misleading

The abstraction hides how each transport behaves at runtime:

  • Discovery. TCP connects to a fixed address, so the caller must know where the server is. NATS and RabbitMQ route through a broker, so callers only need to reach the broker.
  • Delivery. With TCP, a message exists only while the connection is open. A broker can hold, distribute or drop messages according to its own semantics.
  • Failure. When a consumer crashes, TCP callers see connection errors. Brokered transports may queue the message, redeliver it or lose it, depending on configuration.

Switching transports is mostly a configuration change, so ease of setup tells you little. The choice that matters is which delivery and failure guarantees you are getting, and the rest of this article compares the three on those terms.

import { Controller } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
  EventPattern, MessagePattern, MicroserviceOptions, Payload, Transport,
} from '@nestjs/microservices';
import { AppModule } from './app.module'; // registers OrdersController

// Only the server options change between transports.
// The runtime guarantees behind each entry are very different.
const serverOptions: Record<string, MicroserviceOptions> = {
  // Direct socket: callers must know this host and port.
  tcp: { transport: Transport.TCP, options: { host: '0.0.0.0', port: 3001 } },
  // Brokered: callers only need to reach the NATS server.
  nats: { transport: Transport.NATS, options: { servers: ['nats://nats.internal:4222'] } },
  // Brokered with a named queue that can hold messages while consumers are down.
  rmq: {
    transport: Transport.RMQ,
    options: {
      urls: ['amqp://app:change-me@rabbitmq.internal:5672'],
      queue: 'orders',
      queueOptions: { durable: true },
    },
  },
};

// This controller is identical under all three transports.
@Controller()
export class OrdersController {
  @MessagePattern('orders.get') // request-response: caller waits for a reply
  getOrder(@Payload() data: { id: string }) {
    return { id: data.id, status: 'shipped' };
  }

  @EventPattern('orders.created') // fire-and-forget: no reply is sent
  onCreated(@Payload() data: { id: string }) {
    console.log('order created', data.id);
  }
}

async function bootstrap() {
  const options = serverOptions[process.env.TRANSPORT ?? 'tcp'];
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, options);
  await app.listen();
}
bootstrap();

The same @MessagePattern and @EventPattern handlers run unchanged under TCP, NATS or RabbitMQ, because only the server options object differs. That object is also where discovery, buffering and delivery behaviour are decided.

Message patterns: request-response with send() vs events with emit()

…patterns themselves, because each transport implements them differently.

NestJS gives you two message patterns. A handler decorated with @MessagePattern answers requests sent with client.send(). send() returns a cold Observable: nothing goes over the wire until you subscribe, and the caller then waits for a reply. That means the transport must provide a path back to the caller. A handler decorated with @EventPattern receives messages sent with client.emit(). The message is dispatched without a subscription, and the caller gets no result or confirmation that any handler ran.

Transport send() reply path emit() delivery
TCP Reply written back on the same socket Written to the socket; lost if the connection drops
NATS (core) Request-reply via a temporary inbox subject Plain publish; dropped if no subscriber is listening
RabbitMQ Reply routed through a reply queue Queued until a consumer takes it

The table shows the trap. Running request-response over a broker adds at least one extra network hop in each direction. It also hides coupling: the caller no longer knows the callee's address, but it still blocks on the callee's answer. If service A calls B and B calls C, a slow C stalls the entire chain, whichever transport sits underneath.

The broker does not break that dependency. Give every hop its own timeout, set shorter than the timeout of its caller, so a failure surfaces at the hop where it happened. Without this, the outermost request is the one that times out and you lose that information.

import { Controller, Inject } from '@nestjs/common';
import { ClientProxy, EventPattern, MessagePattern, Payload } from '@nestjs/microservices';
import { firstValueFrom, timeout } from 'rxjs';

// Service B: answers requests from service A and calls service C itself.
// Service A calls 'price.quote' with send(...).pipe(timeout(1500)).
@Controller()
export class PricingController {
  constructor(@Inject('INVENTORY_SERVICE') private readonly inventory: ClientProxy) {}

  // Request-response: the return value goes back to A over the transport's reply path.
  @MessagePattern('price.quote')
  async quote(@Payload() data: { sku: string }) {
    // send() is cold. firstValueFrom subscribes, which sends the request.
    // The B -> C hop gets 800 ms, less than A's 1500 ms, so a slow C
    // raises a TimeoutError here in B instead of at A.
    const stock = await firstValueFrom(
      this.inventory
        .send<{ available: number }>('inventory.check', { sku: data.sku })
        .pipe(timeout(800)),
    );

    // Fire-and-forget: no reply and no confirmation that any handler ran.
    this.inventory.emit('price.quoted', { sku: data.sku });

    return { sku: data.sku, priceCents: 1999, available: stock.available };
  }

  // Event handler: the emitter never sees the return value or any thrown error.
  @EventPattern('order.created')
  async onOrderCreated(@Payload() data: { orderId: string }) {
    // e.g. reserve stock. Make this idempotent if the broker can redeliver.
  }
}

A NestJS service that answers a @MessagePattern request, calls the next service with send() under a shorter per-hop timeout than its caller uses, and emits a fire-and-forget event with emit().

TCP, NATS and RabbitMQ: delivery guarantees and failure behaviour

TCP

TCP is a direct connection to a known host and port. There is no broker, so nothing buffers messages, discovers instances or balances load. If the consumer is down, send() fails immediately and emit() drops the event without any error. That makes failures easy to see for requests, but it gives no protection for events.

NATS

Core NATS routes messages by subject. Queue groups spread messages across subscribers that share a group name. Delivery is at-most-once: if no subscriber is listening, or a slow consumer falls behind, the server discards the message. JetStream adds persistence and acknowledgements, but the built-in transport targets core subjects. JetStream needs separate handling, so you do not get durability just by pointing the transport at a stream.

RabbitMQ

RabbitMQ gives you durable queues and explicit acknowledgement. With noAck enabled, the broker treats a message as done as soon as it delivers it. A crash mid-handler then loses that message. Disable noAck and acknowledge manually through RmqContext: get the channel and the original message, then call ack after the work commits. Set prefetchCount to limit how many unacknowledged messages a consumer holds, which applies back-pressure.

Rejecting with nack and requeue enabled puts the message back on the queue. A message that always fails will then be redelivered in a loop. Route repeated failures to a dead-letter queue instead.

Manual ack gives you at-least-once delivery, not exactly-once. A crash after the work commits but before the ack causes redelivery. Handlers must therefore be idempotent, for example by deduplicating on a message ID.

// main.ts: consumer with manual ack, back-pressure and a dead-letter exchange
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
  transport: Transport.RMQ,
  options: {
    urls: ['amqp://app:change-me@rabbitmq:5672'],
    queue: 'orders',
    noAck: false,       // the broker keeps each message until we ack it
    prefetchCount: 10,  // at most 10 unacked messages per consumer
    queueOptions: {
      durable: true,
      // Rejected messages go here instead of being requeued in a loop.
      // The 'orders.dlx' exchange and its bound queue must already exist.
      arguments: { 'x-dead-letter-exchange': 'orders.dlx' },
    },
  },
});
await app.listen();

// orders.controller.ts
@EventPattern('order.created')
async onOrderCreated(@Payload() order: { id: string }, @Ctx() ctx: RmqContext) {
  const channel = ctx.getChannelRef();
  const msg = ctx.getMessage();
  try {
    // Idempotency: a crash after commit but before ack causes redelivery,
    // so skip work already recorded under this order ID.
    if (!(await this.orders.alreadyProcessed(order.id))) {
      await this.orders.process(order); // records order.id in the same transaction
    }
    channel.ack(msg); // ack only after the work has committed
  } catch (err) {
    // requeue=false: dead-letter the message rather than redeliver it forever
    channel.nack(msg, false, false);
  }
}

A NestJS RabbitMQ consumer with noAck disabled and prefetchCount set. It acks through RmqContext after the work commits, dead-letters failed messages instead of requeuing them, and skips duplicate redeliveries by checking the order ID.

Context objects, timeouts and error propagation

Every transport passes a context object to handlers through @Ctx(). NatsContext exposes the subject and message headers. RmqContext exposes the raw AMQP message and the channel, which is how manual acks work. TcpContext exposes the socket and the pattern. These are useful, but each one is transport-specific. Once a handler reads context.getChannelRef() or NATS headers, it can no longer move to another transport without being rewritten. Keep context access in a thin adapter layer and pass plain values into your domain code.

On the caller side, send() returns an Observable that may never complete. Always pipe it through the RxJS timeout operator and map the resulting TimeoutError to a domain error. What happens when no handler responds depends on the transport. The built-in TCP server replies with a "no matching message handler" error. With a broker, the request can sit unanswered, either as an unconsumed RabbitMQ message or a NATS request with no subscriber. In those cases your timeout is the only thing that ends the wait.

Inside handlers, throw RpcException rather than HTTP exceptions. Use an RPC exception filter to control what gets serialized back to the caller. Strip stack traces and send a stable error code with a message.

Observability is uneven. Trace context is not propagated automatically, so inject it into outgoing message headers yourself (NATS and RabbitMQ record builders accept headers) and extract it in the handler. The reply-matching ID that Nest uses internally is not a business correlation ID. Add your own correlation ID and log it on both sides.

import { Catch, Controller, RpcExceptionFilter } from '@nestjs/common';
import { ClientProxy, Ctx, MessagePattern, NatsContext, NatsRecordBuilder, Payload, RpcException } from '@nestjs/microservices';
import { headers as natsHeaders } from 'nats';
import { Observable, TimeoutError, catchError, throwError, timeout } from 'rxjs';

// Caller: attach our own correlation ID as a header. Nest's internal reply ID is not usable for this.
export function getOrder(client: ClientProxy, orderId: string, correlationId: string) {
  const h = natsHeaders();
  h.set('x-correlation-id', correlationId);
  const record = new NatsRecordBuilder({ orderId }).setHeaders(h).build();
  return client.send('orders.get', record).pipe(
    timeout(3000), // without this, a request with no subscriber can wait indefinitely
    catchError((err) => throwError(() =>
      err instanceof TimeoutError ? { code: 'ORDERS_UNAVAILABLE', message: 'orders.get timed out' } : err)),
  );
}

@Controller()
export class OrdersController {
  // Thin adapter: read NATS-specific context here, pass plain values to domain code.
  @MessagePattern('orders.get')
  get(@Payload() data: { orderId: string }, @Ctx() ctx: NatsContext) {
    const correlationId = ctx.getHeaders()?.get('x-correlation-id') ?? 'none';
    console.log(JSON.stringify({ subject: ctx.getSubject(), correlationId }));
    if (!data.orderId) {
      throw new RpcException({ code: 'ORDER_ID_REQUIRED', message: 'orderId is required' });
    }
    return { orderId: data.orderId, status: 'shipped' };
  }
}

// Controls what is serialized back to the caller: a stable code and message, never a stack trace.
@Catch(RpcException)
export class RpcErrorFilter implements RpcExceptionFilter<RpcException> {
  catch(exception: RpcException): Observable<never> {
    const err = exception.getError();
    return throwError(() => (typeof err === 'object' ? err : { code: 'INTERNAL', message: String(err) }));
  }
}

A NATS caller sends a correlation ID header and bounds send() with the RxJS timeout operator, while the handler reads NatsContext in a thin adapter and an RPC exception filter returns a stable error shape.

Decision matrix: which transport fits which workload

Criterion TCP Core NATS RabbitMQ
Delivery guarantee At-most-once; lost if the peer is down At-most-once; no persistence At-least-once with durable queues and manual acks
Operational overhead None beyond the services One lightweight broker or cluster Broker cluster, queue and exchange design, disk
Horizontal scaling Client targets one host:port; balancing is up to you Queue groups spread load across instances Competing consumers on a shared queue
Latency profile Fewest hops One broker hop, in-memory Broker hop plus persistence and ack round trips
Observability Only your own logs and metrics Server monitoring of connections and subjects Queue depth, unacked counts, consumer counts

TCP fits simple internal request-response between a small, fixed set of services, and local development where you don't want to run a broker. Because the client points at one address, scaling means putting a load balancer or service discovery in front of it. You get no buffering, so a restarting service turns into failed calls.

Core NATS fits low-latency fan-out, such as cache invalidation or config change notifications. It also fits spreading stateless request-response load across replicas with queue groups. It accepts dropped messages in exchange for speed and simplicity. If a subscriber is offline, it misses the message.

RabbitMQ fits durable work queues, such as email sending, report generation or payment follow-ups. In these jobs, losing a message is worse than processing it twice. Acknowledgement only matters if the handlers are idempotent, because redelivery after a crash is expected behaviour. Queue depth also gives you a direct signal for autoscaling consumers.

When to avoid the built-in transports and what the alternatives are

The built-in transports cover the common subset of each broker. Once a design depends on features outside that subset, the abstraction starts working against you.

Where the abstraction runs out

The RabbitMQ transport is built around consuming from a named queue. Topic or headers exchanges, multiple bindings with routing-key wildcards, and per-queue arguments such as dead-letter exchanges and message TTLs are broker-side topology. You end up declaring that topology outside NestJS and hoping it matches what the transport expects. Dead-letter handling suffers the most, because deciding when to reject without requeue and where the message lands is core logic that the pattern-based handler model hides.

The NATS transport uses core NATS, which is at-most-once. JetStream streams, durable consumers, explicit acks and replay from a sequence number or timestamp are not part of the shared abstraction.

The alternatives

You can write a custom transporter by extending the server and client base classes and mapping broker semantics onto @MessagePattern and @EventPattern. This is worth the effort when many services need the same behaviour. Otherwise, inject the native client library as an ordinary provider and use it alongside the microservices layer or instead of it. You lose the decorators, but every broker feature is directly available.

Patterns to avoid

Avoid request-response over a broker for latency-sensitive calls. It adds reply queues, correlation IDs and a timeout that only hides a dead consumer. Plain HTTP or gRPC is more honest about failure.

Avoid TCP in production when consumers scale dynamically or events must survive downtime. TCP needs a fixed address, offers no load distribution, and buffers nothing when the receiver is down.

Migrating between transports and the impact on cost and engineering time

Migration checklist

  • Pattern naming. NATS uses the pattern as a subject, so dots and wildcards have routing meaning. RabbitMQ routes by queue name and carries the pattern inside the message. TCP matches string or serialized object keys. Before switching, pick one dot-separated string convention that works on all three. Object patterns break most often during a move.
  • Serializers and deserializers. Each transport wraps payloads in its own envelope. Port any custom serializer to both producers and consumers in the same release. Otherwise a mixed fleet cannot parse each other's messages.
  • Payload shape. Dates, Buffers and class instances do not survive JSON round-trips. Treat every payload as plain data.
  • Versioned contracts. During a staggered deploy, old and new consumers run side by side. Add fields instead of renaming them, and include an explicit version field.
  • Retries and idempotency. Moving from TCP to a broker introduces redelivery. A handler that was safe when each message arrived at most once now needs a deduplication key.

Where the engineering time goes

Transport Operational cost Hidden cost
TCP None beyond the services themselves You must build buffering, retries and discovery yourself
NATS (core / JetStream) Light server operations Learning JetStream streams, consumers and ack policies
RabbitMQ Clustering, durable queues, memory and disk alarms, upgrades Tuning acknowledgements and prefetch per consumer

Illustrative estimate (assumed numbers, not a benchmark).

Effort = (clean handlers × h_clean) + (context-coupled handlers × h_coupled)

Assume 40 handlers, h_clean = 0.5 h and h_coupled = 3 h:

  • 10 handlers read transport context: 30 × 0.5 + 10 × 3 = 45 hours.
  • No handlers read transport context: 40 × 0.5 = 20 hours.

Two habits keep this cost low. First, choose the transport by the delivery guarantees you need from day one. Second, keep context objects such as acks, subjects and channels in a thin adapter layer, not inside the handlers.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nestjs microservice transport, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.