The Event Loop
JavaScript runs on a single thread — one call stack, one piece of code executing at a time. The event loop is the mechanism that lets asynchronous operations (timers, network requests, user events) run without blocking the main thread indefinitely.
The Runtime Components
┌───────────────────────────┐
│ Call Stack │ ← synchronous function execution
└─────────────┬─────────────┘
│
┌─────────────▼─────────────┐
│ Web/Node APIs (libuv) │ ← setTimeout, fetch, fs.readFile
└─────────────┬─────────────┘
│ callbacks ready
┌─────────────▼─────────────┐
│ Microtask Queue │ ← Promises, queueMicrotask (HIGH priority)
└─────────────┬─────────────┘
│
┌─────────────▼─────────────┐
│ Macrotask Queue │ ← setTimeout, setInterval, I/O, UI events
└───────────────────────────┘
The Call Stack
Functions are pushed onto the stack when called and popped when they return:
function c() { console.log('c'); }
function b() { c(); }
function a() { b(); }
a();
// Stack progression: a → b → c → pop c → pop b → pop a
Stack overflow occurs with infinite recursion:
function recurse() { recurse(); }
recurse(); // RangeError: Maximum call stack size exceeded
Web APIs and Node.js libuv
Operations like setTimeout, fetch, DOM events, and file I/O are handled outside the JavaScript engine. When they complete, their callbacks are placed in a queue — not executed immediately.
console.log('start');
setTimeout(() => console.log('timeout'), 0);
console.log('end');
// start → end → timeout (not start → timeout → end)
Even setTimeout(fn, 0) waits until the stack is empty and microtasks are processed.
Microtasks vs Macrotasks
| Type | Sources | Priority |
|---|---|---|
| Microtasks | Promise.then/catch/finally, queueMicrotask, MutationObserver |
Run after current stack, before next macrotask |
| Macrotasks | setTimeout, setInterval, I/O callbacks, UI events |
One per event loop iteration |
Event Loop Algorithm
- Execute all synchronous code until the call stack is empty.
- Drain the entire microtask queue (all microtasks, including microtasks scheduled by other microtasks).
- Render the page (browser — if needed).
- Execute one macrotask.
- Repeat from step 2.
Classic Example
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2
Why:
1and4— synchronous, run immediately3— Promise callback is a microtask, runs before macrotasks2— setTimeout callback is a macrotask, runs last
Nested Promises and Timers
console.log('start');
setTimeout(() => console.log('timeout 1'), 0);
Promise.resolve()
.then(() => {
console.log('promise 1');
return Promise.resolve();
})
.then(() => console.log('promise 2'));
setTimeout(() => console.log('timeout 2'), 0);
console.log('end');
// start → end → promise 1 → promise 2 → timeout 1 → timeout 2
Each macrotask iteration processes all pending microtasks first.
async/await and the Event Loop
await pauses an async function and schedules the continuation as a microtask:
async function example() {
console.log('A');
await Promise.resolve();
console.log('B'); // microtask — runs after synchronous code
}
example();
console.log('C');
// A → C → B
Multiple awaits create multiple microtask checkpoints:
async function steps() {
console.log(1);
await null;
console.log(2);
await null;
console.log(3);
}
steps();
console.log(4);
// 1 → 4 → 2 → 3
Blocking the Event Loop
Long synchronous work blocks everything — no rendering, no callbacks, frozen UI:
// BAD: blocks for 5 seconds
function block() {
const start = Date.now();
while (Date.now() - start < 5000) {}
}
block();
Solutions:
// Break work into chunks with setTimeout
function processChunk(items, index = 0) {
const CHUNK = 100;
const end = Math.min(index + CHUNK, items.length);
for (let i = index; i < end; i++) processItem(items[i]);
if (end < items.length) {
setTimeout(() => processChunk(items, end), 0);
}
}
// Or offload to Web Workers for CPU-heavy tasks
queueMicrotask
Schedule a microtask explicitly:
console.log('sync');
queueMicrotask(() => console.log('microtask'));
Promise.resolve().then(() => console.log('promise'));
console.log('sync 2');
// sync → sync 2 → microtask → promise (microtasks in order)
Node.js Event Loop Phases
Node.js has additional phases (timers, I/O poll, check, close callbacks). The microtask/macrotask distinction still applies — process.nextTick runs even before microtasks in Node.
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// nextTick → promise → timeout
Best Practices
- Keep synchronous work on the main thread short (< 50ms for smooth UI).
- Prefer async I/O (
fetch,fs.promises) over blocking calls. - Use Web Workers for CPU-intensive computation.
- Understand that
setTimeout(0)is not immediate — it queues a macrotask. - Batch DOM updates to avoid layout thrashing within a single frame.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Promise runs before setTimeout | Microtask priority | Expected behavior — design accordingly |
| UI freezes during computation | Long sync loop on main thread | Chunk work or use Workers |
| Callback order surprises | Mixed micro/macrotasks | Trace execution with numbered logs |
| Memory leak with timers | setInterval never cleared | Store ID and call clearInterval |
| Race condition | Assumed immediate async execution | Use await or Promise chains explicitly |
Understanding the event loop explains why JavaScript stays responsive despite being single-threaded — and why blocking the main thread is the cardinal sin of front-end performance.