JavaScript is single-threaded, yet JavaScript runtimes handle high-concurrency I/O effortlessly. They achieve this through the Event Loop—an orchestrator that delegates heavy work (network, disk, timers) to system kernel threads and queues up callbacks for execution on the main thread.
While both environments run JavaScript, the Browser Event Loop is optimized for user interactions and frame rendering (UI thread), whereas the Node.js Event Loop is optimized for high-throughput I/O and OS system operations.
The Mental Model
Think of the Event Loop as a traffic controller managing two highways merging into a single lane (the single JavaScript call stack):
-
Macrotask Queue (Regular Highway): Timers (
setTimeout), I/O callbacks, user clicks, network events. -
Microtask Queue (VIP Express Lane): Promises,
queueMicrotask, and in Node.js,process.nextTick()(which gets its own ultra-VIP lane before standard Promises).
RULE: The Call Stack MUST be completely empty first.
1. Flush Microtasks (VIP Lane) -> Drain to 0
2. Pick Macrotask (Regular Highway) -> Execute exactly 1 item
3. Repeat
The Browser Event Loop Workflow
The Node.js Event Loop Workflow
-
No single macrotask queue: Unlike browsers, Node splits macrotasks into phase-specific queues (
Timers->Poll->Check). -
Timers Phase: Executes callbacks scheduled by
setTimeout()andsetInterval(). - Poll Phase: Processes incoming network requests, database queries, and file system I/O callbacks.
-
Check Phase: Executes
setImmediate()callbacks immediately after the Poll phase completes. -
Microtask Interruption: The microtask queue (
process.nextTick+Promises) drains completely to zero after every single macrotask and between phases.
3 Rules That Prevent Tricky Async Bugs
1. process.nextTick() Always Wins (Node.js Only)
process.nextTick() sits in its own nextTickQueue, executing immediately after the current operation completes—before any standard Promise microtasks.
Promise.resolve().then(() => console.log('1. Promise'));
process.nextTick(() => console.log('2. nextTick'));
// Output:
// 2. nextTick
// 1. Promise
⚠️ Starvation Gotcha: Recursively calling
process.nextTick()starves the Event Loop, blocking I/O and timers indefinitely.
2. setTimeout(fn, 0) vs setImmediate(fn) (Node.js)
setTimeout(fn, 0) runs in the Timers phase (Node converts 0ms to 1ms minimum).
setImmediate(fn) runs in the Check phase.
Execution order is non-deterministic in the main script, but deterministic inside I/O callbacks:
// Main script: Unpredictable output due to CPU/timer initialization bound
setTimeout(() => console.log('1. setTimeout'), 0);
setImmediate(() => console.log('2. setImmediate'));
// Inside I/O callback: setImmediate ALWAYS runs first
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('1. setTimeout'), 0);
setImmediate(() => console.log('2. setImmediate'));
// Output: Always 2, then 1 (Poll phase transitions directly to Check phase)
});
3. Rendering Priority (Browser Only)
Browsers run a Rendering Phase (Style -> Layout -> Paint) inside the event loop targeting ~60Hz (16.6ms per frame).
Microtask Bloat: Blocking the microtask queue delays frame rendering, freezing the page UI.
requestAnimationFrame: Callbacks execute immediately before the repaint step, making them ideal for visual updates compared to setTimeout.
Quick Reference: Execution Order Tracing
Test your knowledge on this classic interview execution tracer:
console.log('1. Sync');
setTimeout(() => console.log('2. Macrotask (Timer)'), 0);
Promise.resolve().then(() => console.log('3. Microtask (Promise)'));
process.nextTick(() => console.log('4. Microtask (nextTick)'));
console.log('5. Sync');
Execution Order:
-
1. Sync(Main Call Stack) -
5. Sync(Main Call Stack) -
4. Microtask (nextTick)(Node Ultra-VIP Queue) -
3. Microtask (Promise)(Standard Microtask Queue) -
2. Macrotask (Timer)(Timers Phase)


Top comments (0)