Microservice transports: TCP, Redis, NATS, Kafka
A Nest microservice listens on a swappable transporter, not HTTP. send/@MessagePattern is request–response; emit/@EventPattern is fire-and-forget. Pick the transport on delivery: TCP/Redis lose messages, RabbitMQ/Kafka redeliver — forcing idempotent consumers.
The “order placed” flow worked in the demo: the orders service called emit('order.placed', order) over Redis, the billing service charged the card, the email service sent the receipt. Then a billing pod was rolling during a deploy when fourteen orders came in. Redis pub/sub dropped them on the floor — no subscriber was listening, so there was nothing to deliver — and fourteen customers were charged zero and shipped product. The fix was not a code bug; it was the transport. Redis pub/sub is at-most-once with no persistence, and “react to this later” needs a broker that holds the message until a consumer is back. That choice — TCP vs Redis vs RabbitMQ vs Kafka, send vs emit — is the whole lesson.
A microservice is an app on a transporter, not HTTP
A Nest microservice is the same AppModule you already write, bootstrapped to listen on a transporter instead of an HTTP server. You call createMicroservice with a transport and options; the transporter is swappable, and your controllers’ handlers stay byte-for-byte the same when you move from TCP to Kafka.
import { NestFactory } from '@nestjs/core';
import { Transport, MicroserviceOptions } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
transport: Transport.TCP, // swap to REDIS / NATS / RMQ / KAFKA, handlers unchanged
options: { host: '0.0.0.0', port: 3001 },
});
await app.listen();
}
bootstrap();Handlers come in two flavours, and the distinction is the most important idea in the lesson. @MessagePattern(pattern) is request–response: the handler’s return value (or Observable) is serialized back to the caller, and the transporter correlates the reply to the request with a correlation id. @EventPattern(event) is fire-and-forget: it reacts to something that happened and returns nothing — there is no reply channel at all.
import { Controller } from '@nestjs/common';
import { MessagePattern, EventPattern, Payload } from '@nestjs/microservices';
@Controller()
export class OrdersController {
// request–response: caller is waiting for this return value
@MessagePattern({ cmd: 'order.total' })
total(@Payload() ids: number[]): number {
return ids.reduce((sum, id) => sum + priceOf(id), 0);
}
// fire-and-forget: "this happened"; nobody is waiting for a reply
@EventPattern('order.placed')
async onOrderPlaced(@Payload() order: OrderDto): Promise<void> {
await this.billing.charge(order); // no return value is sent anywhere
}
}On the caller side, ClientProxy mirrors the split: send() for @MessagePattern, emit() for @EventPattern. send() returns a cold Observable — nothing is sent until you subscribe, and the stream emits the reply — so it is for “I need an answer”. emit() publishes the event and does not wait for one — it is for “this happened, react if you care”.
import { Injectable, Inject } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
@Injectable()
export class OrdersService {
constructor(@Inject('ORDERS') private readonly client: ClientProxy) {}
// send: returns an Observable of the reply; await it for the value
total(ids: number[]) {
return firstValueFrom(this.client.send<number>({ cmd: 'order.total' }, ids));
}
// emit: publish and move on; no reply is expected
placed(order: OrderDto) {
this.client.emit('order.placed', order); // fire-and-forget
}
}▸Why this works
Why is send() a cold Observable? Because the request is not actually dispatched until something subscribes — so calling this.client.send(...) and never subscribing sends nothing at all. That trips people coming from promise-based HTTP clients: the call looks like it fired, but with no subscribe() (or firstValueFrom/await lastValueFrom) the message never leaves. emit() is different — Nest subscribes internally so the event publishes — but it still gives you back no reply.
Choosing a transporter: it is a choice about delivery
Every transporter speaks the same handler API, so the decision is never about syntax — it is about delivery semantics: does the broker persist the message, can a recovered consumer replay it, is ordering guaranteed, how much throughput does it sustain. TCP and Redis pub/sub are simple and fast but lossy; RabbitMQ and Kafka are durable but make you pay for it with idempotency. When you see a “lost events” incident report, this table is where the postmortem starts.
| Transporter | Model | Durable / replay? | Delivery | Pick it when |
|---|---|---|---|---|
| TCP | Point-to-point | No | At-most-once | Simple internal request–response, no broker to run |
| Redis | Pub/sub | No | At-most-once | Cheap fan-out where dropping a message is acceptable |
| NATS | Pub/sub (subjects) | Core no; JetStream yes | At-most-once (core) | Lightweight, low-latency messaging; add JetStream for persistence |
| RabbitMQ | Queues + routing | Yes (acks, requeue) | At-least-once | Work queues, per-message ack, complex routing |
| Kafka | Partitioned log | Yes (offset replay) | At-least-once | High-throughput event streams, replay, ordering per partition |
Two facts from that table carry the senior weight. First, at-least-once (RabbitMQ, Kafka) means redelivery, which means your consumer can run twice on the same message — so it must be idempotent or you double-charge a card. Second, Kafka orders messages only within a partition, not across the topic: events for one orderId stay ordered only if they hash to the same partition (key by orderId), and a consumer group spreads partitions across instances for parallelism while preserving per-partition order. Kafka request–response also exists but is heavier — the client must subscribeToResponseOf(topic) so replies land on a dedicated reply topic, matched back by correlation id.
// Kafka microservice: a broker list and a consumer group
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
transport: Transport.KAFKA,
options: {
client: { brokers: ['localhost:9092'] },
consumer: { groupId: 'billing-consumer' }, // group => partitions split across pods
},
});Hybrid apps: HTTP and a transporter at once
You rarely throw away HTTP. A hybrid application is a normal HTTP app that also listens as a microservice: you connectMicroservice one or more transporters, then startAllMicroservices() before listen(). The same process serves REST and reacts to broker events.
const app = await NestFactory.create(AppModule);
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.KAFKA,
options: { client: { brokers: ['localhost:9092'] }, consumer: { groupId: 'orders' } },
});
await app.startAllMicroservices(); // begin consuming events
await app.listen(3000); // and keep serving HTTPThe 'order placed' event fans out to billing and email. The hard requirement: if a consumer pod is down during a deploy, the events must survive and be replayable when it returns — and per-order events must stay ordered. Which transporter?
A producer calls client.emit('order.placed', order) over Redis pub/sub while the only consumer pod is mid-restart. What happens to that event?
You move the 'order placed' flow to Kafka. Billing now occasionally charges a card twice. What is the root cause and the right fix?
- 01Explain the difference between @MessagePattern + send() and @EventPattern + emit(), including what the caller receives and when you'd use each.
- 02You must choose a transporter for an event that has to survive a consumer outage and be replayable, with per-order ordering. Walk through TCP, Redis, RabbitMQ, and Kafka and justify the pick.
A Nest microservice is your ordinary AppModule bootstrapped with createMicroservice onto a swappable transporter instead of an HTTP server; the transporter changes but the handlers do not. The core semantic split is request–response versus fire-and-forget: @MessagePattern with ClientProxy.send() returns a cold Observable of a correlated reply (use it when you need an answer), while @EventPattern with ClientProxy.emit() publishes an event and expects nothing back (use it to announce that something happened and let any number of consumers react). The transporter choice is a choice about delivery semantics: TCP is simple point-to-point and Redis pub/sub is cheap fan-out, but both are at-most-once with no persistence, so a message published to no current listener is lost; NATS core is similarly at-most-once with JetStream adding persistence; RabbitMQ gives durable queues, per-message acks, and routing at at-least-once; and Kafka is a partitioned append-only log with consumer groups, ordering per partition, and offset replay for high throughput. The senior consequences follow directly: at-least-once (RabbitMQ, Kafka) means redelivery, so consumers must be idempotent or they double-charge; Kafka’s ordering holds only within a partition, so key by orderId; and a hybrid app — connectMicroservice plus startAllMicroservices before listen — lets one process serve HTTP and react to broker events at once. Now when you see a “messages lost during deploy” incident, you’ll know exactly which column of that transporter table the team missed — and which one to reach for next.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.