A practical take on @nestjs/event-emitter for decoupled side effects like notifications and audit, plus the in-process limits that tell you when to graduate to a real broker.
A Wednesday afternoon at a community and talent product I CTO on the side. Someone signs up, and seven things should happen. Welcome email, search indexing, audit row, referral counter, CRM sync. The signup controller had grown a tail of await calls that read like a TODO list. p95 sign-up latency was around 1.4s, most of it from the indexer call sitting on the critical path of a request that had no business waiting on Elasticsearch.
I didn’t reach for Kafka. I reached for @nestjs/event-emitter. Almost always the right first move, and also where most teams overshoot it.
@nestjs/event-emitter wraps eventemitter2 and plugs it into the DI container. You emit a named event from one provider, any provider decorated with @OnEvent reacts. In-process, in-memory, single-replica, fire-and-forget. No durability, no cross-pod fan-out, no replay.
That’s exactly what you want for decoupled side effects where losing the event under a crash is acceptable and the consumer lives in the same process. Notifications, search indexing, audit, cache warmups, analytics pings.
The trap is using it for things it can’t promise. Payment transitions. Billing webhooks. Anything where “the event happened but the handler never ran” is a bug support has to clean up.
EventEmitterModule.forRoot() is fine for a hello-world. Once you have more than a couple of listeners, set the config explicitly.
// app.module.ts
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({
imports: [
EventEmitterModule.forRoot({
wildcard: true,
delimiter: '.',
newListener: false,
removeListener: false,
maxListeners: 20,
verboseMemoryLeak: true,
ignoreErrors: false,
}),
],
})
export class AppModule {}
The two that have saved me are verboseMemoryLeak and ignoreErrors: false. The first tells you when a listener leaks because someone wired a request-scoped provider into a handler. The second surfaces thrown errors instead of letting the emitter swallow them. Loud handlers in dev, always.
Wildcards plus a dot delimiter give you a namespace. user.signed_up, user.deleted, billing.subscription.renewed. A handler can listen on user.* for cross-cutting audit.
Define a class per event, ship them from a contracts/ folder, import on both sides. Type-safety and a place to hang docs.
// contracts/events/user.events.ts
export class UserSignedUpEvent {
static readonly eventName = 'user.signed_up' as const;
constructor(
public readonly userId: string,
public readonly tenantId: string,
public readonly email: string,
public readonly signupSource: 'web' | 'mobile' | 'api',
public readonly occurredAt: Date,
) {}
}
export class UserDeletedEvent {
static readonly eventName = 'user.deleted' as const;
constructor(
public readonly userId: string,
public readonly tenantId: string,
public readonly occurredAt: Date,
) {}
}
Emitting from the signup service is one line. The controller is no longer aware of email, search, audit, or anything else downstream.
// auth/signup.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UsersRepository } from '../users/users.repository';
import { UserSignedUpEvent } from '../contracts/events/user.events';
@Injectable()
export class SignupService {
private readonly logger = new Logger(SignupService.name);
constructor(
private readonly users: UsersRepository,
private readonly events: EventEmitter2,
) {}
async signup(input: { email: string; tenantId: string; source: 'web' | 'mobile' | 'api' }) {
const user = await this.users.create(input);
this.events.emit(
UserSignedUpEvent.eventName,
new UserSignedUpEvent(user.id, input.tenantId, input.email, input.source, new Date()),
);
return user;
}
}
Notice the handler isn’t awaiting events.emit. emit returns a boolean, not a promise. Listeners run synchronously by default unless you mark them async. Which brings us to the part that bites everyone the first time.
@OnEvent accepts an async function. The catch is what “async” means. By default the listener runs in the same call stack as emit, and if it returns a promise that rejects, the rejection propagates up to the emit site unless you set ignoreErrors: true or pass async: true to the decorator.
I prefer the explicit form. Every handler is async, returns void, owns its own error.
// notifications/welcome-email.listener.ts
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EmailService } from './email.service';
import { UserSignedUpEvent } from '../contracts/events/user.events';
@Injectable()
export class WelcomeEmailListener {
private readonly logger = new Logger(WelcomeEmailListener.name);
constructor(private readonly email: EmailService) {}
@OnEvent(UserSignedUpEvent.eventName, { async: true, promisify: true })
async handle(event: UserSignedUpEvent): Promise<void> {
try {
await this.email.sendWelcome(event.email, { userId: event.userId, tenantId: event.tenantId });
} catch (err) {
this.logger.error(
`welcome email failed for user=${event.userId} tenant=${event.tenantId}`,
err instanceof Error ? err.stack : err,
);
}
}
}
async: true means the emitter won’t block on this listener. The try/catch is non-negotiable. Throw out of a listener with nothing to catch it and you get an unhandledRejection that some teams have promoted to process.exit(1). I’ve seen a bad email retry crash a pod that way.
The rule I use: if the event is lost during a redeploy, who pays the bill?
If the answer is “nobody, we re-derive from the next event”, event-emitter is fine. Cache warmups, periodic re-indexing, internal analytics where dropping a fraction is acceptable, welcome emails the user can resend.
If the answer is “the customer, support, or accounting pays”, you need durability before shipping. Subscription renewals. Inventory decrements. Webhook fan-out. Audit compliance reads.
Graduate to a broker. NATS JetStream for low ops cost and durable streams. RabbitMQ for rich routing if you already operate it. Kafka for replay, per-key ordering, and independent consumer groups. Outbox-pattern your DB writes into the broker. Don’t fire-and-forget across a process boundary the way you would inside one.
The combat-sports tournament platform I CTO’d in London. Saturday afternoon during a live federation broadcast. The standings page was fed by a chain that had started six months earlier as a NestJS event-emitter inside the rankings service before being extracted to Kafka. Extraction was complete. The deploy hygiene wasn’t.
The standings-projector consumer group started rebalancing every ~30 seconds. The match-events topic kept growing on the broker. Updates stopped reaching the leaderboard. Page froze at 14:32 local during a live broadcast.
First wrong fix: kubectl rollout restart. Pods rejoined, triggered another rebalance ~40 seconds later.
Real fix came from side-by-side pod logs. One pod out of six had max.poll.interval.ms at 60s instead of 300s. It was running a stale image because someone had pushed a config-touching fix without bumping the image tag, and the deployment was pulling :latest. That pod’s slow downstream call occasionally took ~70s, blew past the interval, got kicked out of the group, caused rebalances for everyone.
About 12 minutes of stale standings during a live broadcast. Two lessons. Pin image SHAs on every consumer pod the moment you graduate to a broker. And the reason we’d graduated was correct, an in-process listener would’ve been a single point of failure tied to whichever pod served the request.
How do you integration-test an event flow? Construct the testing module with the real EventEmitterModule and assert on emitted events.
// signup.service.spec.ts
import { Test } from '@nestjs/testing';
import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter';
import { SignupService } from './signup.service';
import { UsersRepository } from '../users/users.repository';
import { UserSignedUpEvent } from '../contracts/events/user.events';
describe('SignupService', () => {
let service: SignupService;
let emitter: EventEmitter2;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot({ wildcard: true })],
providers: [
SignupService,
{ provide: UsersRepository, useValue: { create: jest.fn().mockResolvedValue({ id: 'u_1' }) } },
],
}).compile();
service = moduleRef.get(SignupService);
emitter = moduleRef.get(EventEmitter2);
});
it('emits user.signed_up after persisting the user', async () => {
const received: UserSignedUpEvent[] = [];
emitter.on(UserSignedUpEvent.eventName, (e: UserSignedUpEvent) => received.push(e));
await service.signup({ email: '[email protected]', tenantId: 't_1', source: 'web' });
expect(received).toHaveLength(1);
expect(received[0].userId).toBe('u_1');
expect(received[0].signupSource).toBe('web');
});
});
No broker, no schema registry, no fake Kafka container. The point of staying in-process is the tests are cheap. Assert on the contract, not on the fact that you can call emit.
@nestjs/event-emitter for decoupled side effects where losing the event under a crash is fine. Notifications, search indexing, audit, cache warmups.async: true and wrap your handler body in try/catch. A thrown listener can crash a pod.eventName. Magic strings rot the moment you rename a field.Thanks for reading. If you’ve got thoughts, send them my way.