Appearance
3.8.5 — Events, Errors & Cancellation
Underneath streams, sockets, servers, and half the npm registry sits one small class: EventEmitter, Node's implementation of publish/subscribe. This page opens it up — how listeners are stored and invoked (synchronously — a fact that surprises), the special error event whose mishandling crashes processes, and the listener-leak warning everyone eventually meets. Then the error models layered through Node's history — error-first callbacks → promisify → promises with the process-level safety nets — and finally the modern piece that ties cancellation together across the entire API surface: AbortController.
1. EventEmitter: the machinery
javascript
import { EventEmitter } from "events";
const bus = new EventEmitter();
const onOrder = (order) => console.log("order:", order.id);
bus.on("order", onOrder); // subscribe (alias: addListener)
bus.once("ready", () => init()); // auto-removed after first firing
bus.emit("order", { id: 42 }); // → "order: 42"
bus.off("order", onOrder); // unsubscribe — needs the SAME referenceInternally it's modest: a map of event name → array of listener functions; emit looks up the array and calls each listener synchronously, in registration order, then returns. Three consequences of that one sentence:
emitis synchronous. By the time it returns, every listener has already run — on the current stack (3.6.1). A slow listener delays the emitter and everything after it; the event system adds no asynchrony whatsoever (defer inside a listener withsetImmediateif needed — 3.8.1).offneeds identity.bus.off("order", (o) => …)removes nothing — that's a new function (3.6.2); you must keep the reference you registered. This is the #1 cause of "I unsubscribed but it still fires."- Listeners are strong references. A registered listener — and everything its closure captures — stays reachable until removed: the leak model of 3.6.2, which is why Node warns at 11+ listeners on one event (
MaxListenersExceededWarning): usually not a real need for 11 subscribers but a loop registering without cleanup. Raise the limit (setMaxListeners) only after proving it's legitimate.
Where you meet it: streams (data/end/error — 3.8.4), servers (request, connection), sockets, process itself (signals — 3.8.7), and as the base class for your own domain events (class OrderService extends EventEmitter).
2. The error event: the one that kills
One event name is special by convention and by code: if an EventEmitter emits "error" and no listener is attached, Node throws the error — and an uncaught throw from the event loop crashes the process:
javascript
const stream = fs.createReadStream("/no/such/file");
// no 'error' listener → ENOENT is THROWN → process exits
stream.on("error", (err) => res.status(404).end()); // ✅ handled — a normal eventThe rationale: errors must never pass silently (3.6.1's strict-mode philosophy at runtime scale), and pub/sub has no caller to throw to — so an unclaimed failure escalates to the top. The discipline it forces: every stream and socket needs an error listener (or, better, management by pipeline, which wires them for you — 3.8.4). A production Node service that "randomly crashes with ENOTFOUND/ECONNRESET" is almost always a bare emitter somewhere.
3. Three error models, one runtime
Node's age means three error-handling generations coexist; you must read all three fluently.
Error-first callbacks — the founding convention: async functions take a callback whose first parameter is the error, exactly one of (err, result) non-null:
javascript
fs.readFile("config.json", (err, data) => {
if (err) return handle(err); // the mandatory first line — forget it and
use(data); // failures silently vanish
});The convention made millions of callbacks composable (every library agreed where the error goes), and its weakness — nothing enforces the if (err) check — is precisely what promises fixed by making unhandled failure loud. util.promisify bridges the eras mechanically: it wraps any error-first function into a promise-returner (err → rejection, result → fulfillment); modern Node ships promised APIs natively (fs/promises, stream/promises, timers/promises), so the bridge is mostly for older libraries.
Promises — the semantics live in 3.6.8 (chain rules, try/catch with await). Node adds the process-level layer:
javascript
process.on("unhandledRejection", (reason) => {
logger.fatal({ reason }, "unhandled rejection"); // observe, then let it die
process.exit(1); // (default since v15: crash)
});
process.on("uncaughtException", (err) => { // synchronous throws that
logger.fatal({ err }, "uncaught exception"); // escaped everything
process.exit(1); // MUST exit — state is suspect
});The stance to internalize: these hooks are for logging and orderly death, not recovery. After an uncaught exception the process state is unknowable (half-finished operations, corrupt invariants) — "catch and continue" trades a clean restart (supervisor, Part 9.9's PM2/orchestrator) for undefined behavior. Fail fast, restart clean, alert loudly. The graceful-shutdown counterpart — SIGTERM → stop accepting → drain → exit — lives with process in 3.8.7. (Domains, the 2012 attempt at zonal error capture, are deprecated legacy — recognize require("domain") in old code as "replace when touched," nothing more. Their observability use-case is served by AsyncLocalStorage — 3.8.7.)
4. AbortController: cancellation, standardized
For years every API cancelled differently (req.abort(), clearTimeout, custom flags) — or not at all. AbortController (adopted from the web platform) unified it: a controller owns a signal; you pass the signal to operations; abort() cancels everything holding it:
javascript
const controller = new AbortController();
const { signal } = controller;
setTimeout(() => controller.abort(), 5000); // one line = a 5 s budget for ALL of:
try {
const res = await fetch(url, { signal }); // HTTP request
await pipeline(res.body, transform, dest, { signal }); // stream chain (3.8.4)
await setTimeoutPromise(200, null, { signal }); // timers/promises
} catch (err) {
if (err.name === "AbortError") return; // cancelled — expected, not a failure
throw err; // real errors still propagate
}Mechanics worth knowing: a signal is a tiny EventEmitter-like object (signal.aborted flag + an abort event) — cancellation is cooperative, each API listening and cleaning up when told. The helpers complete the toolkit: AbortSignal.timeout(ms) (a pre-armed deadline signal — the one-liner for "this fetch gets 5 seconds"), AbortSignal.any([...]) (first of several aborts wins — combine a user-cancel with a timeout), and signal.reason for why. Supporting it in your own async APIs is two steps — check signal.aborted at entry / between stages, and listen for abort to interrupt waits — and it's what turns "the user navigated away" from a resource leak into a clean teardown (the drills in 3.6.2/3.6.6 both leaned on this).
5. The expert lens
Synchronous emit is a design decision with sharp edges. It makes EventEmitter cheap and ordering predictable (no queue, no scheduling), but it means the subscriber's cost lands on the publisher's stack — a slow analytics listener inside request handling slows every request, invisibly (3.8.2's loop-blocking arithmetic applies). In-process events are function calls wearing a costume; genuine decoupling — independent failure, buffering, retry — needs a real queue (Part 10). Choose deliberately: EventEmitter for notification within a process, queues for work across failure boundaries.
Error channels must be total. The through-line of this page: every failure needs exactly one place it's guaranteed to land — callbacks made it a slot (err first), emitters made it an event with a crash-if-ignored rule, promises made it rejection with a process-level dead-letter hook. When you design any async API, the first question is the error channel's totality: can any failure occur that reaches no handler? Every production "random crash" story is a "yes" to that question, discovered late.
Cancellation is a first-class output. Pre-AbortController code treats cancellation as an afterthought, and it shows: leaked sockets on client disconnect, zombie polls after navigation, retries racing their own timeouts. Post-AbortController, "how do I stop this?" has one answer that composes across fetch, streams, timers, and your own code — so plumb the signal through everything you write, and treat an API you're adding that can't be cancelled the way you'd treat one that can't report errors: unfinished.
Next: when one thread isn't enough — 3.8.6: cluster, worker threads, child processes, and choosing among them.
Recall
- EventEmitter = map of event → listener array;
emitis synchronous (listeners run on the emitter's stack, in order — slow listener = slow emitter);offrequires the registered reference; listeners are strong references (leak model — hence the 11-listenerMaxListenersExceededWarning, usually a register-without-cleanup loop). - The error event is special: emitted with no listener → thrown → process crash. Every stream/socket gets an error listener — or
pipelinemanages it. "Random ECONNRESET crashes" = a bare emitter. - Three generations: error-first callbacks (
(err, result), check or lose failures) → util.promisify / native*/promisesAPIs → promises with process-level nets:unhandledRejection/uncaughtExceptionare for logging + orderly exit, never catch-and-continue (state is suspect; restart clean). Domains = deprecated legacy. - AbortController/AbortSignal: one cooperative cancellation standard across
fetch,pipeline,timers/promises, and your APIs (checkaborted, listen forabort);AbortSignal.timeout(ms)for deadlines,AbortSignal.anyto combine causes;AbortErroris an expected outcome, not a failure. - Judgment: in-process events = function calls (notification); real decoupling needs queues. Every async API needs a total error channel and a cancellation path.
Self-test: What does emit's synchrony imply for a slow listener? Why does off with an inline arrow do nothing? What exactly happens on an unlistened error event, and what discipline follows? Why must uncaughtException handlers exit? Wire one AbortSignal through a fetch + pipeline + timer and explain AbortError handling.
Quiz Bank
FoundationalHow does EventEmitter work internally, and what follows from emit being synchronous?
Internally: a map from event name to an array of listener functions; on appends, once wraps with self-removal, off splices by reference identity, and emit iterates the array synchronously in registration order on the current stack — when emit returns, every listener has run. Consequences: (1) a slow listener delays the emitter and every later listener — subscriber cost lands on the publisher (defer with setImmediate when needed); (2) ordering is deterministic — no queue exists; (3) off with a freshly-written arrow removes nothing, since it's a different function object (3.6.2) — keep the registered reference; (4) listeners (and their captured closures) are strong references until removed — the memory-leak model, which the MaxListenersExceededWarning at 11+ listeners exists to surface early (it usually means a loop registering without teardown, not a genuine fan-out).
FoundationalWhat is special about the error event?
By convention and by implementation: when an emitter emits "error" with no listener attached, Node throws the Error object instead of quietly dropping it — and an uncaught throw at event-loop level terminates the process (subject only to the uncaughtException hook). Rationale: pub/sub has no caller for failures to propagate to, and silent loss is the worst outcome — so unclaimed errors escalate maximally ("errors should never pass silently"). Practical discipline: every Readable/Writable/socket carries an error listener — or you use pipeline, which attaches and propagates them across the whole chain and destroys all stages on failure (3.8.4). The production smell it explains: services that "randomly crash" with ECONNRESET/ENOENT stack traces originating inside streams — that's an unlistened emitter, not a mystery.
AppliedExplain the error-first callback convention and what util.promisify does with it.
The founding Node convention: an async function's callback receives (err, result) with the error in the first slot — exactly one of the two is non-null, and every caller's first line is if (err) return handle(err). Its strength was standardization — all libraries agreed where failure lives, making composition and utilities possible; its weakness is that the check is unenforced — omit it and failures vanish silently (the exact silence promises later made loud via unhandled-rejection escalation — 3.6.8). util.promisify converts convention to promises mechanically: it returns a wrapper that calls the original with a generated callback, mapping err → rejection and result → fulfillment (functions with nonstandard shapes declare util.promisify.custom). Modern Node ships promise-native surfaces (fs/promises, stream/promises, timers/promises), so promisify is chiefly the adapter for older third-party libraries.
InterviewWhat should unhandledRejection and uncaughtException handlers do, and why is catch-and-continue wrong?
Both are last-resort observers, not recovery points. uncaughtException fires when a synchronous throw escaped every handler; unhandledRejection when a rejected promise never met a .catch (Node's default since v15 is to crash — deliberately, promoting the silent-failure class to loud). The correct handler: log with full context (structured, fatal level), flush telemetry, optionally close servers, then process.exit(1) and let the supervisor (PM2, systemd, Kubernetes — Part 9.9) restart clean. Catch-and-continue is wrong because the process state after an unattributed failure is unknowable — half-completed writes, held locks, corrupted in-memory invariants; continuing risks serving wrong data and corrupting further, which is strictly worse than a visible restart blip. The distinction to draw: failures you anticipate get handled locally (try/catch, error events) and the process lives; failures that reach the global hooks are by definition un-modeled — fail fast, restart, alert, then fix the missing local handler.
StaffDesign cancellation for a request handler that fans out to two upstream APIs, merges results, and streams the response — with a 3 s budget, client-disconnect handling, and no leaked work. Show the AbortController architecture.
One controller per request; every async edge holds its signal — cancellation then has a single root. Compose the causes with the helpers: const signal = AbortSignal.any([AbortSignal.timeout(3000), clientGone.signal]), where clientGone is an AbortController fired on res close — now either budget expiry or disconnect aborts everything downstream, with signal.reason distinguishing them for logs. Fan-out: Promise.all([fetch(a, { signal }), fetch(b, { signal })]) — on abort both HTTP requests physically cancel (sockets released, upstreams spared the orphan work; without this, a disconnect storm leaves you DDoSing your own upstreams with answers nobody awaits). Merge stage: pass signal into any nontrivial computation and check signal.aborted between steps (cooperative — CPU work doesn't magically stop).
Response: pipeline(merged, res, { signal }) tears the stream chain down cleanly (3.8.4). Failure taxonomy in the catch: AbortError + timeout reason → 504; AbortError + disconnect → log-only (no one to answer); anything else → the real error path. Two production notes: AbortSignal.timeout beats a hand-rolled setTimeout+abort() because it can't leak the timer; and put the signal in your internal client/service interfaces from day one — retrofitting cancellation through five call layers is the expensive version. Principle: a request's work forms a tree; cancellation must be a property of the tree's root, propagated by plumbing, not per-node improvisation.
Flashcards
Flashemit()
Synchronous, in registration order, on the emitter's stack — subscriber cost lands on the publisher. No queue exists.
Flashoff() gotcha
Removal is by reference identity — an inline arrow can't be unsubscribed. Keep the registered function.
FlashMaxListenersExceededWarning
11+ listeners on one event — usually a register-without-cleanup loop (leak), not real fan-out. Prove it before setMaxListeners.
FlashUnlistened 'error'
Thrown → process crash. Every stream/socket gets an error listener, or pipeline manages the chain.
FlashGlobal failure hooks
unhandledRejection / uncaughtException: log, flush, exit(1), supervisor restarts. Never catch-and-continue — state is suspect.
FlashAbortController kit
One signal through fetch/pipeline/timers/your APIs; AbortSignal.timeout(ms) deadlines; AbortSignal.any combines; AbortError = expected outcome.
Scenario Drill
DrillA dashboard service subscribes per-request to a shared market-data EventEmitter to push live prices over SSE. In production: memory climbs all day, MaxListenersExceededWarning floods the logs, p99 latency on unrelated endpoints degrades during market spikes, and once a day the process dies with an unhandled ECONNRESET. Untangle all four symptoms with this page and fix the design.
All four symptoms radiate from one shared emitter misused at request scope. Memory climb + MaxListeners warning: each SSE request registers listeners on the global emitter, and disconnects don't remove them — either off was never called or it's called with a different function than was registered (the inline-arrow identity trap). Dead connections' listeners — and their captured res, buffers, per-request state — stay strongly referenced by the emitter forever: the 3.6.2 leak with an EventEmitter as the long-lived holder; the warning at 11+ listeners was the early alarm, flooded because every request re-trips it.
P99 degradation during spikes: emit is synchronous — every price tick runs all registered listeners (including hundreds of dead ones) on the emitting code path's stack; during spikes, tick rate × listener count = milliseconds of synchronous work per tick on the shared loop, and unrelated endpoints queue behind it (3.8.2's arithmetic).
Daily crash: pushing to a closed SSE socket surfaces ECONNRESET as an error event on a socket with no listener → thrown → process death (section 2). Fix: (1) per-request lifecycle discipline — register named handlers, and tear down on the request's abort path: one AbortController per request fired on res close, with emitter.on(evt, handler, { signal }) (the events API accepts signals — teardown becomes automatic) or an explicit off with the same references; (2) invert the fan-out — requests shouldn't each subscribe to the firehose: one internal subscriber batches ticks and writes to a registry of live connections (a Set maintained by add/remove-on-close), turning per-tick cost from O(listeners-ever) to O(live-connections) and making the sync-emit cost explicit and bounded — batch/throttle writes so spikes coalesce; (3) error-listen every socket/res write path (or route through a small per-connection stream with pipeline) so peer resets are events, not crashes; (4) alerts on listener counts and loop lag — the two early signals this incident printed for weeks.
Team principle: a shared emitter is process-lifetime state; anything request-scoped that touches it must tie its registration to the request's abort signal — symmetric setup/teardown, enforced by plumbing, not memory.