GUIDE · JavaScript
Async & Promises
At a glance
- COMBINATORS
- Promise.all (fail-fast), allSettled (never rejects), race (first settled), any (first fulfilled; AggregateError if all reject)
- ORDER
- sync code -> microtasks (promise callbacks, queueMicrotask, process.nextTick first in Node) -> macrotasks (setTimeout, I/O)
- TRAPS
- forEach(async) doesn't await; await in a loop = sequential; forgetting return in .then; unhandled rejection; async fn always returns a Promise; try/catch only catches awaited rejections
- SAY
- microtask queuefail-fastsequential vs concurrentconcurrency limit / poolbackoff with jitteridempotent retryAbortController for cancellation/timeouts
The idea#
A Promise is the ticket they hand you at a restaurant. You do not hold the food yet. The ticket is pending. Eventually the kitchen resolves it with your plate, or rejects it with a sorry-we're-out. then is what you do when the plate arrives. catch is what you do with the sorry. finally buses the table either way.
An async function always hands you a ticket, even when the body returns a plain number. await means you stand at this table until this ticket settles, then keep going inside this function. The rest of the program keeps seating other tables. await pauses the function, not the whole kitchen.
JavaScript is one cook, plus a board of tickets. Synchronous code is the sentence the cook is already saying. It finishes first. Then come the sticky notes, the microtask queue. In Node, process.nextTick runs, then promise reactions and queueMicrotask. Then one macrotask, such as a setTimeout. The sheet records sync, nextTick, micro, timeout.
Combinators wait on a group of tickets. Promise.all holds the table until every plate is up, and one rejection sends the whole order back. That is fail-fast. A non-promise in the list passes through as itself. Promise.allSettled never rejects the group. Each ticket comes back fulfilled or rejected, with a value or a reason. Promise.race settles with whichever ticket finishes first, food or sorry. Promise.any wants the first success. If every ticket rejects, you get an AggregateError.
Why it matters#
The trap is waiting in the wrong place. await inside a for...of is one course after another. Three 10ms tickets take about 30ms. Promise.all on a map fires them together, about 10ms. arr.forEach(async x => await f(x)) does not wait. The loop ends, the calls run as fire-and-forget, and the next line has already moved on. Use for...of for a sequence. Use Promise.all when the work may overlap.
A concurrency limit is a stove with N burners. mapLimit starts that many workers. Each one grabs the next index, awaits, and grabs another. The sheet's peak in-flight count stays at 2.
try/catch catches a rejection you awaited. The same try around a bare call does not, and that rejection goes unhandled. Forgetting return inside a .then hands the next ticket undefined. Returning a promise unwraps it. Retry asks the kitchen again, waiting baseMs * factor ** attempt times a random half-to-one factor so a crowd does not retry on the same instant. That factor is jitter. A timeout is a second ticket that rejects, raced against the real one. AbortController cancels with the reason you passed to abort.
Say microtask queue, fail-fast, sequential versus concurrent, a concurrency pool, backoff with jitter, an idempotent retry, and AbortController.
A small example#
const ticket = (value, ms) => new Promise((resolve) => { setTimeout(() => resolve(value), ms); }); async function lunch() { const first = await ticket('soup', 5); const both = await Promise.all([ ticket('soup', 5), ticket('bread', 5), ]); return [first, both[0], both[1]];} const order = lunch();order is a ticket for ['soup', 'soup', 'bread']. first is one course, waited on alone. both is two tickets fired together. The function still returns a Promise, not the array itself.
What the code below is doing#
tick and fail are tickets that settle on a timer. The body is one async function so the checks can await.
Event-loop order schedules a timeout, a promise then, and process.nextTick, pushes 'sync' immediately, then waits. The list is sync, nextTick, micro, timeout.
Promise.all accepts a plain 3. One failing ticket rejects the whole all. allSettled reports fulfilled then rejected, and the reason message is 'x'. race returns 'fast'. any returns 'ok' when one side fails, and rejects with AggregateError when both fail.
Three awaits in a loop take longer than one Promise.all of the same three delays. The comment names the forEach(async ...) trap: it does not wait.
mapLimit runs five doubling jobs with a limit of 2. The output is [2, 4, 6, 8, 10], and the peak in flight is 2.
mayThrow returns 'fine' or throws 'nope'. await inside try catches it. .catch on the call turns it into 'caught nope'. A .then with a second function takes the rejection path. finally still runs after a fulfilled tick. The other trap is in the comment: try { mayThrow(true) } without await does not catch.
Retry loops up to attempts. Each failure waits a jittered backoff, then the last error is thrown if nothing succeeded. A flaky function works on the third try. An always-throwing one still rejects.
withTimeout races the real ticket against a rejecting timer and clears that timer in finally. The fast value wins. The slow one loses to 'timeout'. An AbortController listener clears its timer and rejects with the abort reason. fetch and AbortSignal.timeout use that same signal.
A callback API is wrapped so an error rejects and a value resolves. A deferred is { promise, resolve }. Node 22 and later has Promise.withResolvers. The sheet falls back to building the pair by hand, resolves 7, and awaits it.
A .then that computes v + 1 and does not return it yields undefined. A .then that returns a promise flattens to 2. An async function that returns 1 is still a Promise.
An async generator yields pages 1, 2, and 3. for await collects [1, 2, 3].
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict'); const tick = (v, ms = 0) => new Promise(res => setTimeout(() => res(v), ms));const fail = (msg, ms = 0) => new Promise((_, rej) => setTimeout(() => rej(new Error(msg)), ms)); (async () => { // ── 1. Event loop order — the classic "what prints?" const order = []; setTimeout(() => order.push('timeout'), 0); Promise.resolve().then(() => order.push('micro')); process.nextTick(() => order.push('nextTick')); order.push('sync'); await tick(null, 5); assert.deepEqual(order, ['sync', 'nextTick', 'micro', 'timeout']); // ── 2. Combinators assert.deepEqual(await Promise.all([tick(1), tick(2), 3]), [1, 2, 3]); // non-promises pass through await assert.rejects(Promise.all([tick(1), fail('boom')]), /boom/); // fail-fast const settled = await Promise.allSettled([tick(1), fail('x')]); assert.deepEqual(settled.map(s => s.status), ['fulfilled', 'rejected']); assert.equal(settled[1].reason.message, 'x'); assert.equal(await Promise.race([tick('slow', 20), tick('fast', 1)]), 'fast'); assert.equal(await Promise.any([fail('a'), tick('ok', 1)]), 'ok'); await assert.rejects(Promise.any([fail('a'), fail('b')]), AggregateError); // ── 3. Sequential vs concurrent const t0 = Date.now(); for (const ms of [10, 10, 10]) await tick(null, ms); // ~30ms, one after another const seq = Date.now() - t0; const t1 = Date.now(); await Promise.all([10, 10, 10].map(ms => tick(null, ms))); // ~10ms, all at once const par = Date.now() - t1; assert.equal(seq > par, true); // forEach(async ...) does NOT wait: arr.forEach(async x => await f(x)) -> fire-and-forget. Use for..of or Promise.all(map). // ── 4. Concurrency limit (pool of N workers) — asked constantly async function mapLimit(items, limit, fn) { const results = new Array(items.length); let next = 0; async function worker() { while (next < items.length) { const i = next++; results[i] = await fn(items[i], i); } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); return results; } let inFlight = 0, peak = 0; const out = await mapLimit([1, 2, 3, 4, 5], 2, async x => { peak = Math.max(peak, ++inFlight); await tick(null, 2); inFlight--; return x * 2; }); assert.deepEqual(out, [2, 4, 6, 8, 10]); assert.equal(peak, 2); // ── 5. Error handling shapes async function mayThrow(bad) { if (bad) throw new Error('nope'); return 'fine'; } assert.equal(await mayThrow(false), 'fine'); try { await mayThrow(true); assert.fail('unreachable'); } catch (e) { assert.equal(e.message, 'nope'); } const safe = await mayThrow(true).catch(e => `caught ${e.message}`); assert.equal(safe, 'caught nope'); // Without await, try/catch does NOT catch: try { mayThrow(true) } catch {} -> unhandled rejection. const r = await Promise.reject(new Error('z')).then(() => 'no', () => 'yes'); // second arg = onRejected assert.equal(r, 'yes'); let finallyRan = false; await tick(1).finally(() => { finallyRan = true; }); assert.equal(finallyRan, true); // ── 6. Retry with exponential backoff + jitter async function retry(fn, { attempts = 3, baseMs = 1, factor = 2 } = {}) { let lastErr; for (let i = 0; i < attempts; i++) { try { return await fn(i); } catch (e) { lastErr = e; const delay = baseMs * factor ** i * (0.5 + Math.random() / 2); // jitter await tick(null, delay); } } throw lastErr; } let tries = 0; assert.equal(await retry(async () => { if (++tries < 3) throw new Error('flaky'); return 'ok'; }), 'ok'); assert.equal(tries, 3); await assert.rejects(retry(async () => { throw new Error('always'); }, { attempts: 2 }), /always/); // ── 7. Timeout / cancellation function withTimeout(promise, ms) { let timer; const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('timeout')), ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } assert.equal(await withTimeout(tick('v', 1), 50), 'v'); await assert.rejects(withTimeout(tick('v', 50), 1), /timeout/); const ac = new AbortController(); const abortable = new Promise((res, rej) => { const t = setTimeout(() => res('done'), 50); ac.signal.addEventListener('abort', () => { clearTimeout(t); rej(ac.signal.reason); }, { once: true }); }); ac.abort(new Error('cancelled')); await assert.rejects(abortable, /cancelled/); // fetch(url, { signal: ac.signal }) and AbortSignal.timeout(ms) use the same mechanism. // ── 8. Promisify a callback API / build a promise by hand / deferred const cbStyle = (x, cb) => setTimeout(() => (x < 0 ? cb(new Error('neg')) : cb(null, x * 2)), 0); const promisified = x => new Promise((res, rej) => cbStyle(x, (err, v) => (err ? rej(err) : res(v)))); assert.equal(await promisified(2), 4); await assert.rejects(promisified(-1), /neg/); const { promise, resolve } = Promise.withResolvers?.() ?? (() => { let resolve; const promise = new Promise(r => (resolve = r)); return { promise, resolve }; })(); resolve(7); assert.equal(await promise, 7); // Node 22+ has withResolvers // ── 9. Chaining gotchas const chained = await Promise.resolve(1).then(v => v + 1).then(v => { v + 1; }).then(v => v); assert.equal(chained, undefined); // forgot to return -> undefined const flattened = await Promise.resolve(1).then(v => Promise.resolve(v + 1)); // returned promise is unwrapped assert.equal(flattened, 2); async function returnsPromise() { return 1; } assert.equal(returnsPromise() instanceof Promise, true); // ── 10. Async iteration / generators async function* pages() { for (let p = 1; p <= 3; p++) { await tick(null, 1); yield p; } } const got = []; for await (const p of pages()) got.push(p); assert.deepEqual(got, [1, 2, 3]); console.log('ok: javascript/async-promises.js');})().catch(e => { console.error(e); process.exitCode = 1; });See also