JavaScript Event Loop Explained: From Call Stack to Microtasks
Introduction
JavaScript is single-threaded. Yet modern applications handle thousands of concurrent network requests, animations, and user interactions without freezing the UI. The mechanism behind this paradox is the event loop—a coordination pattern that lets one thread juggle synchronous execution with asynchronous work.
Whether you are debugging a race condition in React, explaining why Promise.then runs before setTimeout, or tuning Node.js server throughput, understanding the event loop is non-negotiable. This article walks through the call stack, Web APIs, the task queue, microtasks, and how Node.js extends the model with process.nextTick and libuv phases.
If you have ever stared at console output that defies your intuition, the event loop is usually the answer.
Table of Contents
- The Call Stack
- Web APIs and the Browser Environment
- Macrotasks vs Microtasks
- Event Loop Algorithm
- Node.js Event Loop Phases
- Practical Examples
- Best Practices
- Common Mistakes
- Interview Questions
- Summary
- References
The Call Stack
When JavaScript executes, it uses a call stack—a LIFO structure tracking function invocations. Each function call pushes a frame; each return pops one.
function greet(name) {
return `Hello, ${name}`;
}
function main() {
const message = greet("Monu");
console.log(message);
}
main();
Execution order: main is pushed, then greet, then greet returns, then console.log runs, then main returns. The stack never holds more than a few frames for this snippet.
The call stack is synchronous. Long-running synchronous code blocks everything else—including rendering and input handling. That is why we offload work to asynchronous APIs.
Web APIs and the Browser Environment
Browsers provide APIs outside the JavaScript engine: setTimeout, fetch, DOM events, requestAnimationFrame. When you call setTimeout(fn, 0), the timer is registered by the browser; your callback waits in a task queue (macrotask queue) until the stack is empty and the event loop picks it up.
console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");
// Output: 1, 3, 2
setTimeout does not run immediately after console.log("1"). The callback is scheduled; synchronous code continues until the stack clears.
Macrotasks vs Microtasks
Not all async callbacks are equal. Macrotasks include setTimeout, setInterval, I/O callbacks, and UI events. Microtasks include Promise reactions (then, catch, finally), queueMicrotask, and in browsers MutationObserver.
The critical rule: after each macrotask (or when the stack empties), the runtime drains the entire microtask queue before taking the next macrotask.
console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("end");
// Output: start, end, promise, timeout
Promise.then is a microtask; it runs before the setTimeout macrotask even though both were scheduled during the same synchronous block.
Nested microtasks
Microtasks can enqueue more microtasks. The queue drains completely before the next macrotask:
Promise.resolve()
.then(() => {
console.log("A");
Promise.resolve().then(() => console.log("B"));
})
.then(() => console.log("C"));
setTimeout(() => console.log("D"), 0);
// Output: A, B, C, D
Event Loop Algorithm
A simplified browser event loop:
- Run synchronous code until the call stack is empty.
- Drain all microtasks.
- Optionally render (browser).
- Pick one macrotask from the queue and run it.
- Repeat.
This explains UI jank: if microtasks never stop enqueueing, macrotasks (including paint) starve.
Node.js Event Loop Phases
Node.js uses libuv and organizes work into phases:
| Phase | Examples |
|---|---|
| timers | setTimeout, setInterval |
| pending callbacks | Some system operations |
| idle, prepare | Internal |
| poll | I/O callbacks |
| check | setImmediate |
| close callbacks | socket.on('close') |
Additionally, process.nextTick runs before the next phase—not quite a microtask in the HTML spec sense, but higher priority than Promise microtasks in practice.
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
// Typical: nextTick, promise, timeout, immediate (order may vary slightly by context)
Practical Examples
Blocking the event loop in Node.js
const http = require("http");
http.createServer((req, res) => {
// BAD: blocks entire server for ~2 seconds
const start = Date.now();
while (Date.now() - start < 2000) {}
res.end("done");
}).listen(3000);
Use worker threads or break work into chunks with setImmediate for CPU-heavy tasks.
async/await and microtasks
await suspends an async function and resumes via microtasks:
async function demo() {
console.log("1");
await null;
console.log("2");
}
demo();
console.log("3");
// 1, 3, 2
Best Practices
- Keep synchronous work short—anything over ~50ms risks perceptible lag in browsers.
- Prefer
queueMicrotaskor Promises for deferring work that must run before the next paint, when appropriate. - Use
requestAnimationFramefor visual updates tied to the display refresh cycle. - In Node.js, use
setImmediateoversetTimeout(fn, 0)for deferring I/O-bound follow-ups; it runs in the check phase after poll. - Profile with real tools—Chrome Performance panel,
clinic.js, or0xfor Node—not justconsole.logordering puzzles. - Batch DOM reads and writes to avoid layout thrashing between microtask rounds.
Common Mistakes
- Assuming
setTimeout(fn, 0)runs immediately—it runs after the stack and microtasks. - Creating infinite microtask chains—starves macrotasks and rendering.
- Using
process.nextTickrecursively in Node—can prevent I/O from progressing. - Confusing
asyncfunction return with synchronous completion—the function returns a Promise immediately; body afterawaitis deferred. - Ignoring event loop in server design—one blocked request handler blocks all clients on that process.
Interview Questions
- What is the difference between microtasks and macrotasks?
- Predict the output of nested
setTimeoutandPromise.thenchains. - How does
async/awaitrelate to Promises and the event loop? - What happens if you never resolve a Promise—does the event loop stop?
- Explain Node.js
process.nextTickvssetImmediate. - Why can long synchronous loops freeze a React app?
- How does
requestAnimationFramefit relative to microtasks? - What is the call stack, and what happens on stack overflow?
Browser vs Node.js: Practical Differences
In browsers, rendering is tied to the event loop. After microtasks drain, the browser may run style calculation, layout, paint, and composite steps. Long microtask chains delay paint—users see frozen UI even though JavaScript is "working."
requestAnimationFrame callbacks run before the next repaint—they are not microtasks. Use them for animations:
function animate() {
element.style.transform = `translateX(${position}px)`;
position += 2;
if (position < 300) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
In Node.js, there is no render phase, but I/O starvation is the equivalent risk. Heavy process.nextTick or microtask recursion prevents the poll phase from accepting new connections. Load balancers may mark the instance unhealthy while the process is technically alive.
Worker threads and child processes move CPU work off the main event loop:
import { Worker } from "worker_threads";
function runWorker(data) {
return new Promise((resolve, reject) => {
const worker = new Worker("./heavy-task.js", { workerData: data });
worker.on("message", resolve);
worker.on("error", reject);
});
}
Understanding which phase your async API schedules into explains ordering bugs that appear only under production concurrency.
Summary
The JavaScript event loop is the scheduler that makes single-threaded concurrency possible. Synchronous code runs on the call stack until empty; microtasks drain completely; then one macrotask runs—and the cycle repeats. Browsers add rendering; Node.js adds libuv phases and nextTick. Mastering this model turns confusing async output into predictable behavior and helps you build responsive UIs and scalable servers.