cd ~/blog
|10 min read

Event-Driven Architecture with NestJS EventEmitter

NestJSNode.jsArchitectureBackendTypeScript

Your OrderService shouldn't know about emails, inventory, or analytics. The moment a service starts calling three other services directly, you've built a dependency web. Change one thing, break three others.

Event-Driven Architecture (EDA) flips that relationship. Instead of telling services what to do, you announce what happened and let interested parties react independently. This post covers why EDA matters, what it unlocks, and how to implement it in NestJS using @nestjs/event-emitter — including the nuances of emit, emitAsync, and the async, nextTick, and promisify decorator options that most tutorials skip.

1Why Event-Driven Architecture?

Picture a typical e-commerce backend. When a customer places an order, the OrderService needs to:

  • Send a confirmation email
  • Reserve inventory so the item doesn't get sold twice
  • Track the purchase in analytics

The naive approach? Call each service directly:

order.service.ts
@Injectable()
export class OrderService {
  constructor(
    private emailService: EmailService,
    private inventoryService: InventoryService,
    private analyticsService: AnalyticsService,
  ) {}

  async createOrder(dto: CreateOrderDto) {
    const order = await this.orderRepo.save(dto);

    // OrderService now depends on ALL of these
    await this.emailService.sendConfirmation(order);
    await this.inventoryService.reserve(order.items);
    await this.analyticsService.trackPurchase(order);

    return order;
  }
}

This works — until it doesn't. The OrderService now has direct knowledge of every downstream service. Want to add a loyalty points system? You modify OrderService. Want to send a Slack notification? Modify it again. Every new requirement means touching the same file and adding another dependency.

Comparison diagram showing tight coupling where OrderService directly depends on EmailService, InventoryService, and AnalyticsService versus event-driven where OrderService emits an event and independent listeners react

2What Can We Achieve with EDA?

When you switch to an event-driven approach, you get:

  • Decoupling — the emitter doesn't know (or care) who's listening. Add ten more listeners and the OrderService never changes.
  • Single Responsibility — each listener does one thing well. The email listener sends emails. The inventory listener reserves stock. No god services.
  • Extensibility — new features are additive. Need loyalty points? Create a LoyaltyListener and subscribe to order.created. Zero changes to existing code.
  • Testability — test each listener in isolation. Mock the event, assert the behavior. No need to set up the entire order flow.
  • Async Processing — fire-and-forget patterns become natural. Analytics doesn't need to block the order response.

3Setting Up NestJS EventEmitter

NestJS wraps the eventemitter2 package with decorators and dependency injection. Install it:

terminal
npm install @nestjs/event-emitter

Register the module in your AppModule:

app.module.ts
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';

@Module({
  imports: [
    EventEmitterModule.forRoot(),
  ],
})
export class AppModule {}

Now define the event payload. This is just a class — no decorators needed:

order-created.event.ts
export class OrderCreatedEvent {
  constructor(
    public readonly orderId: string,
    public readonly userId: string,
    public readonly items: { productId: string; quantity: number }[],
    public readonly total: number,
  ) {}
}

Refactor the OrderService to emit instead of calling services directly:

order.service.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { OrderCreatedEvent } from './order-created.event';

@Injectable()
export class OrderService {
  constructor(private eventEmitter: EventEmitter2) {}

  async createOrder(dto: CreateOrderDto) {
    const order = await this.orderRepo.save(dto);

    // Emit and forget — OrderService has ZERO knowledge of listeners
    this.eventEmitter.emit(
      'order.created',
      new OrderCreatedEvent(order.id, order.userId, order.items, order.total),
    );

    return order;
  }
}

And create the listeners — each in its own file, each with a single responsibility:

email.listener.ts
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { OrderCreatedEvent } from './order-created.event';

@Injectable()
export class EmailListener {
  @OnEvent('order.created')
  handleOrderCreated(event: OrderCreatedEvent) {
    // Send confirmation email
    console.log(`Sending email for order ${event.orderId}`);
  }
}
inventory.listener.ts
@Injectable()
export class InventoryListener {
  @OnEvent('order.created')
  handleOrderCreated(event: OrderCreatedEvent) {
    // Reserve stock for each item
    for (const item of event.items) {
      console.log(`Reserving ${item.quantity}x ${item.productId}`);
    }
  }
}
analytics.listener.ts
@Injectable()
export class AnalyticsListener {
  @OnEvent('order.created')
  handleOrderCreated(event: OrderCreatedEvent) {
    // Track purchase event
    console.log(`Tracking purchase: $${event.total}`);
  }
}
Event flow diagram showing OrderService emitting order.created event through EventEmitter2, which fans out to EmailListener, InventoryListener, and AnalyticsListener independently

4emit() vs emitAsync()

The EventEmitter2 instance gives you two ways to dispatch events, and the difference matters:

emit() — Fire and Move On

emit() is synchronous by default. It calls each listener one after another, in the same tick. If a listener is an async function, it fires it but does not await the returned Promise. The emitter moves on immediately.

order.service.ts
// Synchronous dispatch — listeners execute, but async listeners are NOT awaited
this.eventEmitter.emit(
  'order.created',
  new OrderCreatedEvent(order.id, order.userId, order.items, order.total),
);

// This line runs IMMEDIATELY, even if listeners are still processing
return order;

emitAsync() — Wait for Everyone

emitAsync() returns a Promise that resolves when all listeners have completed. This is essential when you need to guarantee that something happened before responding to the client — like confirming the email was sent or inventory was reserved.

order.service.ts
async createOrder(dto: CreateOrderDto) {
  const order = await this.orderRepo.save(dto);

  // Wait for ALL listeners to finish before responding
  await this.eventEmitter.emitAsync(
    'order.created',
    new OrderCreatedEvent(order.id, order.userId, order.items, order.total),
  );

  // At this point, email is sent, inventory is reserved, analytics is tracked
  return order;
}

Important: for emitAsync to actually await your listener, the listener needs to be wrapped as a Promise. This is where the promisify option comes in (covered in the next section).

Timeline diagram comparing emit() which runs listeners synchronously blocking the caller, emitAsync() which awaits all listener Promises, and emit() with async:true which returns immediately while listeners run on the next event loop tick

5@OnEvent Decorator Options Deep Dive

The @OnEvent() decorator accepts an options object as its second argument. Three options control how and when your listener executes: async, nextTick, and promisify. Let's break each one down.

async: true — Defer to the Event Loop

When async: true is set, the listener is invoked using setImmediate (with a fallback to setTimeout if not available). This means the listener does not run in the current execution — it gets scheduled for the next iteration of the event loop. The emitter returns immediately.

This is perfect for fire-and-forget work like analytics tracking, where you don't need the result and don't want to slow down the response:

analytics.listener.ts
@Injectable()
export class AnalyticsListener {
  @OnEvent('order.created', { async: true })
  handleOrderCreated(event: OrderCreatedEvent) {
    // This runs on the NEXT iteration of the event loop (via setImmediate)
    // The emitter has already returned — this doesn't block anything
    this.analyticsClient.track('purchase', {
      orderId: event.orderId,
      total: event.total,
    });
  }
}

nextTick: true — Higher Priority Deferral

When nextTick: true is set, the listener uses process.nextTick instead of setImmediate to invoke the listener asynchronously. The key difference: process.nextTick callbacks execute in the microtask queue, which runs before the next event loop phase. This gives your listener higher priority than setImmediate callbacks.

Use this when you need the listener to run asynchronously but as soon as possible — like invalidating a cache right after the current operation completes:

cache-invalidation.listener.ts
@Injectable()
export class CacheInvalidationListener {
  @OnEvent('order.created', { nextTick: true })
  handleOrderCreated(event: OrderCreatedEvent) {
    // Runs via process.nextTick — before any setImmediate or I/O callbacks
    // Higher priority than { async: true } but still non-blocking
    this.cacheManager.invalidate(`user:${event.userId}:orders`);
  }
}

promisify: true — Make It Awaitable

The promisify option wraps the listener in a Promise, enabling it to work properly with emitAsync(). Without this, calling emitAsync() won't actually wait for non-async listeners to complete.

Smart default: if your listener is an async function (its constructor name is AsyncFunction), the promisify option is automatically enabled. You only need to set it explicitly for synchronous listeners that you want to await with emitAsync.

email.listener.ts
@Injectable()
export class EmailListener {
  // async function → promisify is auto-enabled
  // emitAsync() will properly await this listener
  @OnEvent('order.created')
  async handleOrderCreated(event: OrderCreatedEvent) {
    await this.mailer.send({
      to: event.userId,
      subject: 'Order Confirmed',
      body: `Your order ${event.orderId} has been placed.`,
    });
  }
}

For a synchronous listener you want to work with emitAsync, set promisify: true explicitly:

inventory.listener.ts
@Injectable()
export class InventoryListener {
  // Synchronous listener — promisify: true wraps it in a Promise
  // so emitAsync() can await it
  @OnEvent('order.created', { promisify: true })
  handleOrderCreated(event: OrderCreatedEvent) {
    for (const item of event.items) {
      this.inventoryRepo.decrementStock(item.productId, item.quantity);
    }
  }
}

6Putting It All Together

Here's a realistic order flow that uses different options for different listeners based on their requirements:

order.service.ts
@Injectable()
export class OrderService {
  constructor(private eventEmitter: EventEmitter2) {}

  async createOrder(dto: CreateOrderDto) {
    const order = await this.orderRepo.save(dto);

    // Use emitAsync to wait for critical listeners (email + inventory)
    // while analytics runs async in the background
    await this.eventEmitter.emitAsync(
      'order.created',
      new OrderCreatedEvent(order.id, order.userId, order.items, order.total),
    );

    return order;
  }
}
listeners.ts
// CRITICAL: Must complete before response — async fn auto-promisifies
@OnEvent('order.created')
async handleEmail(event: OrderCreatedEvent) {
  await this.mailer.sendConfirmation(event);
}

// CRITICAL: Must complete — sync fn needs explicit promisify
@OnEvent('order.created', { promisify: true })
handleInventory(event: OrderCreatedEvent) {
  this.inventory.reserve(event.items);
}

// NON-CRITICAL: Fire-and-forget via setImmediate
@OnEvent('order.created', { async: true })
handleAnalytics(event: OrderCreatedEvent) {
  this.analytics.track('purchase', event);
}

// NON-CRITICAL: Deferred but high-priority via process.nextTick
@OnEvent('order.created', { nextTick: true })
handleCacheInvalidation(event: OrderCreatedEvent) {
  this.cache.invalidate(`user:${event.userId}:orders`);
}

What happens when emitAsync is called:

  • handleEmail — awaited (async function → auto-promisified)
  • handleInventory — awaited (explicitly promisified)
  • handleAnalytics not awaited, deferred via setImmediate
  • handleCacheInvalidation not awaited, deferred via process.nextTick

7When to Use Which

Here's a quick reference for choosing the right combination:

ScenarioMethodOption
Listeners are sync, result not neededemit()(none)
Non-critical background workemit()async: true
High-priority deferred workemit()nextTick: true
Must complete before response (async listener)emitAsync()(auto-promisified)
Must complete before response (sync listener)emitAsync()promisify: true

Rule of thumb: use emit() for fire-and-forget, emitAsync() when the caller needs to know the listeners succeeded. Combine with async: true or nextTick: true on individual listeners to opt them out of blocking — even within the same event.

Event-driven doesn't mean over-engineered. Start with plain emit() and simple listeners. Reach for emitAsync and the decorator options when you have a concrete reason — like needing to guarantee delivery before responding, or measuring that synchronous listeners are slowing down your endpoint. Let the problem guide the pattern, not the other way around.