Skip to content
ExponaBLOG
NESTJS · DAY 042 / 100

NestJS microservices: transports — a step-by-step guide

Same programming model over TCP, Redis, NATS, Kafka and more.

Expona Admin8 min read

One of the biggest barriers to building distributed systems is feeling locked into a single communication protocol. NestJS microservices: transports removes that friction by letting you write the same service logic once and swap the underlying transport—TCP, Redis, NATS, Kafka, gRPC—without rewriting your handlers. After five years shipping production platforms across e-commerce and SaaS, I've learned that this flexibility is the real superpower of NestJS's microservices architecture.

Transport options: TCP, Redis, NATS, Kafka, gRPC

NestJS supports multiple transports out of the box. Choosing the right one depends on your delivery guarantees, latency requirements, and operational complexity. Let me break down the most common choices and when I reach for each one.

Transport Best for Delivery guarantee Complexity
TCP Simple internal RPC, request-reply patterns At most once Low
Redis Lightweight pub/sub, cache-friendly workloads At most once Low
NATS Fast pub/sub, fire-and-forget messaging At most once (at least once with JetStream) Low-Medium
Kafka Durable event streaming, audit logs, replay scenarios At least once Medium-High
gRPC Typed, fast contracts, performance-critical paths At most once Medium

TCP: Simple internal RPC

TCP is your first stop for synchronous request-reply between services. It's lightweight, requires no external infrastructure, and NestJS handles connection pooling for you. I use it when microservices live on the same network and you need low latency without event replay semantics.

typescript
// auth.service.ts - TCP client
import { Inject } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';

@Injectable()
export class AuthService {
  constructor(
    @Inject('AUTH_SERVICE') private authClient: ClientProxy
  ) {}

  async validateToken(token: string) {
    return this.authClient
      .send('validate_token', { token })
      .pipe(timeout(2000))
      .toPromise();
  }
}

// main.ts - bootstrap TCP transport
const app = await NestFactory.create(AppModule);
app.connectMicroservice({
  transport: Transport.TCP,
  options: { host: '0.0.0.0', port: 3001 }
});
await app.startAllMicroservices();
await app.listen(3000);

Redis & NATS: Lightweight pub/sub

Redis and NATS excel at pub/sub patterns where you broadcast events to multiple subscribers without strict ordering or durability. I've used Redis extensively in e-commerce order systems where eventual consistency is acceptable—think inventory notifications or user presence broadcasts.

typescript
// order.controller.ts - emit event
@Controller('orders')
export class OrderController {
  constructor(
    @Inject('ORDER_SERVICE') private orderClient: ClientProxy
  ) {}

  @Post()
  async createOrder(@Body() dto: CreateOrderDto) {
    // Async fire-and-forget
    this.orderClient.emit('order.created', { orderId: 123, userId: 456 });
    return { id: 123 };
  }
}

// inventory.service.ts - subscribe
@MessagePattern('order.created')
handleOrderCreated(data: { orderId: number; userId: number }) {
  console.log('Order placed, updating inventory...');
}

// main.ts - bootstrap Redis
app.connectMicroservice({
  transport: Transport.REDIS,
  options: {
    host: 'localhost',
    port: 6379
  }
});

Kafka: Durable event streaming

Kafka is where you go when durability and replay matter. Once a message lands in a Kafka topic, it persists. If your consumer crashes, it resumes from the last offset. I've used Kafka for audit logs in trading platforms and ride-hailing systems where every transaction must be recoverable.

typescript
// payment.service.ts - Kafka producer
@Injectable()
export class PaymentService {
  constructor(
    @Inject('PAYMENT_SERVICE') private paymentClient: ClientProxy
  ) {}

  async processPayment(amount: number, userId: string) {
    // Message durably stored in Kafka topic
    return this.paymentClient
      .emit('payment.processed', { amount, userId, timestamp: Date.now() })
      .toPromise();
  }
}

// accounting.service.ts - Kafka consumer
@Injectable()
export class AccountingService {
  @EventPattern('payment.processed')
  handlePaymentProcessed(data: { amount: number; userId: string; timestamp: number }) {
    // This runs exactly once per message, even if consumer restarts
    console.log(`Recording payment of ${data.amount} for user ${data.userId}`);
  }
}

// main.ts - bootstrap Kafka
app.connectMicroservice({
  transport: Transport.KAFKA,
  options: {
    client: { brokers: ['localhost:9092'] },
    consumer: {
      groupId: 'accounting-service',
      sessionTimeout: 30000
    }
  }
});

gRPC: Typed, fast contracts

gRPC enforces strict protobuf schemas and uses HTTP/2, making it ideal for performance-critical inter-service calls. The upfront schema cost pays off in large teams where contract drift causes bugs. I reach for gRPC when I'm building a platform with 10+ coordinated services.

typescript
// user.proto
syntax = "proto3";

service UserService {
  rpc GetUser (GetUserRequest) returns (User);
}

message GetUserRequest {
  string id = 1;
}

message User {
  string id = 1;
  string email = 2;
}

// main.ts - bootstrap gRPC
app.connectMicroservice({
  transport: Transport.GRPC,
  options: {
    package: 'user',
    protoPath: 'src/protos/user.proto',
    url: 'localhost:5000'
  }
});

One programming model across all transports

Here's the magic: whether you're using TCP, Redis, Kafka, or gRPC, your handler code stays identical. NestJS decorators @MessagePattern (request-reply) and @EventPattern (fire-and-forget) work the same way regardless of transport.

typescript
// Same handler works with any transport
import { Controller, MessagePattern, EventPattern, Payload } from '@nestjs/common';

@Controller()
export class OrderService {
  // Request-reply: caller waits for response
  @MessagePattern('order.validate')
  validateOrder(@Payload() dto: ValidateOrderDto) {
    return { valid: true, errors: [] };
  }

  // Event: caller doesn't wait
  @EventPattern('order.shipped')
  handleOrderShipped(@Payload() data: { orderId: string; trackingNumber: string }) {
    console.log(`Order ${data.orderId} shipped with tracking ${data.trackingNumber}`);
  }
}

This means you can prototype with TCP in development, test with Redis locally, and deploy with Kafka in production without touching your service code. You only change the transport configuration in main.ts. This has saved me countless hours during platform migrations.

Pick by delivery needs: durability, latency, and replay

The choice of transport boils down to three questions:

  1. Do you need durability? If a service crashes, must in-flight messages survive? Yes → Kafka. No → TCP, Redis, NATS.
  2. Do you need replay? Must new consumers read the entire history? Yes → Kafka. No → TCP, Redis, NATS.
  3. Do you need synchronous response? Must the caller block and wait? Yes → TCP or gRPC. No → Redis, NATS, Kafka (async).

In a real SaaS platform I built, we used different transports for different concerns: TCP for the auth microservice (request-reply, low latency), Redis for user notifications (fan-out, no durability needed), and Kafka for billing events (durable, audit trail required). Each choice was intentional and driven by the semantics of the domain.

Start with a monolith: extract later

Here's the trick I always recommend: write your handlers as if they were microservices from day one, even in a monolith. Use @MessagePattern and @EventPattern in-process first with NestJS's built-in in-memory transport. When you're ready to split services, you swap the transport and redeploy. No handler code changes.

typescript

// Day 1: monolith with in-memory messaging
// app.module.ts
@Module({
  imports: [
    ClientsModule.register([
      {
        name: 'ORDER_SERVICE',
        transport: Transport.TCP, // Start here
        options: { host: 'localhost', port: 3001 }
      }
    ])
  ]
})
export class AppModule {}

// 6 months later: still the same code, different config
// app.module.ts (production)
@Module({
  imports: [
    ClientsModule.register([
      {
        name: 'ORDER_SERVICE',
        transport: Transport.KAFKA, // Swap transport
        options: {
          client: { brokers: ['kafka-broker:9092'] },
          consumer: { groupId: 'api-service' }
        }
      }
    ])
  ]
})
export class AppModule {}

This approach lets you defer the complexity of distributed systems until you actually need it. I've seen teams rush into Kafka on day one and spend weeks debugging partition strategies; I've also seen teams hit scalability walls because they didn't think about durability early. The NestJS model lets you stay in the middle ground: simple at first, ready to scale.

Common mistakes

  • Choosing Kafka too early: Kafka adds operational overhead (brokers, monitoring, offset management). Start with Redis or NATS unless you have a proven need for durability.
  • Confusing request-reply and events: @MessagePattern is synchronous (caller waits); @EventPattern is asynchronous (caller fires and forgets). Using the wrong one causes timeouts or missed side effects.
  • Not setting timeouts: TCP and gRPC calls can hang indefinitely. Always wrap .send() with .pipe(timeout(ms)).
  • Ignoring transport-specific gotchas: Redis doesn't guarantee message ordering across channels. Kafka requires thoughtful consumer group strategy. gRPC needs proper health checks. Read the docs for your chosen transport.
  • Tight coupling to transport in business logic: Your domain logic shouldn't know it's talking to Kafka. Keep transport concerns in the NestJS module layer, handlers should be transport-agnostic.

Key takeaways

  • NestJS microservices support multiple transports (TCP, Redis, NATS, Kafka, gRPC) with a single programming model.
  • TCP and gRPC are best for synchronous RPC; Redis and NATS for lightweight pub/sub; Kafka for durable event streaming.
  • @MessagePattern and @EventPattern work identically across all transports—swap the transport config, not the code.
  • Start with TCP or Redis in a monolith, then extract to Kafka or gRPC once you understand your actual scalability needs.
  • Always consider delivery guarantees: at-most-once (TCP, Redis, NATS) vs. at-least-once (Kafka) when picking a transport.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles