The Node.js Event Loop Explained

The event loop is the heart of Node.js. It's what makes Node.js single-threaded yet capable of handling thousands of concurrent requests. Understanding it is crucial to writing efficient Node.js applications.
Why Node.js Needs an Event Loop
Node.js runs on a single thread. There's one thread executing your JavaScript code. But a single thread would be useless if it blocked on every slow operation.
The event loop is the solution. It lets you execute code non-blocking. While waiting for operations to complete, the event loop checks what's ready and executes callbacks.
The Basic Concept
Imagine a task manager:
You give the manager tasks
The manager executes tasks one by one
For tasks that take time (like filing documents), the manager delegates to workers
Once workers finish, the manager executes the callback
The manager never sits idle waiting
This is the event loop.
How the Event Loop Works
When you run Node.js:
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 100);
console.log('End');
Output:
Start
End
Timeout
Here's what happens behind the scenes:
1. Main JavaScript code executes
- Logs "Start"
- setTimeout schedules callback (doesn't execute now)
- Logs "End"
- Main code complete
2. Event loop checks: Is anything ready?
- 100ms haven't passed, nothing ready
3. 100ms passes
4. Event loop checks: Is anything ready?
- Timeout callback is ready! Execute it
- Logs "Timeout"
5. No more callbacks, event loop waits
The event loop is constantly checking: "Is there anything I need to do?"
Call Stack and Callback Queue
The event loop manages two things:
Call Stack: Where your code executes
Callback Queue: Where callbacks wait to execute
Your Code
|
v
Call Stack (currently executing)
|
v (if done)
Event Loop (checks: anything in queue?)
|
v
Callback Queue (waiting callbacks)
When the call stack is empty, the event loop picks a callback from the queue and executes it.
Visualizing the Event Loop
Here's a more detailed flow:
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
Timeline:
Time 0ms:
- Call Stack: [console.log('1')]
- Execute: Logs '1'
- Call Stack is empty
Time 0ms:
- Call Stack: [setTimeout callback scheduled]
- Callback added to queue
- Call Stack is empty
Time 0ms:
- Call Stack: [console.log('3')]
- Execute: Logs '3'
- Call Stack is empty
Time 0ms (main code done):
- Event Loop checks queue
- Callback queue has the setTimeout callback
- Move callback to call stack
- Execute: Logs '2'
- Call Stack is empty
Done
Output:
1
3
2
Even though setTimeout had 0ms delay, it doesn't execute immediately. It's always after the main code finishes.
Phases of the Event Loop
The event loop has several phases. Each phase handles different types of operations:
┌───────────────────────────┐
│ Event Loop Phases │
├───────────────────────────┤
│ 1. Timers │ (setTimeout, setInterval)
│ 2. Pending Callbacks │ (OS operations)
│ 3. Idle, Prepare │ (Internal)
│ 4. Poll │ (I/O operations)
│ 5. Check │ (setImmediate)
│ 6. Close Callbacks │ (Socket close)
└───────────────────────────┘
The event loop cycles through these phases repeatedly.
File I/O and the Event Loop
File operations demonstrate the event loop perfectly:
import fs from 'fs';
console.log('Start');
fs.readFile('file.txt', (err, data) => {
console.log('File read');
});
console.log('End');
Output:
Start
End
File read
Timeline:
1. Main code starts
2. Logs "Start"
3. fs.readFile called - work delegated to file system worker
4. Logs "End"
5. Main code done
6. Event loop checks: Is file read done?
7. No, not yet
8. ...time passes...
9. Event loop checks: Is file read done?
10. Yes! Add callback to queue
11. Event loop executes callback from queue
12. Logs "File read"
Your code never waits. The file is read in the background while your code continues.
setTimeout vs setImmediate
Both defer execution, but they're in different phases:
setImmediate(() => console.log('Immediate'));
setTimeout(() => console.log('Timeout'), 0);
Output (usually):
Timeout
Immediate
setTimeout executes in the Timers phase, setImmediate in the Check phase. The event loop visits Timers first.
Microtasks vs Macrotasks
There's a subtle complexity: microtasks and macrotasks.
Microtasks (execute immediately after each operation):
Promises (.then, .catch, .finally)
process.nextTick()
Macrotasks (queued in phases):
setTimeout
setInterval
setImmediate
I/O operations
Here's the key: After each macrotask, the event loop executes ALL microtasks before moving to the next macrotask.
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 0);
Promise.resolve()
.then(() => console.log('Promise 1'))
.then(() => console.log('Promise 2'));
console.log('End');
Output:
Start
End
Promise 1
Promise 2
Timeout
Timeline:
1. Main code: Logs "Start"
2. Main code: setTimeout scheduled (to Macrotask queue)
3. Main code: Promises created and scheduled (to Microtask queue)
4. Main code: Logs "End"
5. Main code done
6. Event loop checks: Any microtasks?
7. Yes! Execute Promise .then
8. Logs "Promise 1"
9. Microtask queue still has one
10. Execute it
11. Logs "Promise 2"
12. No more microtasks
13. Event loop checks: Any macrotasks?
14. Yes! setTimeout callback
15. Execute it
16. Logs "Timeout"
Promises always execute before setTimeout, even with 0ms delay!
Practical Example
Here's a realistic scenario:
import fs from 'fs/promises';
console.log('1: Start');
setTimeout(() => {
console.log('2: setTimeout');
}, 0);
fs.readFile('file.txt', 'utf8')
.then(data => {
console.log('3: File read (Promise)');
});
Promise.resolve()
.then(() => console.log('4: Promise'));
console.log('5: End');
Output:
1: Start
5: End
4: Promise
3: File read (Promise)
2: setTimeout
Explanation:
Synchronous code runs first (1, 5)
Microtasks run (4)
File read completes and resolves (3) - still a microtask
Macrotasks run (2)
Common Mistakes
Mistake 1: Assuming setTimeout runs immediately
setTimeout(() => {
console.log('runs second');
}, 0);
console.log('runs first');
Even with 0ms, setTimeout callback doesn't run immediately. It waits until the main code finishes.
Mistake 2: Long-running operations block the event loop
// Bad - blocks event loop for 5 seconds
function expensive() {
for (let i = 0; i < 1_000_000_000; i++) {
Math.sqrt(i);
}
}
setTimeout(() => {
expensive(); // Blocks entire event loop
}, 100);
// While expensive() runs, no other callbacks execute
Mistake 3: Thinking setImmediate is faster
setImmediate(() => console.log('1'));
setTimeout(() => console.log('2'), 0);
setImmediate doesn't necessarily run first. It depends on whether the current phase is the Check phase.
Debugging the Event Loop
You can visualize event loop behavior:
const start = Date.now();
setTimeout(() => {
console.log(`Timer: ${Date.now() - start}ms`);
}, 100);
Promise.resolve()
.then(() => console.log(`Promise 1: ${Date.now() - start}ms`))
.then(() => console.log(`Promise 2: ${Date.now() - start}ms`));
console.log(`Main: ${Date.now() - start}ms`);
Output:
Main: 0ms
Promise 1: 0ms
Promise 2: 0ms
Timer: 102ms
The promises run immediately (microtasks), the timer waits for its delay.
Key Takeaways
The event loop continuously checks what work is ready to do
Your JavaScript code runs on the main thread, blocking the event loop
I/O operations run on background workers, not blocking the event loop
Callbacks wait in queues until the event loop picks them up
Microtasks (Promises) always run before macrotasks (timers, I/O)
Even
setTimeout(..., 0)doesn't run immediatelyLong-running code blocks the entire event loop
Understanding the event loop is key to writing efficient Node.js
The event loop isn't magic. It's a simple idea: check what's ready, execute it, repeat. Master this concept, and you'll write Node.js code that scales and performs well.




