How the NestJS microservices abstraction works
NestJS treats inter-service communication as a pluggable layer. You write handlers and clients against one API from @nestjs/microservices, and a transport option set when you create the microservice or register a client decides how bytes actually move: a raw TCP socket, Redis pub/sub, a NATS server or a Kafka cluster. The appeal is real. Business logic stays decoupled from infrastructure, local development can run on something lightweight, and teams share one mental model across services.
The API has two interaction styles. ClientProxy.send() is request-response. It returns an Observable that publishes the message and then waits for a reply correlated to that request. The Observable is cold, so nothing is sent until something subscribes, for example via firstValueFrom(). On the receiving side, a method decorated with @MessagePattern handles it, and its return value becomes the reply. ClientProxy.emit() is event-based. It publishes without expecting a response, and a method decorated with @EventPattern consumes it with nothing sent back.
Here is the article's core argument. The shared API hides very different delivery semantics. Each pattern is identical in code, but each transport decides what happens to it:
- whether one instance or every instance receives an event
- whether a message survives a consumer being offline
- whether a message can be replayed later
Changing one line of transport configuration can silently turn load-balanced work into fan-out, or durable delivery into fire-and-forget. Your handlers compile and your tests pass either way. That is why the rest of this comparison focuses on semantics rather than syntax.
import { Controller, Inject, Injectable } from '@nestjs/common';
import { ClientProxy, EventPattern, MessagePattern, Payload, Transport } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
// Receiving side: these handlers stay the same whichever transport runs them.
@Controller()
export class OrdersController {
// Request-response: the return value is sent back as the reply.
@MessagePattern('orders.get')
getOrder(@Payload() data: { id: string }) {
return { id: data.id, status: 'shipped' };
}
// Event: no reply. Whether one instance or every instance runs this,
// and whether it survives downtime, is decided by the transport.
@EventPattern('order.created')
onOrderCreated(@Payload() data: { id: string }) {
console.log(`indexing order ${data.id}`);
}
}
// Passed to ClientsModule.register([...]). The transport line decides delivery
// semantics. The options shape is transport-specific.
export const ordersClient = {
name: 'ORDERS',
transport: Transport.NATS, // swap for TCP, REDIS or KAFKA: code compiles, behaviour changes
options: { servers: ['nats://localhost:4222'] },
};
@Injectable()
export class CheckoutService {
constructor(@Inject('ORDERS') private readonly client: ClientProxy) {}
async checkout(id: string) {
// send() is cold: nothing is published until firstValueFrom subscribes and awaits the reply.
const order = await firstValueFrom(this.client.send('orders.get', { id }));
// emit(): publish and move on. No reply is expected.
this.client.emit('order.created', { id });
return order;
}
}
One controller and client use send()/@MessagePattern for request-response and emit()/@EventPattern for events, while a single transport setting decides how those messages are actually delivered.
TCP and Redis: brokerless RPC and pub/sub fan-out
TCP: direct connections, no middleman
The TCP transport opens a socket from the client proxy straight to a host and port where the microservice listens. There is no broker between them, so nothing stores a message while the receiver is down. If the target process is restarting, the call fails and your code decides whether to retry. There is no built-in load balancing or service discovery. The client knows exactly one address, so spreading load across replicas requires putting something in front of them, such as a load balancer or a service mesh.
This works well for request-response between a small number of known services that have stable addresses and rarely restart. It works poorly in environments where pods churn constantly, because every restart becomes a window of failed calls. The deeper cost is coupling. Each caller's configuration encodes where its dependencies live, so moving or splitting a service means changing every client.
Redis: pub/sub fan-out
The Redis transport is built on Redis pub/sub channels, not on Redis Streams. Pub/sub delivers each message to every subscriber that is currently connected and keeps nothing afterward. That has two consequences.
First, if you scale a consumer to three replicas, all three receive every event and all three do the work. Second, delivery is fire-and-forget. A subscriber that is offline during a deploy never sees the messages published while it was gone.
Use Redis when fan-out is the intent, such as cache invalidation or broadcast notifications. Avoid it for work queues, where you need each job handled exactly once by one worker and you cannot afford to lose jobs.
// orders.module.ts (caller side)
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
@Module({
imports: [
ClientsModule.register([
{
// TCP: the caller hardcodes one host:port. There is no broker, discovery
// or load balancing. If inventory restarts, calls fail until it is back.
name: 'INVENTORY_SERVICE',
transport: Transport.TCP,
options: { host: 'inventory.internal', port: 4001 },
},
{
// Redis: publishes to pub/sub channels, not Redis Streams. Nothing is
// stored, so subscribers that are offline miss the message.
name: 'EVENTS_BUS',
transport: Transport.REDIS,
options: { host: 'redis.internal', port: 6379 },
},
]),
],
})
export class OrdersModule {}
// cache.controller.ts (subscriber side, started with Transport.REDIS)
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
@Controller()
export class CacheController {
// Every running replica receives this event. That suits cache invalidation,
// but in a job queue each replica would process the same job again.
@EventPattern('product.updated')
invalidate(@Payload() data: { productId: string }) {
console.log(`evicting cache for ${data.productId}`);
}
}
A NestJS client registration in which the TCP target is pinned to a single address and the Redis transport fans each event out to every subscribed replica, which fits cache invalidation but not work queues.
NATS and Kafka: queue groups versus a durable event log
NATS covers that work-queue case. Core NATS routes messages by subject, with wildcards such as orders.*, and supports request-reply natively: the client publishes with a reply subject and the responder answers on it. When several replicas subscribe with the same queue group, NATS delivers each message to only one member, so you get load balancing without a broker-side queue. Core NATS is at-most-once. If no subscriber is connected, the message is dropped. Durability, acknowledgements and redelivery come from JetStream, which the built-in NestJS transport does not use, so you need a custom transport strategy or a community library.
Kafka is a durable, partitioned log. Producers append records to topic partitions. Each consumer group tracks its own offsets, so you can replay from any point. The clientId identifies a connection. The groupId decides which consumers share work. Within a group, each partition is assigned to exactly one consumer, which has two consequences:
- Throughput: parallelism is capped at the partition count.
- Ordering: order holds only within a partition, so the message key decides which events stay ordered.
Redis pub/sub NATS queue group Kafka consumer group
pub ─┬─▶ A pub ──▶ one of P0 ──▶ consumer A
├─▶ B {A, B, C} P1 ──▶ consumer A
└─▶ C P2 ──▶ consumer B
(all receive) (one receives) (one owner per partition)
For request-response, NestJS publishes to a topic and listens on a matching .reply topic. You register it with subscribeToResponseOf(). Every reply therefore travels through a partitioned, persisted log and depends on partition assignment and rebalances, which adds latency and failure modes that RPC doesn't need. Use Kafka as your event backbone and keep synchronous calls on another transport.
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ClientKafka, MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
// NATS worker: every replica starts with the same queue group,
// so each message is delivered to only one of them (at-most-once).
async function bootstrapNatsWorker() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
transport: Transport.NATS,
options: {
servers: ['nats://nats.internal:4222'],
queue: 'orders-workers', // shared by all replicas => load balancing
},
});
await app.listen();
}
// Kafka client, registered in a module via ClientsModule.register with
// options: { client: { clientId: 'orders-api', brokers: ['kafka.internal:9092'] },
// consumer: { groupId: 'orders-api' } }
@Injectable()
export class OrdersClient implements OnModuleInit {
constructor(@Inject('ORDERS_SERVICE') private readonly kafka: ClientKafka) {}
async onModuleInit() {
// Subscribes to 'orders.get.reply' so responses can be matched.
// Must be called before connect().
this.kafka.subscribeToResponseOf('orders.get');
await this.kafka.connect();
}
getOrder(id: string) {
// Request goes to 'orders.get'; the reply comes back through the
// partitioned 'orders.get.reply' topic, subject to assignment and rebalances.
return this.kafka.send('orders.get', { key: id, value: { id } });
}
}
void bootstrapNatsWorker();
A NATS microservice that joins a queue group so replicas share work, alongside a Kafka client that has to subscribe to the .reply topic before it can use request-response.
Comparison matrix: guarantees, topology and operational burden
The table below sets the four transports side by side. These are conceptual properties of each transport as NestJS uses it, not measured throughput or latency figures.
| Property | TCP | Redis | NATS (core) | Kafka |
|---|---|---|---|---|
| Delivery guarantee | At-most-once | At-most-once | At-most-once | At-least-once (offset commits) |
| Persistence and replay | None | None (pub/sub, not streams) | None | Retained log, replay by offset |
| Ordering | Per connection | Per channel, single publisher | Per subject, single publisher | Per partition |
| Load-balancing model | None; client targets one host | Fan-out to every subscriber | Queue groups | Consumer groups over partitions |
| Request-reply | Native | Via reply channels | Native | Via reply topics; heavier |
| Infrastructure to operate | None | Redis instance | NATS server or cluster | Broker cluster, topics, partition planning |
| Typical use | Internal RPC, few services | Simple fan-out, cache invalidation | Low-latency RPC and events | Event backbone, audit, stream processing |
The delivery guarantee depends on where a message waits. TCP, Redis pub/sub and core NATS deliver only to whoever is connected at that moment, so a restarting consumer misses messages. Kafka writes to disk before delivery, which makes replay possible and also makes duplicates possible. Handlers therefore have to be idempotent.
Redis fan-out matters if you scale horizontally. Every replica receives every message unless you design around this, whereas NATS queue groups and Kafka consumer groups deliver each message to one member.
NestJS also ships RabbitMQ, MQTT and gRPC transports. NATS JetStream adds persistence to NATS, but you integrate it through a custom transport strategy rather than a built-in option. Option names and default behaviour differ between NestJS releases, so check the documentation for the version you run.
Implementing hybrid apps and handlers that survive the semantics
A single process can expose HTTP and several transports at once. Create the app with NestFactory.create(), attach each transport with app.connectMicroservice(), then call app.startAllMicroservices() before app.listen(). A typical split puts NATS request-reply behind @MessagePattern handlers for internal queries and Kafka behind @EventPattern handlers for domain events. The two can share the same providers, so a query handler and an event consumer can call the same service class.
Design handlers for the transport's guarantees
Make Kafka handlers idempotent. Kafka consumers are effectively at-least-once. A crash or rebalance after processing, but before the offset commit, redelivers the message. To absorb duplicates:
- Carry a stable idempotency key in the payload or headers.
- Record processed keys under a unique constraint.
- Write that record in the same database transaction as the side effect.
A duplicate then fails the insert and becomes a no-op.
Bound every send() call. ClientProxy.send() returns a cold Observable. If the responder is gone, the caller waits on the transport's own behaviour. Pipe it through RxJS timeout() and map the timeout to an error the caller can handle.
Throw RpcException deliberately. On the responder, throw RpcException with a structured payload so the caller receives a meaningful error instead of a generic one. Translate that error at the HTTP edge.
Understand what request-response costs you
Request-response reintroduces temporal coupling: the caller is only as available as everything it calls synchronously. Latency adds up across hops, and without timeouts and fallbacks, one slow dependency exhausts callers upstream.
Add delivery guarantees where the transport has none
Core NATS and Redis pub/sub are at-most-once. A message published while no subscriber is listening is simply dropped. If loss is unacceptable, you have two options:
- Build acknowledgments and retries yourself, for example with an outbox table and a relay process.
- Move that flow to a persistent mechanism.
// main.ts: one process serving HTTP, NATS (queries) and Kafka (events)
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// NATS request-reply for synchronous internal queries (@MessagePattern).
// A queue group spreads requests across replicas instead of fanning out.
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.NATS,
options: { servers: ['nats://nats.internal:4222'], queue: 'orders-queries' },
});
// Kafka for domain events (@EventPattern). Delivery is at-least-once,
// so handlers behind this transport must tolerate redelivery.
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.KAFKA,
options: {
client: { clientId: 'orders-service', brokers: ['kafka.internal:9092'] },
consumer: { groupId: 'orders-consumer' },
},
});
// Start transport listeners first, then accept HTTP traffic.
await app.startAllMicroservices();
await app.listen(3000);
}
bootstrap();
// inventory.client.ts: never wait on the transport's own failure behaviour
// check(sku: string) {
// return firstValueFrom(
// this.client.send<{ available: number }>('inventory.check', { sku }).pipe(
// timeout(2000), // fail fast instead of tying up the caller
// catchError((err) => throwError(() => err instanceof TimeoutError
// ? new GatewayTimeoutException('inventory did not respond') : err)),
// ));
// }
A hybrid NestJS bootstrap that attaches NATS for request-reply queries and Kafka for domain events before starting HTTP, plus a send() call bounded with RxJS timeout() and mapped to an HTTP-friendly error.
Serialization, contracts and versioning across services
By default, every transport except Kafka passes your payload through JSON.stringify on the way out and JSON.parse on the way in. Kafka uses its own default serializer, but the payload still arrives as plain data. This has consequences that are easy to miss in a monolith. A Date arrives as an ISO string, a BigInt throws during serialization, and class instances lose their prototype and methods. Treat every handler argument as untrusted plain data. Validate it and convert it back into the right types, for example with a ValidationPipe and DTOs, before you use it.
When JSON isn't enough, you can supply serializer and deserializer objects in the transport options of both the client and the server. Typical reasons are a binary format such as Protobuf or MessagePack, an envelope that carries tracing headers, or interop with non-Nest services. Both sides must agree on the format. A deserializer change that ships to only half the fleet is effectively an outage.
Independent deploys mean that during a rolling release, old and new producers and consumers run side by side. On Kafka, new code may also replay messages written months ago. A few conventions keep both directions working:
- Version pattern names, such as
orders.created.v1. A breaking change becomes a new pattern. Producers dual-publish to both patterns until every consumer has moved over. - Make payload changes additive only. Add optional fields. Never rename, retype or remove fields within a version.
- Make consumers tolerant. They should ignore unknown fields and apply defaults for missing ones.
- Deploy consumers before producers for new patterns. A message with no matching handler fails a request-response call, and an event with no handler is simply dropped.
Decision guide and impact on cost and engineering time
Work through four questions in order and stop at the first one that rules transports out.
- Do you need persistence or replay? If a consumer must catch up after downtime or rebuild state from history, only Kafka qualifies among the four.
- Should consumers compete, or should every instance receive each message? NATS queue groups and Kafka consumer groups split work. Redis pub/sub fans out to every subscriber.
- Is the interaction synchronous request-reply? TCP and NATS handle this naturally. Kafka can do it, but it adds reply topics and latency.
- What does the team already run well? A transport you can monitor, upgrade and debug at 3 a.m. is better than a theoretically better one you cannot.
| Transport | Use when | Avoid when |
|---|---|---|
| TCP | Few services make internal RPC calls and you want no broker | You need fan-out, buffering or discovery |
| Redis | Redis already runs, and lossy fan-out notifications are acceptable | A missed event corrupts state |
| NATS | You want low-latency RPC plus load-balanced workers | You need replay with the built-in core transport |
| Kafka | Events are a durable record that many teams consume | Traffic is mostly request-reply or the team is small |
Kafka's cost is ongoing work, not just servers. Someone has to size partitions, tune retention, and watch consumer-group rebalances that pause processing during deploys. NATS, Redis and TCP are each a single, mostly stateless component.
The uniform @MessagePattern/@EventPattern API lets handlers move between transports without changes, which saves real time. That convenience is also the trap. Changing Redis to NATS in configuration turns fan-out into competing consumption. Changing anything to Kafka adds redelivery that your non-idempotent handlers never expected. Decide the delivery semantics first, then the transport. That way the swap stays a configuration change and does not turn into a rewrite.
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 transports, 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
.envby 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.