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.
// 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.
// 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.
// 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.
// 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.
// 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:
- Do you need durability? If a service crashes, must in-flight messages survive? Yes → Kafka. No → TCP, Redis, NATS.
- Do you need replay? Must new consumers read the entire history? Yes → Kafka. No → TCP, Redis, NATS.
- 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.
// 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:
@MessagePatternis synchronous (caller waits);@EventPatternis 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.
@MessagePatternand@EventPatternwork 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.






Comments
Loading comments…