Part 16 — Asynchronous JavaScript
Why async exists, The event loop, precisely 🎯, Callbacks & callback hell, Promises, async / await
This is the chapter that separates juniors from seniors. Read it twice.
Why async exists#
JS is single-threaded. If a network call blocked the thread, the page would freeze. So slow work is offloaded to the environment (browser/Node), and JS is notified when it's done via the event loop.
The event loop, precisely 🎯#
graph TD
CS["Call Stack<br/>(runs synchronous code)"] -->|calls| WA["Web/Node APIs<br/>timers, fetch, I/O"]
WA -->|timer fires / IO done| MAC["Macrotask Queue<br/>(setTimeout, setInterval, I/O, UI events)"]
WA -->|promise resolves| MIC["Microtask Queue<br/>(.then, await, queueMicrotask)"]
EL{"Event Loop"}
EL -->|"1. stack empty?"| EL
EL -->|"2. drain ALL microtasks"| MIC
EL -->|"3. run ONE macrotask"| MAC
MIC --> CS
MAC --> CS
The algorithm: run all synchronous code (stack empties) → drain the entire microtask queue → render (in browsers) → take one macrotask → repeat. Microtasks always beat macrotasks and even starve them if they keep enqueuing.
console.log(1);
setTimeout(() => console.log(2), 0); // macrotask
Promise.resolve().then(() => console.log(3)); // microtask
console.log(4);
// Output: 1, 4, 3, 2 🎯
// sync (1,4) → microtask (3) → macrotask (2)
- Microtasks: promise callbacks (
.then/.catch/.finally),awaitcontinuations,queueMicrotask,MutationObserver. - Macrotasks:
setTimeout,setInterval,setImmediate(Node), I/O, UI events,MessageChannel.
Callbacks & callback hell#
getUser(id, (u) => {
getOrders(u, (o) => {
getDetails(o, (d) => { // 😱 the pyramid of doom
console.log(d);
});
});
});
Problems: nesting, error handling at every level, inversion of control (you trust the callee to call your callback correctly, once).
Promises#
A Promise is an object representing a future value in one of three states: pending → fulfilled | rejected (settling is one-way and permanent).
const p = new Promise((resolve, reject) => {
setTimeout(() => resolve("done"), 100);
});
p.then(v => console.log(v)) // "done"
.catch(e => console.error(e))
.finally(() => console.log("cleanup"));
Chaining — each .then returns a new promise; returning a value passes it on, returning a promise waits for it:
fetch(url)
.then(r => r.json()) // returns a promise → chain waits
.then(data => data.items)
.then(items => render(items))
.catch(handle); // catches any rejection in the chain
⚠️ Common promise traps 🎯:
- Not returning inside
.thenbreaks the chain (.then(x => { doAsync(x); })doesn't wait). - A missing
.catch→ unhandled rejection. .then(fn)wherefnthrows → next.catchhandles it.
async / await#
Syntactic sugar over promises. async functions always return a promise; await pauses the function until the awaited promise settles (without blocking the thread).
async function load() {
try {
const r = await fetch(url);
if (!r.ok) throw new Error(r.status);
const data = await r.json();
return data;
} catch (e) {
console.error(e);
throw e; // rethrow to let callers handle
}
}
Sequential vs parallel — the single most common performance mistake 🎯:
// ❌ sequential — waits 3x
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();
// ✅ parallel — waits 1x (they're independent)
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
Promise combinators#
| Method | Resolves when | Rejects when | Result |
|---|---|---|---|
Promise.all |
all fulfill | any rejects | array of values (fail-fast) |
Promise.allSettled |
all settle | never | array of {status, value/reason} |
Promise.race |
first settles | first settles rejected | that value/reason |
Promise.any |
first fulfills | all reject | value / AggregateError |
await Promise.allSettled(tasks); // when you want every result, success or fail
await Promise.any([mirror1, mirror2]); // first success wins
Promise.withResolvers() 🟢 (ES2024) returns { promise, resolve, reject } — handy for deferred patterns.
Timers & scheduling#
const id = setTimeout(fn, 1000); clearTimeout(id);
const iv = setInterval(fn, 1000); clearInterval(iv);
queueMicrotask(fn); // schedule a microtask directly
requestAnimationFrame(fn); // before next paint (~60fps), browser only
⚠️ setTimeout(fn, 0) is not 0ms — it's "after the current task and any queued work, min ~4ms clamp for nested timers."
fetch & AbortController#
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 5000); // timeout
try {
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`); // ⚠️ fetch only rejects on network error, not 404/500
const data = await res.json();
} catch (e) {
if (e.name === "AbortError") console.log("cancelled");
} finally { clearTimeout(t); }
for await...of (async iteration)#
for await (const chunk of streamReader) {
process(chunk); // handles async iterables / streams sequentially
}
Interview questions (Part 16):
- Predict the log order of the
console.log(1); setTimeout; Promise.then; console.log(4)snippet and explain micro vs macro. - What are the promise states and are they reversible?
Promise.allvsallSettledvsracevsany?- Show sequential vs parallel
awaitand when each is correct. - Does
fetchreject on a 404? How do you time out a fetch? - Why does
awaitinsideforEachnot work?
Practice:
- Implement
promisify(fn)converting a callback-style function to a promise. - Implement
Promise.allfrom scratch. - Build a retry-with-backoff wrapper around
fetch.