Skip to content
NestJS ns microservices 4 min read

Message vs Event Patterns

NestJS microservices communicate through two distinct messaging styles, and choosing the right one is one of the most consequential design decisions you’ll make in a distributed system. A message follows the request-response model: the caller sends data and waits for a reply. An event is fire-and-forget: the producer announces that something happened and never waits for an answer. Picking the wrong style leads to tight coupling, accidental blocking, or lost work, so it’s worth understanding exactly how each one behaves on the wire and in code.

The two patterns at a glance

Both patterns are activated by decorators on a controller method in the microservice, and both are paired with a method on the ClientProxy in the caller. The decorator declares what the service listens for; the client method declares how the caller talks to it.

Aspect@MessagePattern@EventPattern
Client methodclient.send(pattern, data)client.emit(pattern, data)
CommunicationRequest-responseFire-and-forget
ReturnsAn Observable of the handler’s resultAn Observable<void> that completes on dispatch
Handlers per patternExactly one logical responderZero or more subscribers
Caller waits?Yes — for the replyNo — only for the transport to accept the message
Typical useQuerying data, RPC-style callsDomain events, audit logs, notifications

The send() method returns a cold Observable. Nothing is actually transmitted until you subscribe (or await firstValueFrom(...)). This trips up people who call send() and wonder why no request ever fires.

Request-response with @MessagePattern

Use @MessagePattern when the caller needs a result back. Under the hood NestJS attaches a correlation id to the outbound message, the microservice processes it, and the reply is routed back to the exact subscriber that asked. The whole round trip is modeled as an Observable.

Here is a microservice that answers “math” requests:

import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';

@Controller()
export class MathController {
  @MessagePattern({ cmd: 'sum' })
  accumulate(@Payload() data: number[]): number {
    return data.reduce((acc, n) => acc + n, 0);
  }
}

The caller uses send() and consumes the returned Observable. In an async service method, firstValueFrom is the cleanest way to turn the reply into a Promise:

import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';

@Injectable()
export class CalculatorService {
  constructor(@Inject('MATH_SERVICE') private readonly client: ClientProxy) {}

  async sum(numbers: number[]): Promise<number> {
    const result$ = this.client.send<number, number[]>({ cmd: 'sum' }, numbers);
    return firstValueFrom(result$);
  }
}

Output:

sum([1, 2, 3, 4, 5]) => 15

The handler’s return value is serialized and sent back automatically. If the handler returns an Observable or Promise, Nest resolves it before replying. Throwing inside the handler propagates an RpcException to the caller’s Observable, which you can catch with the standard RxJS catchError.

Fire-and-forget with @EventPattern

Use @EventPattern when you’re broadcasting a fact that already happened and the producer doesn’t care who consumes it or what they do with it. No reply channel is opened, and multiple services can subscribe to the same event independently.

import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';

interface UserCreatedEvent {
  userId: string;
  email: string;
}

@Controller()
export class NotificationsController {
  @EventPattern('user_created')
  async handleUserCreated(@Payload() data: UserCreatedEvent): Promise<void> {
    await this.sendWelcomeEmail(data.email);
    console.log(`Welcome email queued for ${data.email}`);
  }

  private async sendWelcomeEmail(email: string): Promise<void> {
    /* integrate your mail provider here */
  }
}

The producer emits the event and moves on without awaiting any business result:

import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';

@Injectable()
export class UsersService {
  constructor(@Inject('NOTIFY_SERVICE') private readonly client: ClientProxy) {}

  async register(email: string): Promise<string> {
    const userId = crypto.randomUUID();
    // persist the user...
    this.client.emit('user_created', { userId, email });
    return userId;
  }
}

Output:

Welcome email queued for [email protected]

emit() still returns an Observable, but it completes once the broker accepts the message — not when the subscriber finishes. Don’t rely on it to confirm the event was handled; it only confirms it was dispatched.

How to choose

Reach for @MessagePattern when the caller’s next step depends on the response — fetching a record, validating a token, computing a price. Reach for @EventPattern when you’re decoupling side effects from the main flow — sending emails, updating read models, writing audit trails. A good heuristic: if removing the listener should break the caller, it’s a message; if removing it should be invisible to the caller, it’s an event.

Best practices

  • Prefer events for cross-service side effects so producers stay decoupled from consumers and can scale independently.
  • Always subscribe to (or firstValueFrom) the Observable from send() — an unsubscribed request is never sent.
  • Keep message handlers fast and side-effect-light; offload slow work to event handlers triggered after the reply.
  • Use structured, versioned patterns (e.g. { cmd: 'sum' } or 'user_created_v2') so you can evolve contracts without breaking subscribers.
  • Make event handlers idempotent — most transports deliver at-least-once, so the same event can arrive more than once.
  • Wrap handler failures in RpcException and handle them with catchError on the caller side to avoid swallowed errors.
Last updated June 14, 2026
Was this helpful?