惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Stack Overflow Blog
Stack Overflow Blog
T
Tor Project blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
P
Palo Alto Networks Blog
T
The Exploit Database - CXSecurity.com
P
Privacy International News Feed
C
Cybersecurity and Infrastructure Security Agency CISA
MyScale Blog
MyScale Blog
D
DataBreaches.Net
I
Intezer
GbyAI
GbyAI
Jina AI
Jina AI
The GitHub Blog
The GitHub Blog
S
Security @ Cisco Blogs
C
Cyber Attacks, Cyber Crime and Cyber Security
NISL@THU
NISL@THU
Project Zero
Project Zero
博客园_首页
Martin Fowler
Martin Fowler
A
About on SuperTechFans
J
Java Code Geeks
AI
AI
WordPress大学
WordPress大学
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
L
LINUX DO - 热门话题
云风的 BLOG
云风的 BLOG
腾讯CDC
酷 壳 – CoolShell
酷 壳 – CoolShell
C
Cisco Blogs
L
LangChain Blog
Google Online Security Blog
Google Online Security Blog
AWS News Blog
AWS News Blog
Help Net Security
Help Net Security
Application and Cybersecurity Blog
Application and Cybersecurity Blog
D
Docker
N
Netflix TechBlog - Medium
Know Your Adversary
Know Your Adversary
D
Darknet – Hacking Tools, Hacker News & Cyber Security
S
Secure Thoughts
H
Heimdal Security Blog
Recent Commits to openclaw:main
Recent Commits to openclaw:main
O
OpenAI News
S
Security Affairs
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
博客园 - 【当耐特】
雷峰网
雷峰网
V
Visual Studio Blog
T
Threat Research - Cisco Blogs

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
EventEmitter in Production: The Patterns Most Node.js Teams Get Wrong
The Practica · 2026-06-27 · via The Practical Developer

You are using EventEmitter right now. Every req.on('data') in Express, every process.on('uncaughtException'), every Stream 'data' listener, every database ORM lifecycle hook --- they all sit on top of Node.js’s EventEmitter. It is the single most pervasive abstraction in the platform, and it is also the one most teams get quietly wrong in production.

Three bugs, in order of how much damage they cause:

  1. An async event handler throws after the emit() call returns. The error is unhandled, the process crashes, and the stack trace points to nothing useful because the async continuation unwound long ago.
  2. A module registers a listener on a global emitter and forgets to clean it up. The listener accumulates on every hot reload or test run. Memory grows, behavior duplicates, and by the time someone notices, the emitter has 4,000 listeners for the same event.
  3. A team builds a beautiful event-driven architecture with custom event classes, domain events, and a central event bus. It works perfectly in development. In production, a single misconfigured listener takes down the entire bus, and nobody can figure out why the event chain broke.

This post fixes all three. It covers EventEmitter patterns that survive production traffic, typed events with TypeScript, async error handling, memory management, and when to reach for something other than EventEmitter entirely.

The EventEmitter contract you signed

Before the patterns, a refresher on what EventEmitter guarantees and what it does not.

EventEmitter is synchronous. When you call emitter.emit('data', payload), every registered listener runs in the same tick, in registration order. If a listener throws synchronously, the error propagates out of emit(). No more listeners run. Your code catches it or crashes. This is well-defined and usually fine.

The trouble starts when listeners are async:

emitter.on('order.placed', async (order) => {
  await sendEmail(order);       // Throws after the emit() returns
  await updateInventory(order); // Never runs
});

emit() does not await the returned promise. The async function starts, hits the first await, yields to the microtask queue, and the emit() call returns immediately. If sendEmail rejects, the rejection is an unhandled promise rejection because nobody owns that promise. Node.js will log it, and eventually (depending on version and --unhandled-rejections mode) it may crash the process.

This is bug number one, and it is the most common EventEmitter mistake in production Node.js code.

Pattern 1: Wrapping emit to handle async errors

The fix is to wrap emit() so it collects returned promises and attaches error handling. You do not need a library. A subclass with a single method override does it:

import { EventEmitter } from 'events';

export class SafeEventEmitter extends EventEmitter {
  emit(event: string | symbol, ...args: unknown[]): boolean {
    // Call the parent emit first so synchronous errors still propagate
    const result = super.emit(event, ...args);

    // Collect any promises returned by listeners and attach error handling
    const listeners = this.rawListeners(event);
    for (const listener of listeners) {
      const listenerResult = (listener as any)(...args);
      if (listenerResult instanceof Promise) {
        listenerResult.catch((err: Error) => {
          this.emit('error', err);
        });
      }
    }

    return result;
  }
}

This is deliberately simple. It fires each listener function again to capture the return value. That is O(n) per emit, which is fine for the typical listener count (single digits). If you have hundreds of listeners on a single event, you have a different problem (discussed later under the memory leak section).

A better version that does not call listeners twice uses a private symbol to track which returns are already captured:

const kAsyncResults = Symbol('asyncResults');

export class SafeEventEmitter extends EventEmitter {
  override emit(event: string | symbol, ...args: unknown[]): boolean {
    const listeners = this.rawListeners(event);
    const results: Array<Promise<unknown>> = [];

    for (const listener of listeners) {
      const result = (listener as any)(...args);
      if (result instanceof Promise) {
        results.push(result);
      }
    }

    for (const promise of results) {
      promise.catch((err: Error) => {
        this.emit('error', err);
      });
    }

    return !listeners.includes((l: any) => l.listener !== undefined && l.listener === undefined);
  }
}

The key line is promise.catch(...). It ensures that no async rejection goes unhandled, regardless of whether the caller awaits the emit. The error is redirected to the emitter’s 'error' event, which has its own contract (discussed next).

Pattern 2: The error event contract and why you must always listen to it

EventEmitter has a special 'error' event. If you emit an error and no listener is registered for it, the emitter throws the error. If the emitter is attached to a process domain (deprecated but still used in some codebases) or is the process itself, the process crashes.

The rule is simple: every emitter that can emit errors must have an 'error' listener, or it must be created with captureRejections: true (available since Node.js 16).

const dbEmitter = new SafeEventEmitter();

// This crashes the process if dbEmitter emits 'error' and no listener
dbEmitter.on('query.error', (err) => {
  logger.error({ err }, 'Database query failed');
});

// This prevents the crash
dbEmitter.on('error', (err) => {
  logger.error({ err }, 'Emitter-level error');
});

Node.js 16 introduced captureRejections, which automatically catches promise rejections from async listeners and emits them as 'error' events. Combined with a SafeEventEmitter, you get double coverage:

const emitter = new SafeEventEmitter({ captureRejections: true });

With this flag, Node.js internally wraps the listener’s return value and emits 'error' on rejection. The SafeEventEmitter above then adds a second layer of protection for any promises that slip through. Belt and suspenders, but for error handling, that is the right amount.

Pattern 3: Typed events with TypeScript

The vanilla EventEmitter types are any-ridden. You register a listener for 'data' and receive ...args: any[]. TypeScript cannot help you if you pass the wrong argument shape. A typed wrapper fixes this:

type EventMap = Record<string, unknown[]>;

export class TypedEventEmitter<T extends EventMap> extends EventEmitter {
  emit<K extends keyof T>(event: K, ...args: T[K]): boolean {
    return super.emit(event as string, ...args);
  }

  on<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
    return super.on(event as string, listener as (...args: unknown[]) => void);
  }

  once<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
    return super.once(event as string, listener as (...args: unknown[]) => void);
  }

  off<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
    return super.off(event as string, listener as (...args: unknown[]) => void);
  }
}

Usage:

interface OrderEvents {
  'order.placed': [orderId: string, customerEmail: string];
  'order.shipped': [orderId: string, trackingNumber: string];
  'order.cancelled': [orderId: string, reason: string];
  'error': [error: Error];
}

const orderBus = new TypedEventEmitter<OrderEvents>();

// TypeScript knows the argument types
orderBus.on('order.placed', (orderId, customerEmail) => {
  // orderId is string, customerEmail is string
});

orderBus.emit('order.placed', 'ord_123', 'user@example.com');
// Type error: orderBus.emit('order.placed', 42); // number is not string

Combine this with the SafeEventEmitter from Pattern 1 and you have a typed, async-safe event emitter that eliminates entire categories of bugs. The TypeScript compiler catches argument mismatches at build time, and the safe emit catches async rejections at runtime.

Pattern 4: Memory management (the one nobody does)

The EventEmitter memory leak warning (MaxListenersExceededWarning) is a symptom, not the disease. The disease is that something is adding listeners faster than it removes them. The most common cause is a module that registers a listener on a long-lived emitter inside a short-lived scope.

// Bad: listener registered on every request
app.get('/api/orders', (req, res) => {
  orderBus.on('order.placed', (orderId) => {
    log.debug(`Order placed: ${orderId}`);
  });
  // ...
});

Every request adds a listener. If you handle 10,000 requests per minute, you add 10,000 listeners per minute. The emitter’s listener array grows without bound. Even if each listener is a no-op (just a log line), the iteration cost of emit() grows linearly with the number of listeners. Eventually the MaxListenersExceededWarning fires at 11 (or your custom limit), production log monitoring catches it, and someone files a ticket.

The fix is to ask: does this listener belong on the emitter at all? If the answer is “no, it belongs on the request or the response,” use once or req.once:

// Better: scoped to the request lifecycle
app.get('/api/orders', (req, res) => {
  const onOrderPlaced = (orderId: string) => {
    log.debug({ orderId }, 'Order placed');
  };
  orderBus.once('order.placed', onOrderPlaced);
  res.on('close', () => orderBus.off('order.placed', onOrderPlaced));
});

The res.on('close') cleanup is not optional. Without it, if the event never fires, the listener leaks. Always pair on with off when the listener is scoped to a request, a transaction, or any ephemeral context.

For testing, the same pattern applies:

afterEach(() => {
  emitter.removeAllListeners(); // Clean slate
});

This is the single biggest EventEmitter anti-pattern in tests: tests that register listeners on a shared emitter and never clean up. The result is that test B’s listeners fire during test A’s emit, test B’s assertions fail, and hours are spent debugging a phantom race condition that is just stale listeners.

If you use a test framework with per-file isolation (like Jest’s --isolateModules or Vitest’s default worker-per-file), this is less of a problem, but it still bites on any shared module-level emitter.

Pattern 5: Graceful shutdown and event drain

When your service receives a SIGTERM, you need to stop accepting new events, let in-flight event handlers finish, and then close the emitter. EventEmitter has no built-in drain mechanism. You build one:

class DrainingEventEmitter extends SafeEventEmitter {
  private inflightCount = 0;
  private drainPromise: Promise<void> | null = null;
  private drainResolve: (() => void) | null = null;

  async emitAsync(event: string, ...args: unknown[]): Promise<void> {
    this.inflightCount++;
    try {
      await super.emit(event, ...args);
    } finally {
      this.inflightCount--;
      if (this.inflightCount === 0 && this.drainResolve) {
        this.drainResolve();
        this.drainPromise = null;
        this.drainResolve = null;
      }
    }
  }

  async drain(): Promise<void> {
    if (this.inflightCount === 0) return;
    this.drainPromise = new Promise((resolve) => {
      this.drainResolve = resolve;
    });
    return this.drainPromise;
  }
}

Usage during shutdown:

async function shutdown() {
  logger.info('Starting graceful shutdown');

  // Stop accepting new emitAsync calls
  emitter.removeAllListeners();

  // Wait for in-flight handlers to finish
  await emitter.drain();

  logger.info('All event handlers drained, exiting');
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

This replaces the raw emit() call with emitAsync() everywhere in your application. It is a small cost --- wrapping every emit in a promise --- but it guarantees that your shutdown does not cut off event handlers mid-flight.

Pattern 6: Building a domain event bus

When you have multiple services or modules that need to react to the same domain events (order placed, user registered, payment received), a central event bus is tempting. It is also a single point of failure if not built carefully.

A minimal domain event bus that avoids the common pitfalls:

interface DomainEvent {
  type: string;
  aggregateId: string;
  timestamp: number;
  data: unknown;
}

class DomainEventBus {
  private readonly emitter = new SafeEventEmitter({ captureRejections: true });

  publish(event: DomainEvent): void {
    this.emitter.emit(event.type, event);
    // Also emit a wildcard so subscribers can catch all events
    this.emitter.emit('*', event);
  }

  subscribe<T extends DomainEvent>(
    eventType: string,
    handler: (event: T) => void | Promise<void>,
  ): () => void {
    this.emitter.on(eventType, handler as (...args: unknown[]) => void);
    // Return an unsubscribe function
    return () => {
      this.emitter.off(eventType, handler as (...args: unknown[]) => void);
    };
  }

  subscribeAll(handler: (event: DomainEvent) => void | Promise<void>): () => void {
    this.emitter.on('*', handler as (...args: unknown[]) => void);
    return () => {
      this.emitter.off('*', handler as (...args: unknown[]) => void);
    };
  }

  async drain(): Promise<void> {
    // Remove all listeners to stop new events
    this.emitter.removeAllListeners();
    // If you use DrainingEventEmitter, await drain here
  }
}

A few design decisions in this bus that matter:

Events are plain objects with a type string, not class instances. Classes couple subscribers to a specific import. A plain object with a type field can cross module boundaries, serialization boundaries (if you ever queue events to a message broker), and process boundaries without ceremony.

subscribe returns an unsubscribe function. This lets the caller manage the listener lifecycle without holding a reference to the bus. React components, request handlers, and test fixtures can all call the returned function in their cleanup phase.

The wildcard '*' event enables monitoring and logging. A subscriber that catches all events can implement metrics, audit logging, or dead-letter detection without knowing about every event type at compile time.

The bus uses SafeEventEmitter internally. Async handler errors become 'error' events. You must subscribe to 'error' on the bus itself:

bus.subscribe('error', (err) => {
  logger.error({ err }, 'Event bus error');
  // Decide: crash, retry, or dead-letter
});

Without this, an async handler failure in any subscriber could crash the process. With it, you have a central place to decide the failure policy.

Pattern 7: EventEmitter vs. EventTarget

Node.js has two event APIs: the classic EventEmitter (Node’s own API, in events module) and the web-standard EventTarget (available since Node 18, global in Node 22+). They are not interchangeable.

EventTarget uses addEventListener, removeEventListener, and dispatchEvent. It passes a single Event object to listeners. It has once options and AbortSignal support built in. It is the API the browser uses, so if you are building something that runs in both environments (a shared library, a web worker, a service worker), EventTarget is the right choice.

EventEmitter is faster, supports multiple arguments, and has richer APIs (listeners(), rawListeners(), eventNames(), setMaxListeners()). If you are building a pure Node.js service, EventEmitter is usually the better choice.

If you need both (an event bus that works in the browser and on the server), wrap EventTarget:

class CrossPlatformEventBus {
  private target = new EventTarget();

  on(type: string, handler: EventListener): void {
    this.target.addEventListener(type, handler);
  }

  off(type: string, handler: EventListener): void {
    this.target.removeEventListener(type, handler);
  }

  emit(type: string, detail?: unknown): void {
    this.target.dispatchEvent(new CustomEvent(type, { detail }));
  }
}

This loses the multi-argument flexibility of EventEmitter. If you need multiple arguments, pack them into the detail object. It gains the ability to run in any JavaScript environment without a polyfill.

The checklist for shipping EventEmitter code

Before you merge that PR that adds an EventEmitter pattern to production, verify these:

  1. Async listeners are caught. Every listener that returns a promise either has its rejection handled or is used with an emitter that captures rejections (captureRejections: true or a SafeEventEmitter wrapper).
  2. The 'error' event has a listener. If the emitter can emit errors, there is a listener that handles them. Without this, the process may crash.
  3. Listeners are cleaned up. Every on() call on a short-lived scope has a matching off() call. If the scope is a request, the cleanup happens on response close. If it is a test, the cleanup happens in teardown.
  4. maxListeners is set explicitly. If you know a particular emitter will have more than 10 listeners, call setMaxListeners(n) with a deliberate number. The warning exists to catch leaks, not to annoy you. Suppress it only by raising the limit, not by silencing the warning.
  5. The emitter type is explicit. If you use TypeScript, wrap EventEmitter in a typed interface. Raw EventEmitter with any arguments is a bug farm.
  6. Shutdown drains events. If you use emitAsync or a similar pattern, the shutdown sequence drains in-flight handlers before exiting.

The working code

Here is the complete, copy-pasteable typed event emitter with async safety:

// safe-emitter.ts
import { EventEmitter } from 'events';

type EventMap = Record<string, unknown[]>;

export class TypedSafeEmitter<T extends EventMap> extends EventEmitter {
  constructor(options?: { captureRejections?: boolean }) {
    super({ captureRejections: options?.captureRejections ?? true });
  }

  emit<K extends keyof T>(event: K, ...args: T[K]): boolean {
    const listeners = this.rawListeners(event as string);
    const results: Array<Promise<unknown>> = [];

    for (const listener of listeners) {
      try {
        const result = (listener as (...args: T[K]) => unknown)(...args);
        if (result instanceof Promise) {
          results.push(result);
        }
      } catch (err) {
        this.emit('error' as keyof T, err as T['error']);
      }
    }

    for (const promise of results) {
      promise.catch((err: Error) => {
        this.emit('error' as keyof T, err as T['error']);
      });
    }

    return true;
  }

  on<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
    return super.on(event as string, listener as (...args: unknown[]) => void);
  }

  once<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
    return super.once(event as string, listener as (...args: unknown[]) => void);
  }

  off<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
    return super.off(event as string, listener as (...args: unknown[]) => void);
  }

  removeListener<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
    return super.removeListener(event as string, listener as (...args: unknown[]) => void);
  }
}

Usage:

interface AppEvents {
  'user.registered': [userId: string, email: string];
  'order.placed': [orderId: string, total: number];
  'error': [error: Error];
}

const bus = new TypedSafeEmitter<AppEvents>();

bus.on('error', (err) => {
  console.error('Bus error:', err.message);
});

bus.on('user.registered', async (userId, email) => {
  await sendWelcomeEmail(email);
  await createUserDirectory(userId);
});

bus.emit('user.registered', 'usr_456', 'newuser@example.com');

That is the entire pattern. One file, no dependencies, and every async error is caught, every event is typed, and the process does not crash when a listener fails.

When not to use EventEmitter

EventEmitter is not always the right tool. Three cases where you should reach for something else:

Case 1: You need exactly-once delivery. EventEmitter delivers to every listener, or to no listeners (if the emit happens before any listener is registered). There is no persistence, no replay, no acknowledgment. If you need guaranteed delivery, use a message queue (Redis Streams, Kafka, RabbitMQ). EventEmitter is a fire-and-forget in-process bus.

Case 2: You need backpressure. If a slow listener blocks the event loop, all other listeners are delayed. EventEmitter does not have backpressure. If your listeners perform I/O, the emit loop runs through all of them synchronously, starting each async operation, but none of them await. If the system is under load, the event loop fills with microtasks and latency degrades. For this scenario, consider a stream or a queue with concurrency control.

Case 3: You need cross-process events. EventEmitter is in-process only. For distributed events, use a message broker or a pub/sub system like Redis Pub/Sub, NATS, or Kafka. The domain event bus pattern above can be adapted to publish to and consume from a broker, but the EventEmitter itself stays in-process.

The takeaway

EventEmitter is the backbone of Node.js async, and it works remarkably well for a 15-year-old API. The bugs are not in the API itself. They are in how we use it: async listeners with abandoned promises, missing error listeners, leaked listeners on global emitters, and the assumption that emit() is fire-and-forget when the listeners are doing real work.

The patterns here fix those bugs with a typed wrapper, a safe emit override, explicit cleanup, and a drain mechanism for shutdown. They add up to about 60 lines of TypeScript that you can drop into any project and immediately eliminate the most common EventEmitter production incidents.

Every Node.js team hits these bugs eventually. The teams that fix them early do not have to debug phantom unhandled rejections at 3 AM.


A note from Yojji

The patterns in this post (typed event emitters, async-safe error propagation, graceful drain on shutdown, and proper listener lifecycle management) are the kind of infrastructure hygiene that Yojji builds into every Node.js backend they ship. Getting these details wrong is how a service that works in development quietly falls over under production load.

Yojji is an international custom software development company with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and building distributed systems where event-driven architecture and reliable async patterns are foundational. Since 2016, they have delivered full-cycle product engagements covering discovery, design, development, QA, and DevOps for clients across industries.