open atlas
↑ Back to track
NestJS, zero to senior NEST · 08 · 01

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.

NEST Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

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.

TransporterModelDurable / replay?DeliveryPick it when
TCPPoint-to-pointNoAt-most-onceSimple internal request–response, no broker to run
RedisPub/subNoAt-most-onceCheap fan-out where dropping a message is acceptable
NATSPub/sub (subjects)Core no; JetStream yesAt-most-once (core)Lightweight, low-latency messaging; add JetStream for persistence
RabbitMQQueues + routingYes (acks, requeue)At-least-onceWork queues, per-message ack, complex routing
KafkaPartitioned logYes (offset replay)At-least-onceHigh-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 HTTP
Pick the best fit

The '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?

Quiz

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?

Quiz

You move the 'order placed' flow to Kafka. Billing now occasionally charges a card twice. What is the root cause and the right fix?

Recall before you leave
  1. 01
    Explain the difference between @MessagePattern + send() and @EventPattern + emit(), including what the caller receives and when you'd use each.
  2. 02
    You 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.
Recap

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.

recallapplystretch0 of 5 done

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.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.