The JavaScript Event Loop: Call Stack, Task Queues, and Microtasks Explained
JavaScript runs on one thread. One call stack, one statement at a time, no parallel execution in the core language. And yet it powers UIs juggling clicks, animations, and network calls simultaneously, and servers handling tens of thousands of concurrent connections.
The trick is the event loop โ an architecture where JS never waits for anything slow, but instead schedules work to happen when results arrive. Itโs the single most important systems concept in the language: it explains promise ordering, setTimeout lies, frozen UIs, and every โwhy did these logs print in that order?โ mystery. Itโs also a guaranteed interview topic. Fifteen minutes here pays off for years.
The Cast: Stack, Runtime APIs, Queues, Loop
โโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ Call stack โ โ Runtime APIs (not JS!) โโ (JS executing โ โโโโโโโถ โ timers, fetch/network, โโ right now) โ hand โ DOM events, file IO... โโโโโโโโโโโฒโโโโโโโโโโโโ off โโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโ โ โ done โ enqueue callback โ take next โผโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ Microtask queue (promises) โ drained COMPLETELY first โโ Task queue (timers, events, IO) โ then ONE task โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ the EVENT LOOP moves work from queues โ stack only when the stack is EMPTY- Call stack โ where synchronous JS executes; function calls push, returns pop. While anything is on the stack, nothing else in JS can happen.
- Runtime APIs โ the browser/Node machinery that handles genuinely slow things (network, timers, disk) outside the JS thread.
setTimeoutandfetchdonโt run in JS โ theyโre requests to the runtime. - Queues โ when the runtime finishes something, the callback you provided goes into a queue, waiting.
- The event loop โ an eternal cycle: is the stack empty? then take the next queued callback and run it to completion.
The phrase to engrave: run to completion. Each callback runs fully before the next begins โ no interruption, no interleaving mid-function. This is why JS needs no locks for its own variables (contrast Javaโs world): two pieces of JS never touch the same data at the same instant. The price: anything long-running on the stack blocks everything โ clicks, rendering, timers. A 2-second synchronous loop is a 2-second frozen page.
Walking Through the Famous Example
console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");// prints: 1, 3, 2 โ despite the 0ms delayStep by step:
console.log("1")โ runs on the stack. Prints.setTimeout(cb, 0)โ handscbto the runtimeโs timer system with delay 0, returns immediately. JS does not wait.console.log("3")โ stack continues. Prints.- Script finishes; stack is empty. The timer (long since expired) has placed
cbin the task queue. - Event loop: stack empty โ run
cb. Prints โ2โ.
The revelation for most learners: setTimeout(fn, 0) doesnโt mean โnowโ โ it means โas soon as the stack is empty and the queue reaches me.โ The delay is a minimum, not a schedule. If the stack is busy for 3 seconds, a 0ms timer fires after 3 seconds. Understanding this converts a whole category of timing bugs from mysterious to obvious.
Microtasks vs. Tasks: The Ordering Rule Interviews Love
There isnโt one queue โ there are (at least) two, with strict priority:
- Microtask queue: promise reactions (
.then/.catch/.finally, code afterawait),queueMicrotask. - Task queue (a.k.a. macrotasks):
setTimeout/setIntervalcallbacks, DOM events, network/IO callbacks.
The rule: after any piece of JS finishes, the engine drains the ENTIRE microtask queue โ including microtasks queued by microtasks โ before touching the next task. One task, then all microtasks, repeat.
The canonical puzzle:
console.log("start");
setTimeout(() => console.log("timeout"), 0); // task
Promise.resolve().then(() => console.log("promise")); // microtask
console.log("end");
// start, end, promise, timeoutSynchronous code first (start, end). Stack empties. Microtasks drain (promise). Only then the next task (timeout). Promises always beat timers from the same tick โ not because theyโre โfaster,โ but by queue priority.
One sharper edge worth knowing: because microtasks-queued-by-microtasks also run before the next task, a microtask loop can starve the task queue entirely:
function loop() { Promise.resolve().then(loop); }loop(); // page frozen: rendering and timers never get a turnA setTimeout version of the same loop would be fine โ tasks yield to rendering between iterations; microtasks donโt. This is trivia until the day a recursive promise chain freezes your app, at which point itโs the diagnosis.
(Node adds refinements โ phases, setImmediate, process.nextTick running even before promise microtasks โ but the browser model above is the 95% mental model that transfers.)
What This Means for Real Code
Never block the stack. The UI renders between tasks โ long synchronous work means no paints, no clicks, a dead page. Heavy computation belongs in a Web Worker (a genuinely separate thread with its own event loop, communicating by messages โ JSโs answer to true parallelism), or chunked across tasks so the loop breathes between slices.
Async is contagious by design. A function that starts async work cannot return its result synchronously โ the result doesnโt exist yet, and the stack wonโt wait:
function getUser() { let user; fetch("/api/user").then((r) => r.json()).then((u) => { user = u; }); return user; // ALWAYS undefined โ the .then runs long after this return}This โ the most common async beginner bug โ is the event loop enforcing its nature: the fetch callback canโt run until the current stack (including getUser and its caller) finishes. The resolution is embracing the flow: return the promise and let callers await it โ the subject of the promises and async/await guides, which stand entirely on this chapterโs model.
Zero-delay timers are a scheduling tool. setTimeout(fn, 0) genuinely means โrun this after the current work, yielding to rendering firstโ โ useful for deferring non-urgent work. queueMicrotask(fn) means โafter the current stack, before anything else.โ Two precision instruments, now that you know the queues.
Ordering across async boundaries is by queue, not by source order. Two fetches complete in whichever order the network decides; their .thens enqueue in completion order. When sequence matters, chain โ donโt assume.
The Interview Version
โExplain the event loopโ answered in four sentences: JavaScript is single-threaded with one call stack; slow operations are delegated to runtime APIs which enqueue callbacks when done. The event loop runs queued callbacks one at a time whenever the stack is empty, each to completion. Promise reactions go to a microtask queue thatโs fully drained before the next task โ which is why promises resolve before timers scheduled in the same tick. Consequence: never block the stack, and structure async code around scheduling rather than waiting.
Then expect the ordering puzzle (practice the start/end/promise/timeout walkthrough until narrating it is automatic) and possibly โhow would you keep a heavy loop from freezing the UI?โ (workers, or chunking with setTimeout/requestIdleCallback).
Chunking: Keeping Long Work Off the Stack in Practice
Since โnever block the stackโ is this chapterโs prime directive, hereโs what the fix actually looks like when you canโt move work to a worker. The problem:
// freezes the page for the whole durationfor (const row of millionRows) process(row);The classic chunked version โ do a slice, yield to the loop, continue:
async function processAll(rows, chunkSize = 1000) { for (let i = 0; i < rows.length; i += chunkSize) { rows.slice(i, i + chunkSize).forEach(process); await new Promise((r) => setTimeout(r)); // yield: rendering & input get a turn }}Each chunk is one task; between chunks the browser paints, handles clicks, and runs timers. The page stays alive at the cost of slightly longer total time โ almost always the right trade for UI work. The modern refinement is scheduler.yield() (where available), which does the same yield with better queue priority semantics, and requestIdleCallback for genuinely deferrable background work that should only use spare time.
Knowing when you need this: the browserโs definition of a problematic โlong taskโ is anything over 50ms on the main thread. DevToolsโ Performance panel highlights them in red โ profile before and after chunking, and you can watch the single scary block dissolve into a comb of small tasks with paint slots between them.
Frequently Asked Questions
Is JavaScript really single-threaded if workers exist? The language semantics per realm are single-threaded; workers are separate realms with separate loops sharing (almost) no memory, communicating by message passing. Thatโs a concurrency architecture โ actor-like โ rather than shared-memory threading; the โno locks neededโ property inside each realm is preserved. (SharedArrayBuffer + Atomics pokes a controlled hole for advanced cases; youโll know if you need it.)
Does await block the thread? No โ await suspends the async function, returning control to the event loop; the rest of the function resumes as a microtask when the promise settles. The stack is never held hostage. โAwait blocks the function, not the threadโ is the precise phrasing โ full story here.
Why did my setTimeout(fn, 100) fire after 400ms? Some combination of: a busy stack when it expired, browser throttling (background tabs clamp timers), and queue backlog. Timers promise โnot before,โ never โat.โ
Where does rendering fit? Between tasks, at the browserโs discretion (typically aligned to display refresh, ~16ms) โ after microtasks drain. Hence the two rules of jank: long tasks skip frames, and microtask storms delay paints. requestAnimationFrame callbacks run just before paint โ the right hook for animation work.
Is Nodeโs event loop the same? Same concept, more structure: libuv phases (timers, poll, checkโฆ), setImmediate (after poll), and process.nextTick (ahead of even microtasks โ use sparingly). Browser intuition transfers; look up the phase diagram when Node timing gets subtle.
A Debugging Field Guide
Symptoms โ event-loop diagnoses, from real incident patterns:
- โThe page freezes when I click Xโ โ synchronous long task in the handler. Profile, find the >50ms block, chunk it or move it to a worker.
- โMy spinner never appears before the heavy workโ โ you showed the spinner and started the work in the same task; the paint slot comes after tasks. Yield once (
await Promise.resolve()isnโt enough โ thatโs a microtask; usesetTimeout(0)orrequestAnimationFrame) between showing and working. - โTwo rapid clicks double-submittedโ โ both click tasks ran before your first async guard updated. Set the guard synchronously (first line of the handler), not after an await.
- โMy interval driftsโ โ
setIntervalcallbacks queue behind other work; drift is inherent. For clock-accurate schedules, compute from timestamps each tick rather than trusting the interval. - โNode process exits before my work finishesโ โ nothing left on the loop: you forgot to await something, so the queues emptied. The async/await guideโs floating-promise section is the usual culprit.
Why This Design Won
A closing perspective worth carrying: the event loop wasnโt JavaScript settling for less โ it was a bet that aged remarkably well. Shared-memory threading (Javaโs model) buys parallelism at the price of locks, races, and deadlocks; the single-threaded loop trades away parallelism for correctness by default โ no data races possible within a realm โ and turns out to fit the webโs workload (IO-bound, event-driven, latency-sensitive) almost perfectly. Nodeโs entire success story is this architecture applied server-side: thousands of concurrent connections, each mostly waiting, multiplexed over one cheap thread. And the industry noticed โ Javaโs own virtual threads and structured concurrency are, in a real sense, the JVM importing the ergonomics that event-loop platforms proved out. The modelโs genuine weakness (CPU-bound work) got dedicated escape hatches (workers) rather than compromising the core. Understanding why the design works โ not just how โ is what lets you feel when youโre fighting it: if your code is drowning in coordination, youโre probably doing thread-work on a loop platform, or loop-work with threads.
Self-Check
// 1 โ full order?console.log("a");setTimeout(() => console.log("b"));Promise.resolve().then(() => console.log("c")).then(() => console.log("d"));console.log("e");
// 2 โ and this one?setTimeout(() => console.log("t1"));Promise.resolve().then(() => { console.log("p1"); setTimeout(() => console.log("t2")); Promise.resolve().then(() => console.log("p2"));});console.log("sync");Answers: (1) a, e, c, d, b โ sync first; the chained .then is a microtask queued by a microtask, still draining before the timer task. (2) sync, p1, p2, t1, t2 โ sync; microtask p1 queues both a new task (t2, behind t1) and a microtask (p2, which drains immediately after p1); then tasks in order.
Both correct โ including why โ and you have the model that makes the entire async half of JavaScript predictable. Which is exactly where the series goes next: Promises, the abstraction built on these queues.