The JavaScript Event Loop: How Async Code Really Runs
JavaScript runs your code on one thread, yet timers, network responses, and promises still feel concurrent. This post walks through the call stack, the task and microtask queues, and why the order of `setTimeout` and `Promise.then` matters in real apps.
Why this matters
If you write JavaScript in the browser or Node.js, you are constantly scheduling work that does not run immediately: clicks, fetch completions, setTimeout, queueMicrotask, and Promise reactions. The event loop is the mechanism that decides what runs next after the current synchronous chunk of code finishes. Understanding it saves you from subtle bugs (wrong ordering, “infinite” microtask chains, UI that never paints) and helps you reason about performance.
This article focuses on the mental model shared by browsers and Node.js, then calls out a few environment-specific details.
One thread, many sources of work
JavaScript language semantics for a single realm are single-threaded: one call stack, one piece of user code executing at a time. That does not mean the host (browser or Node) is single-threaded — browsers use other threads for networking, decoding, and more — but your callbacks are generally interleaved, not literally parallel, unless you use workers or native parallel APIs.
So the question becomes: when a timer fires or a promise settles, where does that callback wait, and when does it run?
The call stack
When a function runs, a stack frame is pushed; when it returns, the frame is popped. A thrown error unwinds the stack until a try/catch handles it. Nothing asynchronous “pauses” the stack in the middle of a function: fetch(...) returns right away; the network work happens elsewhere, and a callback or promise reaction is scheduled for later.
If the stack never empties — for example, a tight while (true) loop — no scheduled tasks run. That is why long synchronous work freezes the UI in browsers.
Tasks (macrotasks) and the event loop
After the call stack is empty, the event loop takes the next task from a task queue (often called a macrotask queue in tutorials). Classic sources of tasks include:
setTimeout/setIntervalcallbacks (after the delay has elapsed)- DOM event delivery (e.g.
click) — once the event is ready to be dispatched MessageChannel/postMessagein browsers- I/O completions in Node (with more nuance; see below)
The important rule: run one task, then check microtasks, then possibly rendering, then the next task (browsers add steps for rendering; the exact specification is in HTML and ECMAScript, but the pattern below is what developers rely on).
Microtasks
Microtasks run after the current task’s synchronous code finishes and before the next macrotask (and, in browsers, often before the next paint). Sources include:
Promise.then,catch,finallyqueueMicrotask(fn)MutationObservercallbacks in browsers- In some environments, other internal bookkeeping
The algorithm, simplified:
- Run a task until the stack is empty.
- Run all microtasks in the microtask queue until it is empty (draining the queue).
- Optionally perform rendering updates (browser).
- Pick the next task.
Because microtasks drain completely before the next macrotask, a chain of Promise.then that keeps enqueueing more microtasks can starve timers and I/O from running — and in the browser, delay painting. That is rare in well-behaved code but shows up in tests and in buggy “recursive” microtask patterns.
A concrete ordering example
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");
Typical output:
A
D
C
B
Why: A and D run synchronously. The timeout schedules a task. The then schedules a microtask. After the synchronous chunk ends, microtasks run (C), then the next macrotask (B).
If you nest a timeout inside a microtask, the timeout’s callback is a new macrotask and runs after the microtask queue drains.
requestAnimationFrame (browser)
requestAnimationFrame is tied to painting: callbacks run before the browser repaints, in a well-defined place relative to style/layout work. It is not a generic “defer to later” replacement for setTimeout(0) — use it when you want to sync with the display pipeline (animations, reading layout after the next frame, etc.).
Node.js: phases in brief
Node’s event loop has phases (timers, pending callbacks, poll, check, close callbacks). For day-to-day code, the same microtask-after-current-operation idea applies: process.nextTick runs before other microtasks (this is Node-specific and can surprise people), then promise microtasks, then the loop continues. If you target both browser and Node, prefer queueMicrotask over process.nextTick for portable “run after stack, before next task” semantics — except when you intentionally need Node’s ordering.
Starvation and yielding
If you process a huge array in small slices using only Promise.resolve().then(nextSlice), you enqueue one microtask per slice. Until that chain ends, timers may not fire and the browser may not paint. Patterns that yield to the macrotask queue — e.g. setTimeout(0, nextSlice) or breaking work across frames with requestAnimationFrame — keep the UI responsive. Choose based on whether you need “as soon as possible after this stack” (microtasks) or “after other tasks get a turn” (macrotasks).
Async/await
async functions return promises. await suspends the async function and schedules its resumption as a microtask (when the awaited promise settles). Multiple await lines in one function still interleave with the same global ordering rules as explicit .then chains.
Practical takeaways
- Keep synchronous work short — especially in the browser — so tasks and rendering can run.
- Assume microtasks run before the next timer or I/O callback unless you know your host’s edge cases.
- Avoid unbounded microtask chains; if you need to chunk work, use
setTimeout(0)orrequestIdleCallback(browser) to yield back to the event loop. - Logging order in tests — when something “should” run first, check whether you are comparing a macrotask to a microtask.
- Node’s
process.nextTickis not the same as a promise microtask; read the docs when debugging server-side ordering.
Closing thought
The event loop is not magic: it is a fairly small set of queues and ordering rules glued to a single call stack. Once you internalize “sync, then drain microtasks, then next task,” most async puzzles in JavaScript become predictable.
This post explains standard behavior at a high level; verify edge cases in your target engines and versions when it matters for production.
Rate this post
All fields are optional. Just stars is fine.