Skip to content
ExponaBLOG
NESTJS · DAY 038 / 100

In-process events with EventEmitter for NestJS developers

Decouple modules without a message broker.

Expona Admin7 min read

In-process events with EventEmitter

When you build modular systems in NestJS, you often face a choice: tightly couple modules together, or reach for an external message broker. There's a middle ground that many teams overlook: in-process events with EventEmitter. This approach lets you decouple your application logic, trigger side effects after critical work is done, and keep things simple when a full message queue is overkill.

In this post, I'll show you how to emit domain events after successful operations, listen for them across your codebase, run async handlers without blocking requests, and understand when this pattern breaks down.

Why in-process events matter

Early in my career, I watched a monolith grow into a tangled mess where every module imported every other module. A user sign-up triggered email sends, audit logs, analytics pings, and Slack notifications—all synchronously, all in one controller. One slow third-party API call blocked the entire response.

Introducing a message broker fixed it, but added operational overhead: another service to monitor, retry logic to write, and deployment complexity. For smaller teams or simpler systems, that's not worth it. NestJS's built-in EventEmitter2 gives you event-driven decoupling without the infrastructure cost.

Emit after success — Publish a domain event once the work is committed

The core idea is simple: emit an event only after your critical operation succeeds. If you emit before saving to the database, a crash leaves listeners hanging. If you emit in a catch block, you broadcast a problem that might confuse downstream handlers.

Here's a realistic example from a lead management system:

typescript
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { LeadsRepository } from './leads.repository';
import { LeadCreatedEvent } from './events/lead-created.event';

@Injectable()
export class LeadsService {
  constructor(
    private readonly leadsRepository: LeadsRepository,
    private readonly eventEmitter: EventEmitter2,
  ) {}

  async createLead(createLeadDto: CreateLeadDto): Promise<Lead> {
    // 1. Do the work
    const lead = await this.leadsRepository.save({
      email: createLeadDto.email,
      name: createLeadDto.name,
      source: createLeadDto.source,
    });

    // 2. Emit the event only after success
    this.eventEmitter.emit(
      'lead.created',
      new LeadCreatedEvent(lead.id, lead.email, lead.source),
    );

    return lead;
  }
}

Notice the sequence: save first, emit after. If the database operation throws, the event never fires. This guarantees that every listener sees a lead that actually exists in your database.

Define the event class to carry exactly the data listeners need:

typescript
export class LeadCreatedEvent {
  constructor(
    readonly leadId: string,
    readonly email: string,
    readonly source: string,
  ) {}
}

This is a simple data carrier, not a service. It tells the story of what happened, without coupling listeners to your service layer.

Listen anywhere — @OnEvent decorator in any module

Once you emit an event, any module can listen for it using the @OnEvent decorator. This is where the decoupling magic happens. Your leads module doesn't know or care what listens; listeners don't need to import the leads service.

Let's say you have an email module that should send a welcome email:

typescript
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EmailService } from './email.service';
import { LeadCreatedEvent } from '../leads/events/lead-created.event';

@Injectable()
export class EmailListener {
  constructor(private readonly emailService: EmailService) {}

  @OnEvent('lead.created')
  async handleLeadCreated(event: LeadCreatedEvent) {
    await this.emailService.sendWelcome(event.email);
  }
}

And an analytics module that tracks lead source:

typescript
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { AnalyticsService } from './analytics.service';
import { LeadCreatedEvent } from '../leads/events/lead-created.event';

@Injectable()
export class AnalyticsListener {
  constructor(private readonly analyticsService: AnalyticsService) {}

  @OnEvent('lead.created')
  async handleLeadCreated(event: LeadCreatedEvent) {
    await this.analyticsService.track('lead.created', {
      leadId: event.leadId,
      source: event.source,
    });
  }
}

Both listeners react to the same event, but they're completely independent. You can add a third listener (CRM sync, assignment rules, whatever) without touching the leads service. This is the power of the event-driven pattern: your core domain logic stays clean.

Async listeners — Non-blocking side effects

By default, NestJS emits events synchronously. If any listener awaits a slow operation (like sending an email), it blocks the HTTP response. That defeats the purpose of decoupling.

Set async: true in the decorator to fire-and-forget:

typescript
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EmailService } from './email.service';
import { LeadCreatedEvent } from '../leads/events/lead-created.event';

@Injectable()
export class EmailListener {
  constructor(private readonly emailService: EmailService) {}

  @OnEvent('lead.created', { async: true })
  async handleLeadCreated(event: LeadCreatedEvent) {
    // This runs in the background, does not block the response
    await this.emailService.sendWelcome(event.email);
  }
}

Now the lead creation response returns immediately after saving to the database. Email, analytics, and other side effects happen in the background.

Here's the practical flow:

  1. Request arrives to create a lead
  2. Service saves lead to database
  3. Service emits lead.created event
  4. Response returns to client (fast)
  5. Listeners execute asynchronously in the Node.js event loop

This is much simpler than managing a Kafka topic or RabbitMQ queue for internal workflows. Users get instant feedback, and your system stays responsive.

Know the limits — In-memory only, lost on crash, not shared across instances

EventEmitter is an in-process solution. That means it lives only in your Node.js process memory. You must understand the constraints before using it in production:

Single instance only: If you scale to multiple server instances, each has its own EventEmitter. An event emitted on server A will not reach listeners on server B. For a single monolith, this is fine. For a distributed system with load balancing, you need a message broker.

No persistence: If your process crashes, all pending events are lost. A listener that hasn't processed yet is gone. If data loss is unacceptable (critical business events, audit trails), use Kafka or RabbitMQ.

No retry logic: If a listener throws an error, EventEmitter2 logs it but doesn't retry. You must handle retries in your listener code if needed.

When do these limits matter? If you're building a ride-hailing app and need to guarantee that every ride completion triggers billing, you want Kafka. If you're building an internal admin dashboard and a background sync occasionally fails, EventEmitter is fine.

To help you decide, here's a simple rule: use in-process events for internal side effects (email, analytics, cache invalidation). Use a message broker for business-critical workflows that require durability and cross-process communication.

Setup in NestJS

EventEmitter2 is built into NestJS. Import it in your module:

typescript
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { LeadsModule } from './leads/leads.module';

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

That's it. Now any service can inject EventEmitter2, and any injectable class can use @OnEvent.

Common mistakes

Emitting before save: If you emit a lead.created event before inserting into the database, a listener might try to fetch the lead and find nothing. Always emit after the write succeeds.

Forgetting async: true: If your listeners do I/O (database calls, HTTP requests), and you forget async: true, your endpoint will be slow. Users will wait for every side effect. Always set async: true unless you have a specific reason not to.

Tightly coupling listeners to internal DTOs: Avoid passing your internal CreateLeadDto or database entity directly as the event. Create a dedicated event class that carries only the data listeners need. This makes it safe to refactor your service later.

Treating it like Kafka: It's tempting to think in-process events are just a lightweight Kafka. They're not. Don't use them for distributed transactions, saga patterns, or workflows that must survive a process restart. Know the limits and pick the right tool.

Key takeaways

  • Emit a domain event after your core operation succeeds, so listeners work with data that actually exists.
  • Use @OnEvent in any module to react to events without importing the emitter service; this decouples your codebase.
  • Set async: true on listeners that do I/O, so side effects don't block your HTTP response.
  • Remember in-process events are single-instance, in-memory, and not persistent; use them for internal side effects, not business-critical workflows.
  • Create dedicated event classes to carry data, not DTOs or entities; this protects your listeners from internal refactoring.
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