Appearance
3.6.8 — The Event Loop & Async
3.6.1 ended on a constraint that should have alarmed you: JavaScript has one call stack and runs one thing at a time. Yet a web page fetches data from a server, animates, responds to clicks, and runs timers — apparently all at once. And Node.js, built on this same single-threaded language, handles tens of thousands of simultaneous connections (2.7). How can a single-threaded language be good at concurrency?
The answer is the event loop, and it is the single most important thing to understand about JavaScript — the subject of more interview questions than any other topic in the language. This page builds it completely: why blocking is fatal, how asynchronous work escapes the single thread, the precise ordering rules of microtasks and macrotasks (which is where most people's understanding breaks down), how promises chain and propagate errors, how async/await desugars onto all of it, and why response.json() is asynchronous. (3.6.9 then opens the engine itself; Node's phase-by-phase loop lives in Chapter 3.8.1.)
1. The problem: one thread, and blocking is fatal
Everything JavaScript executes runs on one call stack (3.6.1). If a piece of code takes three seconds, the stack is occupied for three seconds, and nothing else can run — no clicks are handled, no rendering happens, the page is frozen. This is what "blocking the main thread" means, and in a browser it is immediately visible as a hung tab.
So how does fetch("/api/data") — which waits perhaps 200 milliseconds for a network round trip (1.6) — not freeze the page for 200 ms? Because JavaScript doesn't do the waiting. This is the crucial insight, and it dissolves most confusion about async:
The JavaScript engine is single-threaded, but the environment it runs inside — the browser, or Node.js — is not. The engine hands slow work (network requests, timers, file reads) to the surrounding platform, which performs it elsewhere (using OS-level non-blocking I/O and epoll-style readiness notification from 2.7, and real OS threads where needed), and later delivers the result back to JavaScript as a callback to run.
So the engine never waits — it registers interest and moves on. The event loop is the mechanism that decides when those results get to run.
2. The event loop, precisely
The runtime has four pieces, and the loop's rule connecting them is a single sentence.
- The call stack — where JavaScript executes, one frame at a time (3.6.1).
- The Web APIs / platform APIs — the environment's capabilities outside the engine (timers, network, file system, DOM events). This is where the actual waiting happens, on other threads.
- The macrotask queue (also called the task or callback queue) — completed callbacks waiting to run: timer callbacks, I/O completions, UI events.
- The microtask queue — a separate, higher-priority queue used by promises.
The event loop itself is a simple, endless supervisor with one rule:
When the call stack is empty, first drain the entire microtask queue, then take one task from the macrotask queue and run it. Repeat forever.
That asymmetry — all microtasks versus one macrotask — is the detail interviews probe, so let's make it concrete:
javascript
console.log("1");
setTimeout(() => console.log("2"), 0); // macrotask
Promise.resolve().then(() => console.log("3")); // microtask
console.log("4");
// Output: 1, 4, 3, 2Trace it: 1 and 4 are synchronous — they run immediately on the stack. setTimeout(..., 0) doesn't run after zero milliseconds; it queues a macrotask. The promise callback queues a microtask. When the synchronous code finishes and the stack empties, the loop drains microtasks first → 3. Only then does it take one macrotask → 2. A promise callback always beats a setTimeout(…, 0), no matter the order they were scheduled.
One important consequence: because the loop drains the entire microtask queue before anything else, a microtask that schedules another microtask, repeatedly, will starve the macrotask queue — the page will never render or handle a click again. Microtasks jump the queue by design, which is powerful and abusable.
3. Promises and async/await
Early asynchronous JavaScript used callbacks — pass a function to be called when the work finishes. It worked, but nesting several sequential operations produced the notorious callback hell (deeply indented pyramids where error handling had to be repeated at every level and the logic's order was hard to follow).
A Promise is an object representing a value that isn't available yet — a placeholder for a future result. It has exactly three states: pending (still working), fulfilled (succeeded, with a value), or rejected (failed, with a reason). Once it settles (fulfilled or rejected) it is immutable — it can never change again. You attach continuations with .then() (on success), .catch() (on failure), and .finally(). The decisive improvement over callbacks is that promises chain flatly rather than nesting, and a single .catch() handles errors for an entire chain.
async/await is then syntactic sugar over promises that makes asynchronous code read like synchronous code:
javascript
async function loadUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
return user;
} catch (err) {
// catches failures from either await
}
}Two rules capture how it works. An async function always returns a promise — whatever you return inside it becomes the fulfilment value (so return user yields a promise resolving to user, not user itself; callers must await it or use .then()). And await pauses the function until its promise settles — without blocking the thread: under the hood the rest of the function is registered as a microtask continuation, the function yields control back to the event loop, and it resumes later when the promise settles. That's why await gives you sequential-looking code with none of the freezing.
Four combinators are worth knowing precisely, because choosing wrongly is a common bug:
Promise.all([...])— waits for all to fulfil; rejects immediately if any rejects (fail-fast). Use when you need every result.Promise.allSettled([...])— waits for all to settle, never rejects; returns each outcome. Use when partial failure is acceptable.Promise.race([...])— settles with the first to settle, success or failure. Use for timeouts.Promise.any([...])— settles with the first to fulfil, ignoring rejections unless all fail. Use for redundant sources.
Chaining mechanics: what .then returns
.then() always returns a new promise, and three rules govern what that promise does — they're the entire algebra of chains:
javascript
fetch("/api/user")
.then((res) => res.json()) // (1) return a PROMISE → chain waits for it,
// then adopts its value ("flattening")
.then((user) => user.name) // (2) return a plain VALUE → next .then gets it
.then((name) => { throw new Error("!"); }) // (3) THROW → next .catch gets it
.catch((err) => console.error(err)); // one catch covers the whole chain(1) Returning a promise makes the chain wait and adopt — this auto-flattening is why chains stay flat instead of nesting (Promise<Promise<T>> never reaches you). (2) Returning a value forwards it. (3) Throwing (or returning a rejected promise) switches the chain onto the rejection track, skipping every .then until a .catch — which handles the error and puts the chain back on the success track. Errors propagate down chains exactly like exceptions propagate up stacks.
When nothing catches: a rejection with no handler becomes an unhandled rejection — browsers fire the unhandledrejection event, and Node (since v15) crashes the process by default. The everyday version of this bug is the forgotten await: doAsyncThing(); without await/.catch creates a floating promise whose failure is nobody's business — which is why lint rules (no-floating-promises) treat it as an error. Rule: every promise ends in an await, a .catch, or a deliberate, commented fire-and-forget.
await, desugared
Two rules capture async/await completely — and then one line of truth behind them: an async function is a chain generator. This:
javascript
async function load() {
const res = await fetch("/api");
const data = await res.json();
return data.items;
}is precisely this, mechanically:
javascript
function load() {
return fetch("/api")
.then((res) => res.json())
.then((data) => data.items);
}Each await splits the function at that point: everything after it becomes a .then continuation — scheduled as a microtask when the promise settles — and the function returns to the event loop immediately. (Historically this desugaring ran on generator machinery — yielding promises to a driver, 3.6.6 — before engines made it native.) This explains, with no memorization: why await never blocks the thread (the function suspends; the loop continues), why code after await runs with microtask priority, why try/catch works around await (rejection resumes the continuation by throwing at that point), and why sequential awaits in a loop serialize while Promise.all runs concurrently — the drill at the end makes that one concrete.
Why response.json() is asynchronous
Here's a genuine everyday puzzle. fetch() returning a promise is obvious — it's a network request. But why must you await response.json() as well? Surely the data has arrived?
It hasn't — not all of it. fetch's promise resolves as soon as the response headers have arrived, which is deliberately early: it lets you inspect the status code and headers, and decide whether to continue, before downloading a potentially huge body. At that moment the body is still streaming in over the network (2.7). So response.json() must (a) wait for the remaining body chunks to arrive and (b) parse the complete text into an object — both of which take unknown time. Making it asynchronous is therefore correct on both counts: it can't return data it doesn't have, and parsing a large body synchronously would block the single thread. The same reasoning applies to response.text() and response.blob() — the response is a stream, and reading it is inherently asynchronous. ⚑Why is response.json() asynchronous? [EQ-194]
4. The expert lens
"Single-threaded" and "concurrent" are not contradictory — and conflating them causes bad architecture decisions. JavaScript executes your code on one thread, but the runtime uses many threads and OS-level non-blocking I/O (2.7) underneath. So JavaScript achieves concurrency (many operations in flight) without parallelism (many operations executing simultaneously) for your code — exactly the distinction from 2.3. The practical rule follows precisely: this model is superb for I/O-bound work (thousands of connections mostly waiting — one thread multiplexes them all with no per-connection thread cost) and terrible for CPU-bound work (a heavy computation occupies the only thread and blocks everything). That's why Node excels at APIs and gateways, and why CPU-heavy work must be moved to worker_threads or a separate process (2.7's drill). "Don't block the event loop" is not a style preference; it's the constraint everything else rests on of the entire model.
The microtask/macrotask priority rule is the source of nearly all async ordering surprises. Once you hold "drain all microtasks, then one macrotask," a whole family of puzzles resolves: why a .then() beats a zero-delay setTimeout; why setTimeout(fn, 0) doesn't run in zero milliseconds (it waits for the current stack and all microtasks, then queues behind other tasks and, in browsers, possibly rendering); why an infinite microtask chain freezes a page even though it "looks async"; and why await in a loop is sequential (each await yields and resumes as a microtask before the next iteration) whereas Promise.all is concurrent. When debugging "why did this run in that order," reconstruct the two queues rather than guessing.
Next: 3.6.9 opens the engine that makes all this fast enough to matter — V8's compiler tiers, hidden classes, inline caches, and the Orinoco garbage collector.
Recall
- JavaScript runs on one call stack, so blocking freezes everything. It stays responsive because the environment (browser/Node), not the engine, performs slow work using OS non-blocking I/O (2.7) and other threads, then queues the result as a callback.
- The event loop rule: when the stack is empty, drain the entire microtask queue (promise callbacks), then run one macrotask (timers, I/O, UI events). Hence a
.then()always beatssetTimeout(…, 0); an endless microtask chain starves rendering. - A Promise represents a future value with states pending → fulfilled/rejected (settled once, immutably). Chain rules: return value → forwarded; return promise → flattened (chain waits and adopts); throw → rejection track until a
.catchrecovers. Unhandled rejections crash Node — every promise ends inawait,.catch, or deliberate fire-and-forget. async/awaitis sugar over chains: anasyncfunction always returns a promise, and eachawaitsplits the function — the rest becomes a microtask continuation, so the thread never blocks. Sequentialawaits serialize; start-then-Promise.allruns concurrently. Combinators:all(fail-fast),allSettled(never rejects),race(first to settle),any(first to fulfil).response.json()is async becausefetchresolves at the headers, while the body is still streaming — so it must await the remaining chunks and then parse them.
Self-test: Why doesn't fetch freeze the page if JavaScript is single-threaded? State the event loop's ordering rule and predict the output of the four-line example. What are the three chain rules for what a .then callback returns? Show the desugaring of a two-await function into .thens. Why is response.json() asynchronous?
Quiz Bank
FoundationalHow can single-threaded JavaScript handle asynchronous operations without freezing?
Because JavaScript doesn't do the waiting — the environment does. The engine has one call stack and executes one thing at a time, but the runtime around it (browser or Node.js) is multi-threaded and uses OS-level non-blocking I/O (2.7). When you call fetch or setTimeout, the engine hands the slow work to those platform APIs and immediately continues executing; when the work completes, its callback is placed on a queue. The event loop then runs that callback once the call stack is empty. So the engine never blocks — it registers interest and moves on — giving concurrency (many operations in flight) without parallelism of your code.
FoundationalState the event loop's ordering rule and predict the output: console.log(1); setTimeout(()=>console.log(2),0); Promise.resolve().then(()=>console.log(3)); console.log(4);
Rule: when the call stack is empty, drain the entire microtask queue, then run exactly one macrotask; repeat. Output: 1, 4, 3, 2. 1 and 4 are synchronous and run immediately on the stack. setTimeout(…,0) schedules a macrotask; Promise.resolve().then(…) schedules a microtask. When the synchronous code ends and the stack empties, all microtasks run first → 3, and only then one macrotask → 2. This is why a promise callback always beats a zero-delay setTimeout regardless of scheduling order.
AppliedWhat is a Promise, what are its states, and what does async/await add?
A Promise is an object representing a value that isn't available yet. It has three states: pending, fulfilled (with a value), or rejected (with a reason); once settled it is immutable. You attach continuations via .then/.catch/.finally, and crucially promises chain flatly with one .catch covering the whole chain — solving callback hell's nesting and repeated error handling. async/await is syntactic sugar over promises: an async function always returns a promise (a return x inside becomes the fulfilment value), and await suspends the function until its promise settles without blocking the thread — the remainder of the function is registered as a microtask continuation and the thread returns to the event loop. Result: asynchronous code that reads sequentially, with ordinary try/catch.
AppliedCompare Promise.all, allSettled, race, and any.
Promise.all([...]) fulfils when all promises fulfil, giving an array of results, but rejects immediately if any rejects (fail-fast) — use when you need every result and any failure invalidates the batch. Promise.allSettled([...]) waits for all to settle and never rejects, returning each outcome (status + value/reason) — use when partial failure is acceptable and you want to inspect every result. Promise.race([...]) settles with the first to settle, whether fulfilled or rejected — the standard way to implement timeouts (race the work against a rejecting timer). Promise.any([...]) settles with the first to fulfil, ignoring rejections unless all reject — use for redundant sources where you want the first success.
InterviewWhy is response.json() asynchronous when fetch's promise has already resolved?
Because fetch's promise resolves as soon as the response headers arrive — deliberately early, so you can check the status code and headers and decide whether to proceed before downloading a potentially large body. At that point the body is still streaming over the network (2.7). response.json() therefore has two jobs that both take unknown time: wait for the remaining body chunks, then parse the complete text into a JavaScript object. It cannot return data that hasn't arrived, and parsing a large body synchronously would block the single thread — so returning a promise is correct on both counts. The same applies to response.text() and response.blob(): the response is a stream, and consuming it is inherently asynchronous.
InterviewWhat does .then return, and what are the three rules for what happens next in a chain? Include how errors propagate and what an unhandled rejection is.
.then() always returns a new promise, and the callback's outcome decides that promise's fate: (1) return a plain value → the next .then receives it; (2) return a promise → the chain waits for it and adopts its result (auto-flattening — you never see Promise<Promise<T>>, and it's what keeps chains flat instead of nested); (3) throw (or return a rejected promise) → the chain switches to the rejection track, skipping every subsequent .then until a .catch, which handles the error and returns the chain to the success track (errors flow down chains like exceptions flow up stacks). A rejection that no handler ever receives is an unhandled rejection: browsers fire unhandledrejection, and Node terminates the process by default (since v15). The classic source is the floating promise — calling an async function without awaiting or .catching it — which is why no-floating-promises lint exists. Discipline: every promise ends in an await, a .catch, or an explicitly commented fire-and-forget.
StaffAn Express endpoint that resizes images makes the entire Node service unresponsive under load, while its database-heavy endpoints scale fine. Explain precisely, and give the fix.
The service is fine on database endpoints because those are I/O-bound: the request handler issues a query and awaits it, the work is performed by the platform/OS (2.7), the function suspends as a microtask continuation, and the single thread immediately serves other requests — thousands of connections can be in flight because almost none are executing. The image endpoint is CPU-bound: resizing runs synchronous computation on the event loop's only thread, so while it executes, the call stack is occupied and the event loop cannot run any other callback — every other request, timer, and health check stalls behind it. Under load these serialise and queue, so latency explodes service-wide and the process may appear hung (this is "blocking the event loop," the constraint everything else rests on of the model).
Fix — get the CPU work off the loop: (1) offload to a worker_threads pool so the computation runs on separate threads while the loop keeps serving I/O; (2) or move it to a separate process/service (or a job queue with dedicated workers — Part 10), which also lets you scale it independently and protects the API from resize spikes; (3) ensure any native image library actually releases to libuv's thread pool rather than blocking synchronously; (4) if work must stay in-process and is chunkable, yield to the loop periodically — but this is a weak mitigation, not a solution.
Verify with event-loop lag metrics (perf_hooks monitorEventLoopDelay) and CPU profiling, and add a lag alert so this class of regression is caught early. The staff framing: Node's architecture trades parallelism for cheap concurrency — it is optimal for waiting and pathological for computing, so the design rule is to keep the loop free and push computation to workers or dedicated services.
Flashcards
FlashWhy single-threaded JS isn't blocked by I/O
The engine hands slow work to the platform (browser/Node), which waits using OS non-blocking I/O on other threads and queues the callback when done.
FlashThe event loop rule
Stack empty → drain ALL microtasks → run ONE macrotask → repeat. Microtasks = promise callbacks; macrotasks = timers, I/O, UI events.
FlashPromise states
Pending → fulfilled (with a value) or rejected (with a reason). Settled once, then immutable.
FlashWhat async/await really is
Sugar over promises: an async function always returns a promise; await suspends the function as a microtask continuation without blocking the thread.
FlashPromise.all vs allSettled vs race vs any
all: all fulfil, rejects on first rejection. allSettled: waits for all, never rejects. race: first to settle either way (timeouts). any: first to fulfil.
FlashWhy response.json() is async
fetch resolves at the headers; the body is still streaming, so json() must await the remaining chunks and then parse them.
FlashThe three .then rules
Return value → forwarded. Return promise → chain waits and adopts (flattening). Throw → rejection track until .catch recovers. Unhandled rejection: crashes Node.
Scenario Drill
DrillA page fetches 50 records by awaiting each one inside a for-loop, and takes 10 seconds. A colleague says 'await is asynchronous, so these already run in parallel.' Correct them and fix it.
The colleague has conflated non-blocking with concurrent. await does not start work in parallel — it suspends the enclosing function until that one promise settles, registering the remainder as a microtask continuation. So for (const id of ids) { await fetchOne(id) } issues request 1, suspends, resumes when it completes, then issues request 2, and so on: the requests are strictly sequential, and total time is the sum of 50 round trips (50 × ~200 ms ≈ 10 s). What's true is that the thread isn't blocked meanwhile — the page stays responsive — but the requests themselves are serialised.
The fix is to start all the work first, then await collectively: const results = await Promise.all(ids.map(id => fetchOne(id))). Here .map calls fetchOne for every id immediately, so all 50 requests are in flight at once (handed to the platform's non-blocking I/O — 2.7), and Promise.all waits for the set; total time becomes roughly the slowest single request (~200 ms), a ~50× improvement.
Caveats worth stating: Promise.all is fail-fast, so if one request may fail and you still want the rest, use Promise.allSettled and handle each outcome; and firing 50 (or 5,000) requests simultaneously can overwhelm the server or hit browser/host connection limits, so for large batches add bounded concurrency (a pool/semaphore-style limiter — 2.4 — running, say, 10 at a time) rather than unbounded fan-out. The general rule: await marks where you need a result; concurrency comes from starting operations before awaiting them.