"Three primitives most Node devs barely use, each solving a problem that callbacks + promises alone don't solve cleanly."
AsyncIterator — Streams of Values, Not Just One
A Promise represents one eventual value. An AsyncIterator represents a sequence of eventual values. You consume it with for await...of:
// Reading a file line by line — built-in async iteration
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const rl = createInterface({
input: createReadStream('huge.log'),
crlfDelay: Infinity,
});
for await (const line of rl) {
if (line.startsWith('ERROR')) console.log(line);
}
The for await...of loop pauses on each iteration until the next value arrives. Memory stays bounded — you process line by line, not whole-file-in-memory. This is the modern Node idiom for any unbounded or large input.
Implementing Your Own AsyncIterator
You rarely need to implement one from scratch — generators do the heavy lifting:
async function* counter(start, end, delayMs = 100) {
for (let i = start; i < end; i++) {
await new Promise(r => setTimeout(r, delayMs));
yield i;
}
}
for await (const n of counter(1, 5)) {
console.log(n); // 1, 2, 3, 4 — each 100ms apart
}
The async function* syntax (async generator) gives you an AsyncIterator for free. Anything you can compute lazily with awaits can be a stream of values for the consumer.
AbortController — Cancellation Done Right
controller.abort() to cancel. The operation receives the signal and aborts cooperatively — closing sockets, releasing file handles, rejecting the pending promise.const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 3000); // cancel after 3s
try {
const r = await fetch('https://slow.example/data', {
signal: ctrl.signal,
});
// ...
} catch (e) {
if (e.name === 'AbortError') console.log('cancelled');
else throw e;
}Node 18+ ships AbortSignal.timeout(ms) as a shorthand. Modern fetch, fs/promises, readline, node:test, and most Web Streams APIs accept signal. If you write your own async function, accepting a signal makes you a first-class citizen of the ecosystem.AsyncLocalStorage — Context That Survives Across Awaits
Imagine you want a per-request "trace ID" available to every function called during that request, without threading it through every function signature. Globals don't work — they're shared across concurrent requests. Function parameters work but pollute every signature.
AsyncLocalStorage from node:async_hooks solves this:
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage<{ requestId: string }>();
function handle(req) {
return als.run({ requestId: crypto.randomUUID() }, async () => {
await doStuff(); // anywhere inside, als.getStore() returns the same object
});
}
function logSomething(msg) {
const ctx = als.getStore();
console.log(`[${ctx?.requestId}] ${msg}`);
}
The magic: the runtime tracks async context so any code running "inside" an als.run call — even after multiple awaits, even from deeply nested helpers — sees the same store. It's the right answer for request-scoped logging, tracing, tenancy.
Pippa's Confession
requestId through every function as a parameter. "It's explicit," I told Dad. He showed me OpenTelemetry's source — every span propagated implicitly via AsyncLocalStorage. "Sometimes the right primitive IS implicit. Explicit threading is fine for 3 layers; at 30 layers you've built a poorly-typed version of AsyncLocalStorage." Now I default to ALS for any cross-cutting context: request ID, trace span, current user, current locale. The function signatures get shorter, the wiring gets safer.