Async Patterns in Node.js: From Callbacks to Promises, EventEmitter, AbortSignal and AsyncLocalStorage
The building blocks of asynchronous Node.js code are the promise APIs, the synchronous EventEmitter, AbortSignal for cancellation, and AsyncLocalStorage, which carries request context across awaits. With real output: emit calling listeners synchronously, a process crashing on an unhandled 'error', captureRejections, AbortSignal.timeout and any(), request ids getting mixed up in a global variable, and a Bookstore OrderService that emits orderPlaced.
Version note: The examples in this post were run with Node.js 26.13.1 (LTS) and TypeScript 7.0.2.
Guess First
In Bookstore, the online bookstore used as the shared example across this site's series, we publish an event called orderPlaced when an order is created. There are two listeners: one sends a confirmation e-mail, the other is async and updates the sales dashboard.
import { EventEmitter } from "node:events";
const orders = new EventEmitter();
orders.on("orderPlaced", (id: string) => console.log(`send confirmation e-mail for ${id}`));
orders.on("orderPlaced", async (id: string) => {
await Promise.resolve();
console.log(`update sales dashboard for ${id}`);
});
console.log("before emit");
const handled = orders.emit("orderPlaced", "ORD-1001");
console.log(`after emit, handled = ${handled}`);
"Publishing an event" sounds like a message bus: emit drops the message and the listeners run at some later point. In what order are the four lines printed?
node labs/07/guess.ts
before emit
send confirmation e-mail for ORD-1001
after emit, handled = true
update sales dashboard for ORD-1001
The first listener ran before emit returned. EventEmitter calls listeners synchronously, in the order they were registered; emit only returns whether there were any listeners. The second listener was called right away too, but it stopped at its first await and the rest ran later as a microtask. There is no queue in between and no other thread; emit is a plain function call.
This post covers the patterns that hold asynchronous Node code together: moving from error-first callbacks to promises, EventEmitter, cancellation with AbortSignal, and AsyncLocalStorage for carrying per-request context. Promises and async/await themselves are covered in the Intermediate JavaScript series; here we look at the Node-specific side. At the end we add an OrderService to the Bookstore API, plus a correlation id that is attached to every log line automatically.
From Error-First Callbacks to Promises
Node's older APIs follow one convention: the last argument is a callback, and the callback's first parameter is the error. When there is no error it is null, and the result is in the second parameter:
import { readFile } from "node:fs";
import { promisify } from "node:util";
// Error-first callback: the first argument is the error, the rest is the result
readFile("missing-catalog.csv", "utf8", (err, text) => {
if (err) {
console.log(`callback: ${err.code} (${err.syscall})`);
return;
}
console.log(text.length);
});
// A legacy shipping client that only knows callbacks
function quoteShipping(city: string, weightGrams: number, callback: (err: Error | null, priceTry?: number) => void): void {
setTimeout(() => {
if (weightGrams <= 0) {
callback(new Error(`invalid weight: ${weightGrams}`));
return;
}
callback(null, city === "Istanbul" ? 49.9 : 79.9);
}, 10);
}
const quoteShippingAsync = promisify(quoteShipping);
console.log(`promisified: ${await quoteShippingAsync("Ankara", 450)} TRY`);
try {
await quoteShippingAsync("Izmir", 0);
} catch (err) {
console.log(`promisified rejection: ${(err as Error).message}`);
}
callback: ENOENT (open)
promisified: 79.9 TRY
promisified rejection: invalid weight: 0
util.promisify turns any function that follows this convention into one that returns a promise: if the callback receives an error the promise rejects, otherwise it resolves with the second argument. It is similar to wrapping the old APM (BeginRead/EndRead) APIs in .NET with Task.Factory.FromAsync. You will rarely write it in your own code, because Node's own modules already have promise versions: node:fs/promises, node:dns/promises, node:stream/promises, node:readline/promises and the node:timers/promises we used in 06. Use those in new code, not the callback versions, and keep promisify for an old package that knows nothing but callbacks.
The biggest problem with callbacks is error flow: an outer try/catch cannot catch an exception thrown inside a callback, because by the time the callback runs that try has long finished. With promises, the error is caught as a normal exception where it is awaited.
EventEmitter: Synchronous Emit, the 'error' Event and Async Listeners
EventEmitter is the class half of Node is built on: the HTTP server, sockets, streams and child processes are all emitters. Its API is small: on adds a listener, once adds one that runs a single time, off removes one, emit publishes. It is the same idea as C# events and delegates; the differences are that event names are strings and that the event named error behaves specially.
import { EventEmitter } from "node:events";
const payments = new EventEmitter();
payments.on("captured", (orderId: string) => console.log(`captured ${orderId}`));
payments.emit("captured", "ORD-1001");
// No 'error' listener: the emitter throws the error itself
payments.emit("error", new Error("card declined for ORD-1002"));
console.log("this line never runs");
captured ORD-1001
file:///home/user/bookstore-api/labs/07/emitter-error.ts:8
payments.emit("error", new Error("card declined for ORD-1002"));
^
Error: card declined for ORD-1002
at file:///home/user/bookstore-api/labs/07/emitter-error.ts:8:24
...
Emitted 'error' event at:
at file:///home/user/bookstore-api/labs/07/emitter-error.ts:8:10
...
Node.js v26.13.1
An error event nobody listens to terminates the process with exit code 1. Forgetting to listen to the error of a socket, a stream or your own emitter means a single connection failure in production can take the whole API down. The rule is simple: add an error listener to every emitter that can produce errors.
Where does an async listener's error go?
We saw it in Guess First: emit does not wait for the promise an async listener returns, and does not check its outcome either. What happens if the listener rejects?
import { EventEmitter } from "node:events";
// An async listener that fails: who catches the rejection?
const orders = new EventEmitter({ captureRejections: process.argv[2] === "capture" });
orders.on("orderPlaced", async (id: string) => {
await Promise.resolve();
throw new Error(`loyalty service unavailable for ${id}`);
});
orders.on("error", (err: Error) => console.log(`'error' listener: ${err.message}`));
orders.emit("orderPlaced", "ORD-1001");
setTimeout(() => console.log("process is still alive"), 10);
node labs/07/async-listener.ts; echo "exit=$?"
node labs/07/async-listener.ts capture; echo "exit=$?"
file:///home/user/bookstore-api/labs/07/async-listener.ts:7
throw new Error(`loyalty service unavailable for ${id}`);
^
Error: loyalty service unavailable for ORD-1001
at EventEmitter.<anonymous> (file:///home/user/bookstore-api/labs/07/async-listener.ts:7:11)
Node.js v26.13.1
exit=1
'error' listener: loyalty service unavailable for ORD-1001
process is still alive
exit=0
By default nobody catches the rejection, it becomes an unhandled rejection and the process exits; having an error listener does not help. With captureRejections: true the emitter attaches a catch to the async listener's promise itself and routes the error to the error event. Turn this setting on for every emitter that has async listeners. The Node documentation adds one more thing: the error listener itself should not be async, because its rejection is not captured again.
events.once and events.on: awaiting events
import { EventEmitter, once, on } from "node:events";
import { setTimeout as delay } from "node:timers/promises";
const shipments = new EventEmitter();
// events.once: wait for a single event as a promise
setTimeout(() => shipments.emit("labelPrinted", "SHP-77", "standard"), 20);
const [shipmentId, carrier] = await once(shipments, "labelPrinted");
console.log(`once -> ${shipmentId} via ${carrier}`);
// events.on: consume events with for await; 'closed' ends the loop
(async () => {
for (const status of ["InTransit", "OutForDelivery", "Delivered"]) {
await delay(10);
shipments.emit("status", "SHP-77", status);
}
shipments.emit("closed");
})();
for await (const [id, status] of on(shipments, "status", { close: ["closed"] })) {
console.log(`on -> ${id} ${status}`);
}
console.log(`listeners left: ${shipments.listenerCount("status")}`);
once -> SHP-77 via standard
on -> SHP-77 InTransit
on -> SHP-77 OutForDelivery
on -> SHP-77 Delivered
listeners left: 0
once resolves with the event's arguments as an array, and rejects if the emitter emits error while it is waiting. That is exactly how we waited for the worker's message in 06. on turns events into an async iterator; when an event from the close list arrives the loop ends and the listeners are cleaned up. Both can be cancelled with the signal option. Async iterators themselves are covered in the Advanced JavaScript series post on async iterators.
MaxListenersExceededWarning
import { EventEmitter } from "node:events";
const orders = new EventEmitter();
// A bug: every request subscribes again and never unsubscribes
function handleRequest(requestNo: number): void {
orders.on("orderPlaced", (id: string) => console.log(`request ${requestNo} saw ${id}`));
}
for (let i = 1; i <= 11; i++) {
handleRequest(i);
}
console.log(`listeners: ${orders.listenerCount("orderPlaced")}`);
listeners: 11
(node:47510) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 orderPlaced listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
The default limit is 10 listeners per event. It is a warning, not an error: the listener is still added. When you see it, your first reaction should not be setMaxListeners(100). Adding a listener per request to a long-lived, application-wide emitter and never removing it with off is a classic memory leak; each listener keeps its closure, and everything the request referenced, in memory. Remove request-bound subscriptions with once, or with off when the work is done.
Cancellation: AbortController, AbortSignal.timeout() and AbortSignal.any()
In .NET, cancellation goes through CancellationToken, and ASP.NET Core gives you HttpContext.RequestAborted for every request. Node's counterpart is the web-standard AbortController and AbortSignal: fetch, fs/promises, timers/promises, events.once, streams and child processes accept a signal option. Let's call a slow stock service with three kinds of cancellation:
import { createServer } from "node:http";
import { setTimeout as delay } from "node:timers/promises";
// A slow stock service: answers after 2 seconds
const stock = createServer(async (req, res) => {
await delay(2000);
res.end(JSON.stringify({ isbn: "9780441172719", available: 12 }));
});
await new Promise<void>((resolve) => stock.listen(0, "127.0.0.1", resolve));
const { port } = stock.address() as { port: number };
const url = `http://127.0.0.1:${port}/stock/9780441172719`;
// 1) Timeout only: CancellationTokenSource.CancelAfter counterpart
let started = performance.now();
try {
await fetch(url, { signal: AbortSignal.timeout(300) });
} catch (err) {
const e = err as Error;
console.log(`timeout: ${e.name} after ${Math.round(performance.now() - started)} ms (${e.message})`);
}
// 2) Linked signals: the client went away OR the timeout expired, whichever comes first
const clientGone = new AbortController();
setTimeout(() => clientGone.abort(new Error("client closed the connection")), 100);
started = performance.now();
try {
await fetch(url, { signal: AbortSignal.any([clientGone.signal, AbortSignal.timeout(300)]) });
} catch (err) {
const e = err as Error;
console.log(`any(): ${e.name} after ${Math.round(performance.now() - started)} ms (${e.message})`);
}
// 3) Promise-based timers accept a signal too
const ac = new AbortController();
setTimeout(() => ac.abort(), 50);
try {
await delay(1000, undefined, { signal: ac.signal });
} catch (err) {
console.log(`delay(): ${(err as Error).name}`);
}
stock.closeAllConnections();
stock.close();
timeout: TimeoutError after 306 ms (The operation was aborted due to timeout)
any(): Error after 102 ms (client closed the connection)
delay(): AbortError
Three details matter. AbortSignal.timeout() produces an error named TimeoutError, not AbortError, so your logs and retry logic can tell the two apart. AbortSignal.any() is the counterpart of C#'s CancellationTokenSource.CreateLinkedTokenSource: it aborts as soon as any of the listed signals aborts. Whatever you pass to abort(reason) becomes the rejection as is; the error on the second line is our own Error. Finally, AbortController is for requesting cancellation and AbortSignal is for observing it; pass only the signal to your functions, never the controller, just as you pass a CancellationToken rather than a CancellationTokenSource. The .NET side of carrying cancellation all the way to the database is in the ASP.NET Core Web API series post on async/await and CancellationToken; in Node we will build the signal for a client closing its connection on our own server in 10.
AsyncLocalStorage: Per-Request Context
In ASP.NET Core a service can reach the current request's data through IHttpContextAccessor or Activity.Current without taking a parameter. Underneath sits AsyncLocal<T>: the value travels along the async call chain. In Node, hundreds of requests are processed on a single thread at the same time, so keeping "the current request" in a module-level variable does not work. Let's see it in an experiment: the same log function reads both a global variable and an AsyncLocalStorage, three requests arrive at once and each finishes after a different time:
import { createServer } from "node:http";
import { AsyncLocalStorage } from "node:async_hooks";
import { setTimeout as delay } from "node:timers/promises";
// Wrong: one variable shared by every request in flight
let currentRequestId = "";
// Right: a value bound to the async call chain of each request
const requestContext = new AsyncLocalStorage<{ requestId: string }>();
function log(message: string): void {
const fromStore = requestContext.getStore()?.requestId;
console.log(`global=${currentRequestId} als=${fromStore} | ${message}`);
}
// The service never receives the request id as a parameter
async function loadOrder(orderId: string, dbLatencyMs: number): Promise<void> {
log(`loading ${orderId}`);
await delay(dbLatencyMs);
log(`loaded ${orderId}`);
}
const server = createServer((req, res) => {
const requestId = String(req.headers["x-request-id"]);
currentRequestId = requestId;
requestContext.run({ requestId }, async () => {
const url = new URL(req.url ?? "/", "http://localhost");
await loadOrder(url.searchParams.get("id")!, Number(url.searchParams.get("latency")));
res.end("ok");
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as { port: number };
const call = (requestId: string, orderId: string, latency: number) =>
fetch(`http://127.0.0.1:${port}/orders?id=${orderId}&latency=${latency}`, { headers: { "x-request-id": requestId } });
await Promise.all([call("req-A", "ORD-1001", 60), call("req-B", "ORD-1002", 20), call("req-C", "ORD-1003", 40)]);
server.close();
global=req-A als=req-A | loading ORD-1001
global=req-B als=req-B | loading ORD-1002
global=req-C als=req-C | loading ORD-1003
global=req-C als=req-B | loaded ORD-1002
global=req-C als=req-C | loaded ORD-1003
global=req-C als=req-A | loaded ORD-1001
On the first three lines both are right, because each request logs right after writing the variable. After the await, the global variable is stuck on the last request to arrive, req-C, and the log for ORD-1002 is attributed to the wrong request. AsyncLocalStorage is right on every line. run(store, callback) runs the callback, and every async operation it starts, with that store; getStore() returns the value for the current chain. Since Node 24, AsyncLocalStorage uses a more efficient implementation called AsyncContextFrame by default; the module is stable. Use run() rather than methods that are still experimental, such as enterWith().
Bookstore: OrderService, 'orderPlaced' and a Correlation Id
Now we put these pieces into the API. First the request context and a small logger that reads it:
import { AsyncLocalStorage } from "node:async_hooks";
// Per-request values that follow the async call chain (HttpContext / Activity counterpart)
export interface RequestContext {
requestId: string;
method: string;
path: string;
}
const storage = new AsyncLocalStorage<RequestContext>();
export function runWithRequestContext<T>(context: RequestContext, work: () => T): T {
return storage.run(context, work);
}
export function currentRequestContext(): RequestContext | undefined {
return storage.getStore();
}
import { currentRequestContext } from "../context/requestContext.ts";
type Level = "info" | "warn" | "error";
// One JSON line per entry; the request id comes from AsyncLocalStorage, not from a parameter
export function log(level: Level, msg: string, fields: Record<string, unknown> = {}): void {
const entry = { time: new Date().toISOString(), level, requestId: currentRequestContext()?.requestId, msg, ...fields };
const line = JSON.stringify(entry);
if (level === "error") {
console.error(line);
} else {
console.log(line);
}
}
This logger is temporary; pino replaces it in 14 and the correlation id will keep coming from the same store. OrderService is a typed EventEmitter: the generic parameter in @types/node checks event names and arguments at compile time. captureRejections is on and it has its own error listener:
interface OrderEvents {
orderPlaced: [order: Order];
error: [err: Error];
}
// Places orders in memory and announces them; listeners (notifications, reports) stay decoupled
export class OrderService extends EventEmitter<OrderEvents> {
#orders = new Map<string, Order>();
#nextNumber = 1001;
constructor() {
// Rejections from async listeners go to the 'error' event instead of crashing the process
super({ captureRejections: true });
this.on("error", (err) => {
log("error", "orderPlaced listener failed", { error: `${err.name}: ${err.message}`, cause: (err.cause as Error | undefined)?.name });
});
}
placeOrder(customerId: string, lines: readonly OrderLine[]): Order {
const items: OrderItem[] = lines.map((line) => {
const book = findBook(line.isbn);
if (!book) {
throw new UnknownBookError(line.isbn);
}
return { isbn: book.isbn, quantity: line.quantity, unitPrice: book.price };
});
const order: Order = {
id: `ORD-${this.#nextNumber++}`,
customerId,
status: "Pending",
items,
createdAtUtc: new Date().toISOString(),
};
this.#orders.set(order.id, order);
log("info", "order placed", { orderId: order.id, items: items.length });
this.emit("orderPlaced", order);
return order;
}
}
UnknownBookError is a small class derived from Error; we declare its field on a separate line instead of in the constructor parameter, because parameter properties do not work with type stripping (05). The notification side does not know OrderService; it only subscribes to the event and limits the call to the e-mail provider to 2 seconds:
// Stand-in for an e-mail provider call; honours the abort signal like fetch does
async function sendEmail(to: string, subject: string, signal: AbortSignal): Promise<void> {
await delay(Number(process.env.EMAIL_LATENCY_MS ?? 30), undefined, { signal });
log("info", "e-mail sent", { to, subject });
}
async function sendOrderConfirmation(order: Order): Promise<void> {
await sendEmail(`${order.customerId}@example.com`, `Order ${order.id} received`, AbortSignal.timeout(2000));
}
export function registerOrderNotifications(orders: OrderService): void {
orders.on("orderPlaced", sendOrderConfirmation);
}
We write the POST /api/orders endpoint in 10; for now labs/07/place-order.ts creates the order inside a request context. First the normal case, then the case where the e-mail provider is 3 seconds late:
node labs/07/place-order.ts
EMAIL_LATENCY_MS=3000 node labs/07/place-order.ts
{"time":"2026-11-14T18:32:24.984Z","level":"info","requestId":"req-7f3a","msg":"order placed","orderId":"ORD-1001","items":2}
placeOrder returned ORD-1001
UnknownBookError: unknown ISBN 9780000000000
{"time":"2026-11-14T18:32:25.018Z","level":"info","requestId":"req-7f3a","msg":"e-mail sent","to":"ada@example.com","subject":"Order ORD-1001 received"}
---
{"time":"2026-11-14T18:32:28.542Z","level":"info","requestId":"req-7f3a","msg":"order placed","orderId":"ORD-1001","items":2}
placeOrder returned ORD-1001
UnknownBookError: unknown ISBN 9780000000000
{"time":"2026-11-14T18:32:30.546Z","level":"error","requestId":"req-7f3a","msg":"orderPlaced listener failed","error":"AbortError: The operation was aborted","cause":"TimeoutError"}
Note two things. The e-mail log line carries the right request id even though it was written 34 ms after the order log line, long after the run() callback had finished: the listener inherits the context that was active at emit. In the second run, the expired timeout did not take the process down; thanks to captureRejections it went to the error listener, and the cause field shows that the real reason was the timeout. One caveat: this notification runs in the same process and in memory, so if the process crashes the e-mail is lost. Work that must not be lost needs the outbox and queue approach from the Message Queues series.
The last piece is src/server.ts. For every request we use the incoming x-request-id header if it looks reasonable, otherwise a new one from randomUUID(); we echo it in the response and run the handler inside the context. The response's finish event is an EventEmitter event too, and we log the request duration there. The server now listens on 127.0.0.1 only by default (HOST changes that); we will discuss why in 10.
// Accept the caller's correlation id if it looks sane, otherwise create one
function requestIdFrom(header: string | string[] | undefined): string {
return typeof header === "string" && /^[\w.-]{1,64}$/.test(header) ? header : randomUUID();
}
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const requestId = requestIdFrom(req.headers["x-request-id"]);
res.setHeader("x-request-id", requestId);
runWithRequestContext({ requestId, method: req.method ?? "GET", path: url.pathname }, async () => {
const started = performance.now();
res.on("finish", () => {
log("info", "request finished", { method: req.method, path: url.pathname, status: res.statusCode, durationMs: Math.round(performance.now() - started) });
});
// ... routes as in 06
});
});
curl -s -o /dev/null -D - -H "x-request-id: angular-5d1c" http://127.0.0.1:3000/api/books | grep -i x-request
curl -s -o /dev/null -D - -H "x-request-id: bad id with spaces" http://127.0.0.1:3000/api/books | grep -i x-request
x-request-id: angular-5d1c
x-request-id: 0be9eab5-069d-47ed-b49c-4dd89686afd3
Bookstore API listening on http://127.0.0.1:3000 (development)
{"time":"2026-11-14T18:32:41.433Z","level":"info","requestId":"angular-5d1c","msg":"request finished","method":"GET","path":"/api/books","status":200,"durationMs":0}
{"time":"2026-11-14T18:32:41.444Z","level":"info","requestId":"0be9eab5-069d-47ed-b49c-4dd89686afd3","msg":"request finished","method":"GET","path":"/api/books","status":200,"durationMs":0}
The id sent by the Angular client came back unchanged; the one containing spaces was rejected and replaced with a UUID. Validating the header matters: using a client value unchecked lets very long or meaningless values into your logs, and on systems that write plain-text logs it allows fake log lines to be injected with line breaks.
unhandledRejection and uncaughtException
When a promise that is never awaited, or has no catch, rejects:
// A promise rejects and nobody awaits it
async function chargeCard(orderId: string): Promise<void> {
throw new Error(`payment gateway timeout for ${orderId}`);
}
chargeCard("ORD-1003");
setTimeout(() => console.log("still serving requests?"), 10);
file:///home/user/bookstore-api/labs/07/unhandled.ts:3
throw new Error(`payment gateway timeout for ${orderId}`);
^
Error: payment gateway timeout for ORD-1003
at chargeCard (file:///home/user/bookstore-api/labs/07/unhandled.ts:3:11)
...
Node.js v26.13.1
exit=1
Since Node 15 the default mode is --unhandled-rejections=throw: the rejection is treated like an uncaught exception and the process exits. With the warn mode only an UnhandledPromiseRejectionWarning is printed and still serving requests? appears; do not use that mode in production, because it hides the error. .NET behaves differently here: an unobserved Task exception does not take the process down, it only raises the TaskScheduler.UnobservedTaskException event.
What about catching everything with process.on("uncaughtException") and carrying on? In this example, reserving stock is a two-step update and there is a bug between the two steps:
// Anti-pattern: swallow every crash and keep going
const reservations: { isbn: string; quantity: number }[] = [];
let reservedUnits = 0;
process.on("uncaughtException", (err) => {
console.log(`ignored: ${err.message}`);
});
function reserveStock(isbn: string, quantity: number, warehouse: string | undefined): void {
reservedUnits += quantity;
// A bug between the two steps of the update
const code = warehouse!.toUpperCase();
reservations.push({ isbn, quantity });
console.log(`reserved ${quantity} x ${isbn} in ${code}`);
}
setTimeout(() => reserveStock("9780441172719", 3, undefined), 0);
setTimeout(() => console.log(`reservedUnits: ${reservedUnits}, reservations: ${reservations.length}`), 10);
ignored: Cannot read properties of undefined (reading 'toUpperCase')
reservedUnits: 3, reservations: 0
exit=0
The process is alive, but its state is corrupt: the counter says 3 units are reserved, the reservation list is empty. Every request from now on works with this inconsistent state. The Node documentation also recommends using uncaughtException only for final cleanup and then letting the process exit. The right approach: log the error, try to close open resources, exit with code 1, and leave the restart to a supervisor outside the process (Kubernetes, systemd, Docker's restart policy). We build graceful shutdown in 14.
In Production
Put a correlation id on every log line. Validate the incoming x-request-id, generate one if it is missing, and pass the same header to the response and to downstream calls. With OpenTelemetry, trace context travels through the same mechanism, AsyncLocalStorage; the details are in the Observability series.
Give every outgoing call a timeout. fetch has no default timeout for the total duration; without AbortSignal.timeout() a slow service can keep your request open for minutes.
Prepare the process to crash. unhandledRejection and uncaughtException terminate the process, and that is the right behaviour. What matters is that a supervisor restarts it immediately and that work kept in memory (like our e-mail notification) lives in a durable queue instead.
Watch long-lived emitters. Treat MaxListenersExceededWarning as an error in CI and in logs; it is usually the first sign of a real leak.
Common Pitfalls
Assuming emit is asynchronous. Listeners run synchronously; a heavy listener holds up placeOrder and the request that called it, and an exception from a synchronous listener is thrown straight into the code that called emit.
Using async listeners without captureRejections. The error becomes an unhandled rejection and terminates the process; an error listener does not prevent that.
Not listening to the error event. A socket, a stream, a child process or your own emitter: an error nobody listens to crashes the process.
Keeping request data in a module-level variable. It looks right until the first await, then gets mixed up with another request's data under load. Use AsyncLocalStorage.
Passing an async function to a callback API. With something like fs.readFile(path, async (err, data) => { ... }), nobody waits for the promise the callback returns, so an error inside it is again an unhandled rejection. Use the promise API.
A Small Experiment
★☆☆ Add the first listener in labs/07/guess.ts with orders.once and call emit twice. Which lines are printed on the second call, and what does emit return?
★★☆ Change sendOrderConfirmation with AbortSignal.any() so that, in addition to the timeout, it also listens to the signal of an AbortController that is aborted when the application shuts down. Which error name shows up in the log when SIGINT arrives?
★★★ Add a "sales summary" consumer to OrderService that consumes the orderPlaced event with events.on: it should log total revenue every 10 orders and be stoppable with an AbortSignal. Which request id appears on the consumer's log lines, and why?
We have seen the pieces of asynchronous flow: callbacks, promises, events, cancellation and context. In the next post we move to the place where we use these tools most often, the file system: fs/promises, path and Buffer, with Bookstore's book cover folder and a catalog import from CSV.