JavaScript Event Loop Explained
Understand how JavaScript's single-threaded event loop works, why microtasks run before macrotasks, and how to reason about async execution order.
JavaScript runs on a single thread — there is no parallel execution of your code. Yet it handles HTTP requests, timers, and user events without blocking. The event loop is the mechanism that makes this possible. Understanding it is not just academic — it explains why async code runs in the order it does, why the UI can freeze, and how to design code that stays responsive.
The Mental Model
Think of the runtime as four interacting components. Each plays a specific role:
- Call Stack — a LIFO stack that tracks which function is currently executing. Synchronous code runs here, frame by frame.
- Heap — unstructured memory where objects are allocated.
- Web APIs (browser) / libuv (Node.js) — handles timers, network I/O, and DOM events outside the JS thread. These run in parallel with your code.
- Task Queues — two queues: the microtask queue (high priority) and the callback queue / macrotask queue (lower priority).
The event loop rule is simple: when the call stack is empty, drain all microtasks, then pick one macrotask, then repeat.
A Concrete Execution Trace
Walking through a concrete example is the fastest way to internalize the ordering rules. Read the output prediction first, then verify it against the explanation:
console.log("1 — synchronous start");
setTimeout(() => console.log("2 — macrotask (setTimeout)"), 0);
Promise.resolve()
.then(() => console.log("3 — microtask (Promise .then)"))
.then(() => console.log("4 — microtask (chained .then)"));
queueMicrotask(() => console.log("5 — microtask (queueMicrotask)"));
console.log("6 — synchronous end");
Output:
1 — synchronous start
6 — synchronous end
3 — microtask (Promise .then)
5 — microtask (queueMicrotask)
4 — microtask (chained .then)
2 — macrotask (setTimeout)
Step-by-step:
"1"and"6"run synchronously — call stack executes top to bottom.setTimeoutregisters a macrotask;Promise.resolve().then(...)queues a microtask;queueMicrotaskqueues a microtask.- Call stack is now empty. Event loop drains the microtask queue:
"3", then"5", then"4"(the chained.thenwas enqueued when"3"resolved). - Microtask queue is empty. Event loop picks the next macrotask:
"2".
Microtasks Run to Completion
All microtasks queued during microtask processing are also drained before any macrotask runs. This is by design — it ensures Promise chains resolve completely before the browser gets a chance to repaint or run timers. The downside is that a loop of recursive microtasks can starve the macrotask queue entirely:
// This will never let any macrotask run — the microtask queue never empties
function infiniteMicrotasks() {
Promise.resolve().then(infiniteMicrotasks); // enqueues a new microtask each time
}
// Don't do this — it freezes the browser/Node process permanently
The Call Stack in Practice
Synchronous code fills the call stack frame by frame. Each function call pushes a frame; each return pops it. When the stack is empty, the event loop can process queued callbacks.
function add(a, b) {
return a + b; // frame 3 — innermost, runs first to complete
}
function calculate(x) {
return add(x, 10); // frame 2
}
function main() {
const result = calculate(5); // frame 1
console.log(result); // 15
}
main();
// Stack lifecycle:
// [main] → [main, calculate] → [main, calculate, add]
// → [main, calculate] → [main] → []
A deep recursive call with no base case overflows the stack:
function infinite() { infinite(); }
// Uncaught RangeError: Maximum call stack size exceeded
setTimeout(fn, 0) is Not Instantaneous
setTimeout(fn, 0) is a common pattern, but many developers assume it runs “right away.” In reality, it schedules a macrotask — meaning it will run after the current synchronous code finishes AND after all pending microtasks are drained.
function render() {
console.log("render");
}
setTimeout(render, 0); // queued as a macrotask
// This Promise callback runs BEFORE render, even though setTimeout was called first
Promise.resolve().then(() => console.log("promise"));
// Output:
// promise ← microtask runs first
// render ← macrotask runs after all microtasks
Use setTimeout(fn, 0) to yield to the browser’s rendering pipeline (e.g., let a repaint happen before heavy work). Use queueMicrotask() when you need to defer to the end of the current task but before any I/O or rendering.
Practical: Avoiding UI Jank with Chunked Work
Long synchronous tasks block the call stack and freeze the UI — the browser cannot repaint or respond to input while JavaScript is running. The fix is to break heavy work into smaller chunks and yield between them, giving the browser time to paint and stay interactive.
function processInChunks(items, chunkSize = 100) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
// process one chunk synchronously
heavyComputation(items[index]);
}
if (index < items.length) {
// yield to the event loop — browser can repaint before next chunk
setTimeout(processChunk, 0);
}
}
processChunk();
}
For modern code, prefer requestIdleCallback (browser) for low-priority background work, or scheduler.postTask when available.
async/await and the Event Loop
async/await is syntax sugar over Promises. An await expression suspends the async function and enqueues the rest of the function as a microtask once the awaited Promise settles. This means code after await does not run synchronously — it runs as a microtask after the current call stack empties.
async function fetchUser(id) {
console.log("A — before await");
const user = await getUser(id); // function suspends here; rest is enqueued as microtask
console.log("B — after await"); // runs as a microtask when getUser resolves
return user;
}
console.log("1 — before call");
fetchUser(1);
console.log("2 — after call (synchronous)"); // runs before "B" because stack isn't empty yet
// Output:
// 1 — before call
// A — before await
// 2 — after call (synchronous)
// B — after await ← microtask runs after call stack clears
Node.js Additions
Node.js adds two more scheduling mechanisms that sit between the standard queues:
process.nextTick()— runs before any other microtasks (even Promises). Use sparingly; it can delay I/O callbacks.setImmediate()— runs after I/O callbacks in the current event loop iteration, before anysetTimeoutwith a 0ms delay.
Priority order in Node.js: process.nextTick → microtasks (Promises) → I/O → setImmediate → setTimeout.
Key Takeaways
- The call stack runs synchronous code; Web APIs handle async work off-thread.
- When the stack empties, drain all microtasks, then run one macrotask, repeat.
- Promises and
queueMicrotaskenqueue microtasks (higher priority thansetTimeout). setTimeout(fn, 0)guarantees only that the callback runs after the current microtask queue is empty.- Never block the call stack with long synchronous loops — break work into chunks.