JavaScript Deep Dive — 03 · Async Engineering
Post 01 explained when callbacks run: tasks, microtasks, and the rendering steps between them. This post is about building systems on top of that machinery. Knowing that await yields to the microtask queue is the easy part. The hard part is everything production adds: a request that must be cancelled when the user navigates away, a fan-out of 10,000 calls that must not open 10,000 sockets, a flaky dependency that needs retries without turning a blip into a retry storm, a stream that must not buffer a 4 GB file into memory, and an error that must still say why it happened after passing through five layers. The examples target the current platform: ES2025 plus the parts of ES2026 already shipped in Node 24 and evergreen browsers.
Series map: 00 Engines & Runtime → 01 Event Loop & Concurrency → 02 Objects, Prototypes & Metaprogramming → 03 Async Engineering (this post) → 04 Modules, Packaging & TypeScript at Scale → 05 Performance, Memory & Production Debugging (future: see the extensions table in 05).
Learning Outcome
- Describe a promise as a state machine, predict when each combinator settles, and use
Promise.withResolvers/Promise.try. - Find the code paths that produce unhandled rejections, and know how Node and browsers react to them.
- Explain what
async/awaitdesugars to and what eachawaitcosts. - Cancel work end to end with
AbortController,AbortSignal.timeout()andAbortSignal.any(). - Write and consume async iterators (with cleanup on early
break), and use WHATWG and Node streams with real backpressure. - Build a concurrency limiter, retries with backoff and full jitter, timeouts that stop the work, idempotency keys and
causechains.
Chapter 1 — Promises as a state machine
Terminology
- Pending / fulfilled / rejected — the three states. A promise leaves pending at most once.
- Settled — fulfilled or rejected. Resolved is different: a promise is resolved once its fate is locked in, which can happen while it is still pending (resolved to another promise).
- Thenable — any object with a callable
thenmethod. Promise machinery adopts thenables, which is whyresolve(otherPromise)does not fulfil with a promise value. - Reaction — a callback registered with
then/catch/finally. Reactions always run as microtasks, never synchronously. - Handled — a rejected promise that has at least one rejection reaction attached. The host tracks this to report unhandled rejections.
Resolution is not fulfilment
1
2
3
4
const inner = new Promise((r) => setTimeout(() => r('late'), 100));
const outer = new Promise((resolve) => resolve(inner)); // resolved now, pending for 100 ms,
// then fulfils with 'late', not with `inner`
Resolving with a thenable queues a job that calls thenable.then(resolve, reject). That job costs extra microtask ticks, which matters for ordering puzzles and for the return vs return await discussion in Chapter 3.
The four combinators
All four take an iterable and return one promise. They differ in what "done" means.
| Combinator | Fulfils when | Rejects when | Empty input |
|---|---|---|---|
Promise.all |
every input fulfils (values in input order) | the first input rejects | fulfils with [] |
Promise.allSettled |
every input settles (never rejects) | never | fulfils with [] |
Promise.any |
the first input fulfils | every input rejects, with an AggregateError |
rejects with AggregateError |
Promise.race |
the first input to settle fulfils | the first input to settle rejects | stays pending forever |
A worked example with three inputs: B rejects at 20 ms, A fulfils at 30 ms, C fulfils at 80 ms.
1
2
3
4
5
6
7
8
9
10
const after = (ms, fn) => new Promise((res, rej) => setTimeout(() => fn(res, rej), ms));
const A = () => after(30, (res) => res('a'));
const B = () => after(20, (_, rej) => rej(new Error('b')));
const C = () => after(80, (res) => res('c'));
await Promise.all([A(), B(), C()]); // rejects at 20 ms with Error('b')
await Promise.allSettled([A(), B(), C()]); // fulfils at 80 ms with three {status, value|reason} records
await Promise.any([A(), B(), C()]); // fulfils at 30 ms with 'a' (B's rejection is ignored)
await Promise.race([A(), B(), C()]); // rejects at 20 ms with Error('b')
The most important row is the one that is not in the table: no combinator cancels the losers. When Promise.all rejects at 20 ms, A and C keep running, their sockets stay open, and their results are thrown away. Cancellation is a separate mechanism (Chapter 4), and you have to wire it in yourself.
Common pitfall:
Promise.race([work(), timeout(5000)])is not a timeout. It stops waiting after 5 s. The work carries on, holding its connection and memory, and its eventual rejection may even become unhandled.
Newer helpers
1
2
3
4
5
6
7
8
9
// Promise.withResolvers (ES2024): the "deferred" pattern without the closure dance.
const { promise, resolve, reject } = Promise.withResolvers();
socket.once('ready', resolve); socket.once('error', reject);
await promise;
// Promise.try (ES2025): run a function that may throw synchronously OR return a promise,
// and always get a promise back. Sync throws become rejections.
const p = Promise.try(() => JSON.parse(maybeBadInput));
withResolvers is the right tool when resolution happens in a different scope from creation: event emitters, request/response correlation by ID, test harnesses. If you find yourself storing resolve functions in a Map, it is also where leaks start. Every stored resolver needs a timeout or an abort path, or a lost reply keeps it (and everything it closes over) alive forever.
Chapter 2 — Unhandled rejections
A rejection is "unhandled" if no rejection reaction is attached by the time the host checks. Hosts check after the microtask queue drains, so "attached later" means "attached in a later task".
| Host | Behaviour |
|---|---|
| Browsers | Fire unhandledrejection on window (or the worker global). If a handler is attached later, fire rejectionhandled. The page keeps running. |
| Node 15+ | Default mode --unhandled-rejections=throw: emit process.on('unhandledRejection'); if nothing listens, raise it as an uncaught exception and exit the process. |
Where they come from
1
2
3
4
5
6
7
8
9
10
11
// 1. Start two operations, then await them one by one (fixed in Exercise 3).
const user = fetchUser(id), orders = fetchOrders(id);
const u = await user; // if `orders` rejects while we wait here, nobody is listening yet
const o = await orders;
// 2. Fire-and-forget calls.
button.onclick = () => { saveDraft(); }; // use saveDraft().catch(reportError)
// 3. `for await` over already-started promises.
for await (const r of [slow(), fastFailure()]) { … } // fastFailure rejects unobserved
Promises stored and never awaited (a cache entry, a "warm-up" call) are the fourth source.
Watch out:
process.on('unhandledRejection', () => {})"fixes" crashes by hiding bugs. If you install a handler in Node, log with full context and then decide: exit (usually right, under a supervisor) or keep going (only if you can prove the state is still sound).
Chapter 3 — What async/await compiles to
An async function is a generator driven by promises. Before engines shipped native support, transpilers emitted almost exactly this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function run(genFn, ...args) {
return new Promise((resolve, reject) => {
const gen = genFn(...args);
function step(method, arg) {
let r;
try { r = gen[method](arg); } // resume the body
catch (e) { return reject(e); } // a throw in the body rejects the result
if (r.done) return resolve(r.value); // `return x` fulfils (or adopts) x
Promise.resolve(r.value).then( // `await x` = yield x, resume in a microtask
(v) => step('next', v),
(e) => step('throw', e), // a rejection is thrown *at* the await
);
}
step('next');
});
}
// async function f(x) { const y = await g(x); return y + 1; } ≈
// const f = (x) => run(function* () { const y = yield g(x); return y + 1; });
Three consequences follow directly from this shape:
- The body runs synchronously until the first
await. Code before it is not deferred. A throw there still becomes a rejection, not a synchronous exception. - Every
awaitsuspends, even on a non-promise.await 42still resumes in a microtask. Its cost is small, but never zero. - The call stack is gone after the first
await. The resumed frame is called from the microtask checkpoint. V8 rebuilds "async stack traces" from the promise chain, which only works when the chain is made ofawaits inasyncfunctions.
What an await costs
The V8 team's 2018 work (and the spec change that followed) cut the cost of await on a native promise from at least three microtask ticks and two extra promises down to one tick in the common case. That makes micro-optimising await pointless in application code. What still costs real time:
- Sequential awaits that could be parallel.
await a(); await b();takesa + b.await Promise.all([a(), b()])takesmax(a, b). This one mistake explains a large share of slow API handlers. awaitinside hot loops over in-memory data. Each iteration becomes a microtask. Process synchronously, and onlyawaitat I/O boundaries.- Needless
asyncwrappers around functions that return a promise already. Each one adds a promise and a hop.
return promise vs return await promise
1
2
3
4
5
async function a() { try { return fetchThing(); } catch { return fallback(); } }
// a(): the try/catch does NOT see a rejection from fetchThing()
async function b() { try { return await fetchThing(); } catch { return fallback(); } }
// b(): the rejection is thrown at the await and caught
Inside try, return await changes behaviour; outside try it only changes timing, and it can even be slightly faster. Returning a promise from an async function resolves the outer promise with a thenable, and that adoption takes extra ticks. It also keeps the function in async stack traces. The old lint rule no-return-await was deprecated in ESLint for these reasons. typescript-eslint's return-await rule, with its default in-try-catch option, encodes the correct policy.
Chapter 4 — Cancellation with AbortSignal
JavaScript has no way to cancel a promise, and that is deliberate. A promise is a read-only view of a result, so cancelling the view does not stop the work. The platform's answer is a separate capability object: an AbortController owns an AbortSignal, and you pass the signal into every operation that should stop.
Terminology
AbortController— the writer side.controller.abort(reason)aborts its signal once. Later calls do nothing.AbortSignal— the reader side:aborted,reason,throwIfAborted(), and anabortevent.- Reason — any value. It defaults to a
DOMExceptionnamedAbortError.AbortSignal.timeout(ms)aborts with aDOMExceptionnamedTimeoutError. - Composite signal —
AbortSignal.any([s1, s2, …])aborts as soon as any input aborts, with that input's reason.
The pattern
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function loadDashboard({ signal }) {
// Cancel if the caller cancels OR if 8 s pass, whichever comes first.
const s = AbortSignal.any([signal, AbortSignal.timeout(8_000)]);
const [profile, feed] = await Promise.all([
fetch('/api/profile', { signal: s }).then((r) => r.json()),
fetch('/api/feed', { signal: s }).then((r) => r.json()),
]);
s.throwIfAborted(); // before the next expensive CPU step
return render(profile, feed);
}
const nav = new AbortController();
router.onLeave(() => nav.abort()); // user navigated away
await loadDashboard({ signal: nav.signal }); // err.name is 'AbortError' (user left) or 'TimeoutError'
This time both requests really stop: fetch closes the connection and rejects with signal.reason. Many APIs accept a signal directly: fetch, addEventListener (the listener is removed on abort), Node's timers/promises, events.once, fs.readFile, child_process, stream.pipeline, and most modern database drivers.
Making your own functions abortable
1
2
3
4
5
6
7
8
9
10
11
12
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const onAbort = () => { clearTimeout(timer); reject(signal.reason); };
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort); // do not leak the listener
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
Rules for a well-behaved abortable function:
- Check
signal.abortedon entry, since the signal may already be aborted. - Reject with
signal.reason, not with a new error, so that callers can tell a timeout from a user cancel. - Remove the
abortlistener on the success path. A long-lived signal (a whole page's lifetime, say) with thousands of leftover listeners is a real memory leak, and Node warns once a signal has more than the default maximum number of listeners. - Pass the signal down to everything you call.
Watch out: CPU-bound loops do not notice aborts by themselves. Call
signal.throwIfAborted()between chunks of work, and yield to the event loop between chunks (Post 01), or the abort event cannot even be delivered.
Trick: in React effects, create one
AbortControllerper effect run and callabort()in the cleanup. That single line prevents both the stale-response race and "state update on unmounted component" noise. The React Deep Dive series covers this in detail.
Chapter 5 — Async iterators and streams
Async iteration
An async iterable has a [Symbol.asyncIterator]() method whose next() returns a promise of { value, done }. for await…of consumes it and, on break, return or throw, calls the iterator's return(), which is where cleanup lives.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async function* paginate(url, { signal }) {
let next = url;
try {
while (next) {
const res = await fetch(next, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`, { cause: { url: next } });
const body = await res.json();
yield* body.items;
next = body.nextPageUrl;
}
} finally {
console.debug('paginate closed at', next); // also runs when the consumer breaks early
}
}
for await (const item of paginate('/api/items', { signal })) {
if (item.id === target) break; // triggers finally in the generator; no more fetches
}
Useful built-ins that speak this protocol: Node Readable streams, events.on(emitter, name, { signal }), fs.promises.opendir, web ReadableStream (Chrome 124+, Firefox 110+; Safari added it last, so check your support matrix), and Array.fromAsync (ES2026) to collect one into an array.
Common pitfall: async generators are pull-based and sequential.
paginatenever fetches page 2 until the consumer asks for more. That is great for memory, and wrong when you wanted prefetching. Add a bounded buffer explicitly if you need read-ahead.
WHATWG streams and backpressure
Streams add what iterators lack: a queue with a size limit and a signal back upstream when the queue is full. That signal is backpressure.
Terminology
- Queuing strategy — how chunks are measured:
CountQueuingStrategy(one per chunk) orByteLengthQueuingStrategy. highWaterMark— the queue size at which the stream reports "full".desiredSize—highWaterMark − queued size. Zero or negative means "stop producing".- Pull source — a source whose
pull()is called only whendesiredSize > 0. This is backpressure done for you.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// A transform: split text into lines, carrying the partial line between chunks.
function lines() {
let buf = '';
return new TransformStream({
transform(chunk, controller) {
buf += chunk;
const parts = buf.split('\n');
buf = parts.pop();
for (const p of parts) controller.enqueue(p);
},
flush(controller) { if (buf) controller.enqueue(buf); },
});
}
const res = await fetch('/export.ndjson', { signal });
const stream = res.body.pipeThrough(new TextDecoderStream()).pipeThrough(lines());
for await (const line of stream) handle(JSON.parse(line));
pipeTo and pipeThrough carry backpressure end to end: a slow writer makes the transform's queue fill, which stops reads from the network, which lets the TCP receive window close, which slows the sender. pipeTo(dest, { signal }) also takes a signal.
A source with a pull() method gets backpressure for free: the stream calls pull() only while desiredSize > 0. The failure mode is a push source that ignores desiredSize. For example, a start() that subscribes to a WebSocket and calls enqueue for every message. The queue then grows without bound. Either check controller.desiredSize and pause the upstream, or drop and count messages on purpose.
Node streams
Node's older stream API uses the same idea in a different form: writable.write(chunk) returns false when the buffer is over highWaterMark, and you must wait for 'drain'. Hand-written on('data') → write() loops that ignore the return value are the classic Node memory blow-up. Use pipeline:
1
2
3
4
5
6
7
8
9
10
11
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
await pipeline(
createReadStream('access.log'),
createGzip(),
createWriteStream('access.log.gz'),
{ signal }, // abort tears down every stage
);
pipeline propagates errors and destroys all stages on failure. .pipe() does neither, and it leaks file descriptors on error. Converting between the two worlds: Readable.toWeb(), Readable.fromWeb(), Writable.toWeb().
Chapter 6 — Concurrency limits
Promise.all(items.map(fetchOne)) with 10,000 items starts 10,000 requests at once. The results are socket exhaustion, rate-limit responses, a database connection pool drained in milliseconds, and memory for 10,000 pending responses. Bound it.
A worker pool with fail-fast cancellation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
async function mapLimit(items, limit, fn, { signal } = {}) {
const inner = new AbortController();
const s = signal ? AbortSignal.any([signal, inner.signal]) : inner.signal;
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
s.throwIfAborted();
const i = next++; // safe: single-threaded between awaits
results[i] = await fn(items[i], { signal: s });
}
}
try {
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
} catch (err) {
inner.abort(err); // stop the other workers' in-flight calls
throw err;
}
}
const pages = await mapLimit(urls, 8, (u, { signal }) => fetch(u, { signal }).then((r) => r.text()));
This pool keeps at most limit calls in flight, returns results in input order, and fails fast: the first error aborts the other workers' in-flight calls. If you need partial results instead, catch inside fn and return an { ok, value, error } record.
When tasks arrive over time (webhooks, UI events) rather than as one array, the same idea becomes a queue: keep an active counter, push { fn, resolve, reject } records (made with Promise.withResolvers()), and start the next record whenever one settles. Libraries such as p-limit do exactly this.
Watch out: a limiter with an unbounded queue only moves the memory problem somewhere else. Under sustained overload, reject new work when the queue passes a bound (load shedding), and return 429 or 503 to the caller. Never let the queue grow until the process dies.
Trick: pick the limit from the downstream resource, not from your CPU count. Examples: the DB pool size minus headroom, the vendor's documented rate limit, the number of sockets per host your HTTP agent allows.
Chapter 7 — Timeouts, retries and idempotency
Timeouts
Every network call needs a deadline, and the deadline must also cancel the work, which is why AbortSignal.timeout() beats Promise.race. In a multi-step operation, create one deadline per request, AbortSignal.any([req.signal, AbortSignal.timeout(2_000)]), and pass that same signal to every step. Each later step then gets whatever time is left, instead of its own full timeout.
Retries with exponential backoff and full jitter
Retrying immediately makes an overloaded dependency worse. Exponential backoff spaces the attempts out. Jitter stops a thousand clients that failed together from retrying together. The AWS Architecture Blog analysis (Marc Brooker, "Exponential Backoff and Jitter") found full jitter, sleep = random(0, min(cap, base × 2^attempt)), to be a strong default.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
function isRetryable(err) {
if (err.name === 'AbortError') return false; // the caller gave up: stop
if (err.status) return RETRYABLE_STATUS.has(err.status);
return err.name === 'TimeoutError' || err instanceof TypeError; // network failure in fetch
}
async function retry(fn, { retries = 4, base = 100, cap = 5_000, signal } = {}) {
for (let attempt = 0; ; attempt++) {
try {
return await fn({ attempt, signal });
} catch (err) {
if (attempt >= retries || !isRetryable(err) || signal?.aborted) {
throw new Error(`gave up after ${attempt + 1} attempt(s)`, { cause: err });
}
const ceiling = Math.min(cap, base * 2 ** attempt);
const hinted = retryAfterMs(err); // honour Retry-After if present
await sleep(hinted ?? Math.random() * ceiling, signal);
}
}
}
retryAfterMs(err) is a small helper that reads the Retry-After response header (seconds or an HTTP date) and returns undefined when there is none. With retries = 4, base = 100 and cap = 5_000, the call makes up to 5 attempts. The sleep ceilings before the four retries are 100, 200, 400 and 800 ms, so the worst-case total sleep is 1,500 ms. The actual sleep is uniform below each ceiling.
Retry discipline that matters more than the formula:
- Retry in one layer only. If the browser, the BFF and the service each make 3 attempts (1 try + 2 retries), one user click can become 3 × 3 × 3 = 27 calls at the bottom layer, exactly when that layer is struggling.
- Use a retry budget. For example, allow retries only while they are under 10% of recent requests. A circuit breaker that fails fast while the dependency is down is the same idea taken further.
- Only retry idempotent operations, or make them idempotent first.
Idempotency
An operation is idempotent if doing it twice has the same effect as doing it once. HTTP defines GET, HEAD, PUT and DELETE as idempotent (RFC 9110). POST is not. That matters because a timeout does not tell you whether the server did the work: the request may have succeeded and only the response was lost.
The standard fix is an idempotency key. The client generates a unique key per logical operation and sends it on every attempt. The server records the key with the result and returns the stored result for repeats. The pattern was popularised by Stripe's API and is being standardised as the Idempotency-Key header (an IETF httpapi working-group draft).
1
2
3
4
5
6
7
async function createPayment(order, { signal }) {
const key = crypto.randomUUID(); // once per logical operation, NOT per attempt
const headers = { 'content-type': 'application/json', 'idempotency-key': key };
return retry(({ signal }) => fetch('/api/payments',
{ method: 'POST', signal, headers, body: JSON.stringify(order) }).then(assertOk), { signal });
}
Server side, the key needs a unique constraint and a "claim first, then do the work" order. Otherwise two concurrent attempts both see "no record yet" and both charge the card.
Common pitfall: generating the key inside the retried function. Every attempt gets a fresh key, so the protection disappears exactly when it is needed.
Chapter 8 — Errors that keep their cause
Async code crosses many layers, and each layer that catches and re-throws tends to lose information. Since ES2022 every Error constructor takes a cause:
1
2
3
try { await db.query(sql); }
catch (err) { throw new Error(`loading invoice ${id} failed`, { cause: err }); }
Node's util.inspect (and so console.log) prints the whole chain with [cause]: markers, and browser consoles show it too. When you serialise a chain for logs yourself, walk e.cause with a depth limit, because a chain can contain a cycle.
Related tools:
AggregateError(ES2021): several errors at once, with.errors.Promise.anyuses it. Use it yourself for "3 of 5 replicas failed".Error.isError(ES2026): a cross-realm brand check. It is true for errors from iframes orvmcontexts, whereinstanceof Erroris false.- Custom error classes with a stable
nameand acodefield makeisRetryableand alerting rules simple and robust. Matching on message strings is fragile.
1
2
3
4
5
6
7
8
9
10
class HttpError extends Error {
constructor(res, options) {
super(`HTTP ${res.status} ${res.statusText} for ${res.url}`, options);
this.name = 'HttpError';
this.status = res.status;
this.headers = res.headers; // lets retryAfterMs read Retry-After
}
}
const assertOk = (res) => { if (!res.ok) throw new HttpError(res); return res; };
Watch out:
causecan carry secrets. A wrapped database error may include the SQL and its parameters. Serialise chains deliberately at the API boundary, and send the full chain only to internal logs.
Practice Exercises
Exercise 1 — Predict the order
What does this print?
1
2
3
4
5
6
7
console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
queueMicrotask(() => console.log(4));
(async () => { console.log(5); await null; console.log(6); })();
console.log(7);
Solution
1 5 7 3 4 6 2. The async function body runs synchronously up to the await, so 5 comes before 7. The microtasks run in the order they were queued: the then (3), queueMicrotask (4), then the await continuation (6). The timer callback (2) is a task, so it runs after the microtask queue drains.
Exercise 2 — Combinator choice
For each case pick a combinator: (a) load five widgets and show whichever succeed; (b) query three mirrors and use the first good answer; (c) a checkout that needs price, stock and tax, all or nothing.
Solution
(a) Promise.allSettled, then render the fulfilled ones. (b) Promise.any. It ignores mirrors that fail and only rejects (with AggregateError) if all fail. Pair it with an AbortController so that the losers are cancelled once a winner arrives. (c) Promise.all with a shared signal, aborted in the catch, so that the other two stop once one fails.
Exercise 3 — Fix the crash
This Node handler crashes the process on some requests. Why? Fix it.
1
2
3
4
5
6
app.get('/me', async (req, res) => {
const profile = getProfile(req.user);
const prefs = getPrefs(req.user);
res.json({ profile: await profile, prefs: await prefs });
});
Solution
If getPrefs rejects while the handler is still awaiting profile, that rejection has no handler yet. Node's default throw mode turns it into an uncaught exception. Fix: const [profile, prefs] = await Promise.all([getProfile(req.user), getPrefs(req.user)]);. Also make sure your framework forwards async errors to its error middleware (Express 5 does; Express 4 needs a wrapper).
Exercise 4 — Abortable wait-for-event
Write waitFor(target, type, { signal, timeout }) that resolves with the first event of type, rejects with the right reason on abort or timeout, and leaves no listeners behind.
Solution
1
2
3
4
5
6
7
8
9
10
11
12
13
function waitFor(target, type, { signal, timeout } = {}) {
const signals = [signal, timeout != null && AbortSignal.timeout(timeout)].filter(Boolean);
const s = signals.length ? AbortSignal.any(signals) : undefined;
return new Promise((resolve, reject) => {
if (s?.aborted) return reject(s.reason);
const cleanup = new AbortController(); // removes both listeners at once
target.addEventListener(type, (e) => { cleanup.abort(); resolve(e); },
{ once: true, signal: cleanup.signal });
s?.addEventListener('abort', () => { cleanup.abort(); reject(s.reason); },
{ once: true, signal: cleanup.signal });
});
}
Passing signal to addEventListener means one cleanup.abort() removes every listener on every path.
Exercise 5 — Backoff arithmetic
With full jitter, base = 200 ms, cap = 3,000 ms and 5 retries, list the ceilings and the worst-case total sleep.
Solution
Ceilings min(3000, 200 × 2^n) for n = 0…4: 200, 400, 800, 1,600, 3,000 (3,200 capped). Worst-case total = 200 + 400 + 800 + 1,600 + 3,000 = 6,000 ms. The expected total is half of that, 3,000 ms, because each sleep is uniform on [0, ceiling). This total must fit inside the caller's own deadline, or the retries are wasted.
Cheat-sheet
| Need | Use |
|---|---|
| All must succeed | Promise.all + shared AbortController, abort in catch |
| First success wins | Promise.any (rejects with AggregateError) |
| First settle wins | Promise.race, never on its own as a timeout |
| Timeout that cancels | AbortSignal.timeout(ms) |
| Combine caller cancel + timeout | AbortSignal.any([signal, AbortSignal.timeout(ms)]) |
| Byte/text pipeline with backpressure | body.pipeThrough(new TextDecoderStream()).pipeThrough(t) |
| Bound fan-out | worker pool / limiter sized to the downstream resource |
| Retry delay | full jitter: random(0, min(cap, base × 2^n)), honour Retry-After |
| Safe retries of POST | Idempotency-Key generated once per logical operation |
What's next
Post 04 moves from runtime behaviour to code organisation: ESM vs CommonJS semantics (live bindings, top-level await and what it does to module graphs, cycles), package.json exports/imports and conditions, dual packages and their hazards, require(esm) in current Node, import maps, tree-shaking, today's bundlers, and TypeScript at scale. That covers strict flags, module resolution, declaration files, project references, and Node's built-in type stripping.
References
- ECMAScript® Language Specification — Promise objects — the state machine, jobs and combinators.
- MDN — Using promises and Promise.
- V8 — Faster async functions and promises — the await optimisation and zero-cost async stack traces.
- MDN — AbortSignal —
timeout(),any(),throwIfAborted(). - DOM Standard — Aborting ongoing activities — the guidance for API authors that Chapter 4 follows.
- Streams Standard and MDN — Streams API concepts — queuing strategies and backpressure.
- Node.js — Stream and Process: unhandled rejections.
- V8 — Explicit resource management.
- AWS Architecture Blog — Exponential Backoff and Jitter.
- RFC 9110 — HTTP Semantics, idempotent methods and IETF draft — The Idempotency-Key HTTP Header Field.
- MDN — Error: cause.










