Skip to main content

RPC (Request/Reply)

Use whenone service needs a response from another within a timeout window (queries, lookups, commands with results). You pick: Core mode for sub-millisecond replies, JetStream mode for commands that must survive a restart. Same @MessagePattern either way.

The client sends a request and waits for the reply inside a timeout. The application code is the same in both modes; only rpc.mode in forRoot() changes.

Sending and handling​

src/gateway/gateway.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';

@Injectable()
export class GatewayService {
constructor(@Inject('orders') private readonly ordersClient: ClientProxy) {}

getOrder(id: string): Promise<Order> {
return firstValueFrom(this.ordersClient.send<Order>('get.order', { id }));
}
}
src/orders/orders.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';

@Controller()
export class OrdersController {
@MessagePattern('get.order')
handleGetOrder(@Payload() data: { id: string }): Promise<Order> {
return this.ordersService.findById(data.id);
}
}

A handler can return a plain value, a Promise, or an Observable. For an Observable the first emitted value becomes the reply (see Observable return values).

Core mode (default)​

Core mode uses NATS request/reply directly, with nothing persisted. It applies when rpc is unset or rpc.mode is 'core'.

  1. The client calls nc.request(subject, payload, { timeout }).
  2. NATS routes the request to one server instance through a queue group, which load-balances across replicas.
  3. The handler runs and the server answers with msg.respond(result).
  4. The reply arrives on a temporary inbox managed by NATS.

If no server instance is subscribed, the request fails at once with a NATS no responders error instead of waiting out the timeout.

JetStream mode​

JetStream mode stores the command in a stream before delivery, so it survives a server restart. The reply still travels over Core NATS.

JetstreamModule.forRoot({
name: 'orders',
servers: ['nats://localhost:4222'],
rpc: { mode: 'jetstream' },
});
  1. The client generates a correlation ID and registers a pending callback.
  2. It publishes the command with js.publish(), carrying the correlation ID and its inbox subject in the x-correlation-id and x-reply-to headers.
  3. The server's command consumer delivers the message to the handler.
  4. On success the server acks the message, then publishes the result to the client's inbox with nc.publish().
  5. The client matches the correlation ID and resolves the callback.

A command is never redelivered: a failing handler terminates the message (term) and the error goes back to the caller. Retrying a command behind the caller's back could, for example, charge a card twice.

If the server is down, the command waits in the stream and runs when a server starts. When the client's timeout expires first, the client gets a timeout error, yet the handler may still run later and publish a reply nobody listens to. Make such handlers idempotent.

Core or JetStream​

AspectCore modeJetStream mode
LatencyLowest, direct request/replySlightly higher: stream write plus inbox routing
PersistenceNoneCommand stored in a stream before delivery
Server offlineImmediate no responders errorCommand waits in the stream until a server starts
Retry on failureNoneNone (max_deliver: 1), error returned to the caller
Default timeout30 seconds3 minutes
Load balancingNATS queue groupJetStream consumer, one delivery
Use caseLow-latency queries, real-time lookupsCommands that must not be lost (payments, state changes)

Start with Core mode. Switch to JetStream mode when a command must survive a server restart without the client seeing an error.

Errors​

The handler's error reaches the caller as the rejection of send(), flagged by the x-error header. How the error is serialized depends on what you throw:

// RpcException: the full payload from getError()
throw new RpcException({ code: 'NOT_FOUND', message: 'Order not found' });
// caller receives { code: 'NOT_FOUND', message: 'Order not found' }

// Error: only the message
throw new Error('Something went wrong');
// caller receives { message: 'Something went wrong' }

// Plain object: passed through as-is
throw { code: 'VALIDATION_ERROR', fields: ['email'] };
// caller receives { code: 'VALIDATION_ERROR', fields: ['email'] }
warning

Prefer RpcException. A generic Error loses everything except message, including status codes and validation details.

Failures that never reach the handler behave differently per mode:

FailureCore modeJetStream mode
No handler for the subjectError reply sent at onceterm, the client times out
Payload cannot be decodedError reply, handler not calledterm, the client times out
x-reply-to or x-correlation-id header missingn/aterm, the client times out
Handler exceeds rpc.timeoutClient times out, handler keeps runningterm, no reply published

In JetStream mode, if the handler succeeds but the reply cannot be published, the message stays acked and the client times out.

NestJS exception filters work as usual. A filter that returns an Observable, for example through throwError(), is unwrapped by the transport:

import { Catch, RpcExceptionFilter } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { Observable, throwError } from 'rxjs';

@Catch(RpcException)
export class CustomRpcFilter implements RpcExceptionFilter<RpcException> {
catch(exception: RpcException): Observable<never> {
return throwError(() => exception);
}
}

Timeouts​

rpc.timeout sets both sides of the call:

  • Client: how long send() waits before rejecting.
  • Server, JetStream mode only: how long the handler has before the message is terminated.

It defaults to 30 seconds in Core mode and 3 minutes in JetStream mode.

JetstreamModule.forRoot({
name: 'orders',
servers: ['nats://localhost:4222'],
rpc: { mode: 'jetstream', timeout: 60_000 },
});

In JetStream mode the handler result and the timeout race, and whichever comes first settles the message. The other is ignored, so the client never receives two replies.

For a single call, setTimeout() on the record builder overrides the client-side wait:

const record = new JetstreamRecordBuilder({ id: orderId }).setTimeout(5_000).build();
const order = await firstValueFrom(this.ordersClient.send<Order>('get.order', record));
note

The per-request value only changes how long the client waits. In JetStream mode the server still enforces the module-level rpc.timeout.

Connection lost​

In JetStream mode, a dropped connection rejects every pending call at once with Connection lost instead of letting each one wait for its timeout. The client clears the timers, tears down its inbox subscription, and recreates the inbox on the next connect(). In Core mode the NATS client rejects pending nc.request() calls itself.

See also​