mdashikjs/blog
All posts
Event-Driven Architecture in Node.js: Patterns That Scale
System Design

Event-Driven Architecture in Node.js: Patterns That Scale

System Design5 min

Event-Driven Architecture in Node.js: Patterns That Scale

Direct function calls couple your modules. Events decouple them. But event-driven architecture has its own set of traps — lost events, ordering issues, and debugging nightmares. Here is how I handle them.

Event-DrivenNode.jsArchitecturePatterns
Share:

When events make sense

Use events when the producer does not care about the outcome. User signs up? Emit user.registered. Whether that triggers a welcome email, an analytics event, or a Slack notification is not the signup handler's concern.

In-process events with typed emitters

import { EventEmitter } from 'events';

interface AppEvents {
  'user.registered': [{ userId: string; email: string }];
  'order.completed': [{ orderId: string; total: number }];
}

class TypedEmitter extends EventEmitter {
  emit<K extends keyof AppEvents>(event: K, ...args: AppEvents[K]): boolean {
    return super.emit(event, ...args);
  }
  on<K extends keyof AppEvents>(event: K, listener: (...args: AppEvents[K]) => void): this {
    return super.on(event, listener as (...args: unknown[]) => void);
  }
}

export const appEvents = new TypedEmitter();

Now appEvents.emit('user.registered', { userId: '123' }) will type-error if email is missing.

The lost event problem

In-process events vanish if the process crashes between emitting and handling. For critical events (payments, order confirmations), use a transactional outbox:

  1. Write the event to an outbox table in the same transaction as the business data
  2. A separate worker polls the outbox and publishes events
  3. After successful publish, mark the outbox row as processed
BEGIN;
  INSERT INTO orders (id, total) VALUES ('abc', 99.00);
  INSERT INTO outbox (event_type, payload) VALUES ('order.completed', '{"orderId": "abc", "total": 99}');
COMMIT;

The event is guaranteed to exist if the order exists.

Idempotent handlers

Events can be delivered more than once (network retry, worker restart). Every handler must be idempotent:

appEvents.on('user.registered', async ({ userId, email }) => {
  // Check if we already sent the welcome email
  const sent = await redis.get(`welcome:${userId}`);
  if (sent) return;

  await sendWelcomeEmail(email);
  await redis.set(`welcome:${userId}`, '1', 'EX', 86400);
});

Debugging event-driven systems

The biggest downside: "why did this happen" is hard to answer when there is no direct call stack. Two things help:

  1. Correlation IDs: Every event carries a correlation ID from the original request. Log it everywhere.
  2. Event log: Store every emitted event with timestamp, type, and payload. This is your audit trail.

When to use a message broker instead

If you need cross-service events, durability, or replay capability, graduate to RabbitMQ or Kafka. In-process events are for in-process decoupling only.

MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.