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.