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 — the original async tool#
A callback is a function you pass so it runs when the async work finishes. Node's convention is error-first: the callback's first argument is the error (or null).
// error-first callback convention
fs.readFile("f.txt", "utf8", (err, data) => {
if (err) return handle(err); // handle the error branch first
console.log(data);
});
Callback hell (the pyramid of doom) — chaining dependent async steps nests deeper and deeper:
getUser(id, (err, u) => {
if (err) return handle(err);
getOrders(u, (err, o) => {
if (err) return handle(err);
getDetails(o, (err, d) => { // 😱 nesting + repeated error handling
if (err) return handle(err);
console.log(d);
});
});
});
Three problems: deep nesting, error handling repeated at every level, and inversion of control — you trust the callee to call your callback correctly, exactly once. Promises fix all three.
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.
Callback → Promise → async/await (same flow, 3 styles) 🎯#
The exact same "get a user, then their orders" flow, evolving:
// 1) Callbacks — nested, error handled at each level
getUser(id, (err, user) => {
if (err) return handle(err);
getOrders(user, (err, orders) => {
if (err) return handle(err);
render(orders);
});
});
// 2) Promises — a flat chain, one .catch for the whole thing
getUser(id)
.then(user => getOrders(user)) // return a promise → the chain waits
.then(render)
.catch(handle);
// 3) async/await — reads top-to-bottom, try/catch for errors
async function show(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user);
render(orders);
} catch (err) {
handle(err);
}
}
Same dependency (orders need the user first) in all three — Promises flatten the nesting, async/await makes it read like sync code.
Converting a callback API into a Promise (promisify)#
const readFileP = (path) => new Promise((resolve, reject) => {
fs.readFile(path, "utf8", (err, data) => err ? reject(err) : resolve(data));
});
await readFileP("f.txt"); // now awaitable
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()]);
⚠️ await inside a for loop is sequential (each iteration waits for the last). Correct when steps depend on each other; to parallelize independent work, collect promises then await Promise.all:
for (const id of ids) { await save(id); } // sequential (dependent / throttled)
await Promise.all(ids.map(id => save(id))); // parallel (independent)
const results = await Promise.allSettled(ids.map(save)); // parallel, get every outcome
⚠️ await inside .forEach does nothing — forEach ignores the returned promise. Use for...of or map + Promise.all.
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 Q&A#
Q1. Predict the output and explain micro vs macro.
console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// → 1, 4, 3, 2
Sync runs first (1, 4); the stack empties; the entire microtask queue drains (3, the .then); then one macrotask runs (2, the timer). Microtasks always beat macrotasks.
Q2. What are the promise states? Are they reversible?
pending → fulfilled or rejected. Settling is one-way and permanent — a settled promise never changes state or value again.
Q3. Promise.all vs allSettled vs race vs any?
all — waits for all, rejects on the first failure (fail-fast), returns an array of values. allSettled — waits for all, never rejects, returns {status, value/reason} for each. race — settles as the first promise settles (fulfil or reject). any — resolves on the first fulfilment, rejects only if all reject (AggregateError).
Q4. Sequential vs parallel await — when is each correct?
Sequential (await a; await b;) when each step depends on the previous. Parallel (await Promise.all([a(), b()])) when the calls are independent — it's the single most common perf fix.
Q5. Does fetch reject on a 404? How do you time out a fetch?
No — fetch only rejects on a network error, not on 4xx/5xx. Check res.ok (or res.status) yourself. Timeout with an AbortController: setTimeout(() => controller.abort(), ms) and pass signal to fetch.
Q6. Why doesn't await inside forEach work?
forEach ignores the promise its callback returns, so it doesn't pause between iterations. Use for...of with await for sequential, or Promise.all(arr.map(...)) for parallel.
Q7. What's the difference between a callback and a Promise?
A callback is a function the callee invokes when done (you give up control — "inversion of control"). A Promise is a value you own representing the future result; you attach handlers (.then/.catch) to it, chain flatly, and get unified error handling. async/await is syntax over Promises.
Q8. Is setTimeout(fn, 0) really 0 ms?
No — it schedules fn as a macrotask to run after the current task and all microtasks, with a minimum ~4 ms clamp for nested timers. Any pending .then callbacks run before it.
Q9. What does an async function return? What does await do to the thread?
An async function always returns a Promise (a plain return value is wrapped, a throw becomes a rejection). await pauses only that function until the awaited promise settles — it does not block the main thread.
Practice: implement promisify(fn); implement Promise.all from scratch; build a retry-with-backoff wrapper around fetch.