Event-Driven Architecture — Node.js and TypeScript Patterns 2026

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Introduction

Why This Matters

Event-driven architecture (EDA) decouples producers from consumers — a service emits an event and never needs to know which services react to it. This enables independent scaling, loose coupling, and temporal decoupling (consumers can process events when they are available, not only when the producer is running).

Node.js's asynchronous event loop makes it a natural fit for EDA. From the built-in EventEmitter for in-process eventing to Kafka for distributed, durable event streams, the same mental model scales across every level.

In-Process Events with TypeScript EventEmitter

The EventEmitter class is Node.js's primitive for in-process pub/sub. TypeScript makes it type-safe with a typed event map:

import EventEmitter from 'events';
 
// Define the event map for type safety
interface AppEvents {
  'user:created': { userId: string; email: string; name: string };
  'user:deleted': { userId: string };
  'order:placed': { orderId: string; userId: string; total: number };
  'payment:failed': { orderId: string; reason: string };
}
 
// Typed EventEmitter wrapper
class TypedEventBus extends EventEmitter {
  emit<K extends keyof AppEvents>(event: K, payload: AppEvents[K]): boolean {
    return super.emit(event as string, payload);
  }
 
  on<K extends keyof AppEvents>(
    event: K,
    listener: (payload: AppEvents[K]) => void
  ): this {
    return super.on(event as string, listener);
  }
 
  once<K extends keyof AppEvents>(
    event: K,
    listener: (payload: AppEvents[K]) => void
  ): this {
    return super.once(event as string, listener);
  }
}
 
export const eventBus = new TypedEventBus();
eventBus.setMaxListeners(50); // Increase from default 10 for large apps

Usage across your application:

// In user service
eventBus.emit('user:created', {
  userId: 'usr_123',
  email: 'alice@example.com',
  name: 'Alice',
});
 
// In email service (listener registered at startup)
eventBus.on('user:created', async (user) => {
  await emailService.sendWelcome(user.email, user.name);
});
 
// In analytics service
eventBus.on('user:created', async (user) => {
  await analytics.track('user_created', { userId: user.userId });
});

In-process events are synchronous and in-memory only — they do not survive process restarts and cannot cross service boundaries.

Distributed Pub/Sub with RabbitMQ

For cross-service eventing, RabbitMQ provides durable, reliable message delivery with flexible routing via exchanges.

import amqp, { Connection, Channel } from 'amqplib';
 
interface EventEnvelope<T> {
  eventType: string;
  payload: T;
  timestamp: string;
  correlationId: string;
  version: number;
}
 
class RabbitMQEventBus {
  private connection!: Connection;
  private channel!: Channel;
  private readonly exchangeName = 'app.events';
 
  async connect(url: string): Promise<void> {
    this.connection = await amqp.connect(url);
    this.channel = await this.connection.createChannel();
    await this.channel.assertExchange(this.exchangeName, 'topic', {
      durable: true,
    });
  }
 
  async publish<T>(eventType: string, payload: T, correlationId: string): Promise<void> {
    const envelope: EventEnvelope<T> = {
      eventType,
      payload,
      timestamp: new Date().toISOString(),
      correlationId,
      version: 1,
    };
 
    this.channel.publish(
      this.exchangeName,
      eventType, // Routing key: "user.created", "order.placed"
      Buffer.from(JSON.stringify(envelope)),
      { persistent: true, contentType: 'application/json' }
    );
  }
 
  async subscribe<T>(
    eventType: string,
    handler: (envelope: EventEnvelope<T>) => Promise<void>
  ): Promise<void> {
    const queueName = `${process.env.SERVICE_NAME}.${eventType}`;
    await this.channel.assertQueue(queueName, { durable: true });
    await this.channel.bindQueue(queueName, this.exchangeName, eventType);
    this.channel.prefetch(10); // Process 10 messages concurrently per consumer
 
    this.channel.consume(queueName, async (msg) => {
      if (!msg) return;
 
      try {
        const envelope = JSON.parse(msg.content.toString()) as EventEnvelope<T>;
        await handler(envelope);
        this.channel.ack(msg); // Acknowledge only after successful processing
      } catch (err) {
        console.error('Event processing failed:', err);
        // Reject and requeue once; dead-letter after second failure
        this.channel.nack(msg, false, !msg.fields.redelivered);
      }
    });
  }
}

High-Throughput Streaming with Kafka

Kafka is appropriate when event volume exceeds what a traditional message broker can handle, or when event log retention and replay are required.

import { Kafka, Producer, Consumer, EachMessagePayload } from 'kafkajs';
 
const kafka = new Kafka({
  clientId: process.env.SERVICE_NAME ?? 'my-service',
  brokers: (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(','),
});
 
class KafkaEventBus {
  private producer!: Producer;
  private consumer!: Consumer;
 
  async connect(groupId: string): Promise<void> {
    this.producer = kafka.producer({ idempotent: true });
    this.consumer = kafka.consumer({ groupId });
 
    await this.producer.connect();
    await this.consumer.connect();
  }
 
  async publish(topic: string, key: string, payload: object): Promise<void> {
    await this.producer.send({
      topic,
      messages: [
        {
          key,
          value: JSON.stringify(payload),
          headers: { timestamp: Date.now().toString() },
        },
      ],
    });
  }
 
  async subscribe(
    topics: string[],
    handler: (topic: string, key: string, value: object) => Promise<void>
  ): Promise<void> {
    await this.consumer.subscribe({ topics, fromBeginning: false });
 
    await this.consumer.run({
      eachMessage: async ({ topic, message }: EachMessagePayload) => {
        const key = message.key?.toString() ?? '';
        const value = JSON.parse(message.value?.toString() ?? '{}');
        await handler(topic, key, value);
      },
    });
  }
 
  async disconnect(): Promise<void> {
    await this.producer.disconnect();
    await this.consumer.disconnect();
  }
}

Kafka consumers in the same groupId share partition consumption — enabling parallel processing with guaranteed ordering per partition key.

Event Schema Design and Versioning

Events are the API of event-driven systems. Breaking changes to event schemas are as disruptive as breaking REST API changes.

// Version your events explicitly
interface UserCreatedV1 {
  version: 1;
  userId: string;
  email: string;
}
 
interface UserCreatedV2 {
  version: 2;
  userId: string;
  email: string;
  name: string; // Added in v2
  plan: 'free' | 'pro' | 'enterprise'; // Added in v2
}
 
type UserCreatedEvent = UserCreatedV1 | UserCreatedV2;
 
function handleUserCreated(event: UserCreatedEvent): void {
  switch (event.version) {
    case 1:
      // Handle legacy v1 format
      console.log(`User ${event.userId} created (legacy)`);
      break;
    case 2:
      console.log(`User ${event.userId} (${event.plan}) created`);
      break;
  }
}

Follow the Postel's Law principle for events: be conservative in what you emit, liberal in what you accept. Consumers should ignore unknown fields rather than failing.

Common Mistakes

  • Not acknowledging messages after processing — with RabbitMQ, unacknowledged messages are redelivered indefinitely. Always ack after successful handling and nack on failure.
  • Emitting events inside database transactions — if the transaction rolls back after the event is emitted, consumers process an event for a state that no longer exists. Emit events after the transaction commits, or use the outbox pattern.
  • No dead-letter queue — messages that repeatedly fail processing accumulate and block the queue. Configure a dead-letter exchange for poison messages.
  • Tight event coupling — events should contain only the data consumers need, not internal domain objects. Fat events that expose internal state create implicit coupling.
  • Missing correlation IDs — without a correlation ID in every event, tracing a workflow across multiple services is impossible.

Best Practices

  • Use the outbox pattern: write events to a database table in the same transaction as domain changes, then publish them asynchronously.
  • Set prefetch (RabbitMQ) or maxInFlightRequests (Kafka) to control consumer concurrency and prevent overwhelm.
  • Validate event schemas on both publish and consume sides using Zod or JSON Schema.
  • Use topic naming conventions: <service>.<entity>.<action> (e.g., user.account.created).
  • Implement idempotent consumers — re-processing an event must produce the same result as processing it once.
  • Monitor queue depth, consumer lag, and processing error rate as primary operational metrics for event-driven systems.

Key Takeaways

  • Event-driven architecture decouples producers from consumers, enabling independent scaling and deployment of services.
  • Node.js EventEmitter provides typed in-process pub/sub; RabbitMQ and Kafka provide durable cross-service messaging.
  • RabbitMQ topic exchanges route events via wildcard routing keys; consumers bind named queues to exchanges for durable subscription.
  • Kafka consumer groups enable parallel processing with per-partition ordering guarantees.
  • Event schema versioning is required from day one — adding a version field and using discriminated unions in TypeScript enables backward-compatible evolution.
  • The outbox pattern (write events to a DB table in the same transaction, publish asynchronously) prevents the dual-write problem between database and message broker.
  • Idempotent consumers — those that produce the same result when processing an event multiple times — are essential for reliable event-driven systems.
  • Dead-letter queues and consumer lag monitoring are mandatory production infrastructure for event-driven systems.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading