JavaScript is single-threaded. There's only one call stack, one thread executing your code at any given time. Yet somehow you can make HTTP requests, set timers, handle user clicks, and your code doesn't freeze waiting for these operations.
How? The Event Loop.
Most explanations show you a diagram with boxes and arrows. We'll go deeper - let's understand what's actually happening when your async code runs.
The Mental Model: JavaScript Runtime Architecture
JavaScript doesn't run in isolation. The runtime (browser or Node.js) consists of:
1. The JavaScript Engine (V8, SpiderMonkey, etc.)
- Call Stack: Where your synchronous code executes
- Heap: Where objects live
2. Web APIs / C++ APIs (browser/Node.js)
- Timer APIs (
setTimeout,setInterval) - Network APIs (fetch, XMLHttpRequest)
- File system, crypto, etc. (Node.js)
3. Task Queues
- Macrotask Queue (also called Task Queue or Callback Queue)
- Microtask Queue
4. The Event Loop
- Coordinates everything
Here's the crucial part: When you call setTimeout, you're not calling a JavaScript function. You're calling a Web API that runs outside the JavaScript engine. The event loop bridges the gap between the engine and these external APIs.
The Event Loop: What It Actually Does
The event loop is a simple infinite loop that does one thing:
while (true) {
if (callStack.isEmpty()) {
if (microtaskQueue.hasItems()) {
microtaskQueue.executeNext();
} else if (macrotaskQueue.hasItems()) {
macrotaskQueue.executeNext();
}
}
}
That's it. The event loop constantly checks: "Is the call stack empty? If yes, is there work in the queues?"
Let's trace through a real example:
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 0);
Promise.resolve().then(() => {
console.log('Promise');
});
console.log('End');
Output:
Start
End
Promise
Timeout
Let's see why.
Phase-by-Phase Execution
Phase 1: Synchronous Code Runs
console.log('Start'); // Executes immediately
Call Stack: [console.log] → executes → prints "Start" → pops off
Phase 2: setTimeout
setTimeout(() => { console.log('Timeout'); }, 0);
This doesn't execute the callback. Instead:
-
setTimeoutis called (pushed to call stack) - The browser's timer API is invoked with the callback
-
setTimeoutimmediately returns and is popped from the stack - The timer API starts a 0ms timer in the background (separate thread)
- When the timer expires, the callback is placed in the Macrotask Queue
Call Stack: [setTimeout] → pops off
Macrotask Queue: [] (callback will arrive here after ~0ms)
Phase 3: Promise
Promise.resolve().then(() => { console.log('Promise'); });
-
Promise.resolve()creates an already-resolved promise -
.then()schedules a callback - The callback goes into the Microtask Queue (not macrotask!)
Call Stack: [Promise.resolve, .then] → pops off
Microtask Queue: [() => console.log('Promise')]
Phase 4: More Synchronous Code
console.log('End');
Call Stack: [console.log] → executes → prints "End" → pops off
Phase 5: Call Stack Empty - Event Loop Wakes Up
Now the call stack is empty. The event loop starts working:
-
Check Microtask Queue first (always has priority!)
- Found:
() => console.log('Promise') - Push to call stack → execute → prints "Promise"
- Microtask Queue is now empty
- Found:
-
Check Macrotask Queue
- By now, the timer has expired and placed its callback here
- Found:
() => console.log('Timeout') - Push to call stack → execute → prints "Timeout"
Final output: Start, End, Promise, Timeout
Microtasks vs Macrotasks: The Priority System
The event loop always processes all microtasks before moving to the next macrotask.
Macrotasks (Task Queue):
setTimeoutsetInterval-
setImmediate(Node.js only) - I/O operations
- UI rendering (browser)
Microtasks:
Promise.then/catch/finallyqueueMicrotask()-
MutationObserver(browser) -
process.nextTick(Node.js - special case, even higher priority!)
Example showing priority:
setTimeout(() => console.log('Timeout 1'), 0);
Promise.resolve().then(() => {
console.log('Promise 1');
Promise.resolve().then(() => {
console.log('Promise 2');
});
});
setTimeout(() => console.log('Timeout 2'), 0);
Promise.resolve().then(() => {
console.log('Promise 3');
});
Output:
Promise 1
Promise 3
Promise 2
Timeout 1
Timeout 2
Why?
- All synchronous code runs first (nothing to log)
- Call stack empty → Event loop checks microtasks
-
Promise 1executes, schedulesPromise 2(added to microtask queue) -
Promise 3executes -
Promise 2executes (microtask queue must be empty before macrotasks!) - Now microtask queue is empty → move to macrotasks
-
Timeout 1executes -
Timeout 2executes
Node.js Event Loop: More Complex Phases
In Node.js, the event loop has specific phases that execute in order:
┌───────────────────────────┐
┌─>│ timers │ <- setTimeout, setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ <- I/O callbacks deferred from previous cycle
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ <- internal use only
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ <- retrieve new I/O events
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ <- setImmediate callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ close callbacks │ <- socket.on('close', ...)
│ └───────────────────────────┘
└──────────────────────────────────
Between each phase: All microtasks are processed.
Timers Phase
Executes callbacks scheduled by setTimeout and setInterval whose timers have expired.
Important: The timer specifies the minimum delay, not a guarantee:
setTimeout(() => console.log('100ms'), 100);
If the event loop is busy (executing a long task), the callback might run after 150ms, 200ms, etc.
Poll Phase
This is where Node.js spends most of its time. The poll phase:
- Executes I/O callbacks (file reads, network requests, etc.)
- If no callbacks, waits for new events (blocking, but can be interrupted)
When I/O completes (file read finishes, HTTP response arrives), the callback is placed in the poll queue.
Check Phase: setImmediate
setImmediate is like setTimeout(fn, 0) but with a guarantee: it always runs after the poll phase.
const fs = require('fs');
fs.readFile('file.txt', () => {
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
});
Output:
setImmediate
setTimeout
Why? Inside an I/O callback (poll phase), setImmediate executes in the next check phase, while setTimeout has to wait until the next timers phase (next cycle of the event loop).
process.nextTick: The Priority Microtask
In Node.js, process.nextTick is special. It has even higher priority than regular microtasks:
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
Output:
nextTick
Promise
The execution order in Node.js:
- Synchronous code
- process.nextTick queue (completely drain)
- Microtask queue (completely drain)
- Next event loop phase
Warning: You can starve the event loop:
function recurse() {
process.nextTick(recurse);
}
recurse();
This will block the event loop forever because the nextTick queue never empties - I/O callbacks will never run!
I/O Operations: How They Actually Work
When you read a file in Node.js:
const fs = require('fs');
console.log('Start');
fs.readFile('data.txt', (err, data) => {
console.log('File read');
});
console.log('End');
What happens:
-
fs.readFileis called (synchronous part) - Node.js delegates to libuv (C++ layer)
- libuv uses the OS's async I/O or a thread pool
-
fs.readFilereturns immediately (non-blocking) - JavaScript continues executing (
console.log('End')) - When the file read completes (in background), libuv notifies the event loop
- The callback is placed in the poll queue
- Event loop picks it up and executes it
Output:
Start
End
File read
The I/O happens in parallel (separate thread/OS), but the callback runs on the main JavaScript thread.
Practical Example: Ordering Chaos
setTimeout(() => console.log('1'), 0);
setImmediate(() => console.log('2'));
Promise.resolve().then(() => console.log('3'));
process.nextTick(() => console.log('4'));
console.log('5');
Output:
5
4
3
1
2
Execution order:
- Synchronous:
5 - nextTick queue:
4 - Microtask queue:
3 - Macrotask (timer phase):
1 - Check phase:
2
(Note: Outside I/O callbacks, the order of setTimeout vs setImmediate can vary)
Visualizing Async Flow
console.log('A');
setTimeout(() => {
console.log('B');
Promise.resolve().then(() => console.log('C'));
}, 0);
Promise.resolve()
.then(() => console.log('D'))
.then(() => console.log('E'));
console.log('F');
Execution trace:
Sync Phase:
-
Aprints -
setTimeoutregisters callback in macrotask queue - Promise chain starts,
Dcallback → microtask queue -
Fprints
Microtask Phase:
-
Dprints, schedulesE→ microtask queue -
Eprints - Microtask queue empty
Macrotask Phase:
-
setTimeoutcallback executes -
Bprints - Promise callback → microtask queue
Microtask Phase (again!):
-
Cprints
Output: A, F, D, E, B, C
Common Pitfalls
1. Assuming setTimeout(fn, 0) runs immediately:
let x = 0;
setTimeout(() => { x = 1; }, 0);
console.log(x); // 0, not 1!
The callback runs after synchronous code completes.
2. Infinite microtask loops:
function loop() {
Promise.resolve().then(loop);
}
loop(); // Blocks event loop!
Each promise schedules another microtask. Macrotasks never get a chance to run.
3. Heavy computation blocking the loop:
setTimeout(() => console.log('Never runs?'), 100);
// Block for 5 seconds
const start = Date.now();
while (Date.now() - start < 5000) {}
The setTimeout callback won't run until the while loop finishes, even though 100ms passed long ago.
Summary
The event loop is JavaScript's solution to single-threaded async:
Key Points:
- JavaScript executes synchronous code first
- Async APIs (timers, I/O) run outside JavaScript, notify when done
- Callbacks go into queues: microtasks (promises) or macrotasks (setTimeout, I/O)
- Event loop processes all microtasks before any macrotask
- In Node.js: specific phases (timers → poll → check), microtasks run between phases
-
process.nextTickhas highest priority (use carefully!)
Mental model:
- Sync code runs to completion
- Drain all microtasks
- Take one macrotask
- Drain all microtasks again
- Repeat
Understanding this helps you:
- Predict execution order
- Avoid blocking the event loop
- Debug async race conditions
- Write performant async code
Next time you see a setTimeout or a Promise, you'll know exactly when that code will run - and why.
Top comments (0)