The Event Loop
The event loop is the heart of Node.js. It enables JavaScript to perform non-blocking operations despite being single-threaded. Understanding the event loop is crucial for writing efficient Node.js applications and debugging asynchronous behavior.
JavaScript is Single-Threaded
JavaScript runs on a single thread, meaning it can only do one thing at a time:
console.log('First');
console.log('Second');
console.log('Third');
// Always outputs: First, Second, Third
But how does Node.js handle multiple concurrent operations like network requests and file I/O? The answer is the event loop.
The Call Stack
The call stack tracks what function is currently executing:
function first() {
console.log('first');
second();
}
function second() {
console.log('second');
third();
}
function third() {
console.log('third');
}
first();
Stack progression:
first()added to stacksecond()added to stackthird()added to stackthird()completes, removed from stacksecond()completes, removed from stackfirst()completes, removed from stack
How the Event Loop Works
The event loop continuously checks if the call stack is empty. If it is, it takes the first task from the queue and executes it.
┌───────────────────────────┐
┌─>│ timers │ (setTimeout, setInterval)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ (I/O callbacks)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │ (setImmediate)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
Task Queue (Callback Queue)
When async operations complete, their callbacks are added to the task queue:
console.log('Start');
setTimeout(() => {
console.log('Timeout callback');
}, 0);
console.log('End');
// Output:
// Start
// End
// Timeout callback (even with 0ms delay!)
Even with 0ms delay, the callback is queued and runs after the current code finishes.
Microtasks vs Macrotasks
Not all async operations are equal. JavaScript has two types of task queues:
Microtasks (higher priority):
- Promise callbacks (.then, .catch, .finally)
- queueMicrotask()
- process.nextTick() (Node.js specific)
Macrotasks (lower priority):
- setTimeout, setInterval
- setImmediate (Node.js)
- I/O operations
- UI rendering (browsers)
Microtasks run before macrotasks!
console.log('1. Start');
setTimeout(() => console.log('4. Timeout'), 0);
Promise.resolve().then(() => console.log('3. Promise'));
console.log('2. End');
// Output: 1, 2, 3, 4
// Promise runs BEFORE timeout!
process.nextTick()
Node.js has process.nextTick() which runs even before Promise microtasks:
console.log('Start');
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
console.log('End');
// Output: Start, End, nextTick, Promise
Priority order:
- Synchronous code
- process.nextTick() callbacks
- Promise microtasks
- Macrotasks (setTimeout, I/O, etc.)
Visualizing the Event Loop
// Event loop visualization
while (true) {
// 1. Execute all sync code (call stack)
// 2. Execute all microtasks
while (microtaskQueue.length > 0) {
executeMicrotask();
}
// 3. Execute one macrotask
if (macrotaskQueue.length > 0) {
executeMacrotask();
}
// 4. Repeat
}
Blocking the Event Loop
Since JavaScript is single-threaded, CPU-intensive operations block everything:
// This blocks the event loop!
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
console.log('Start');
console.log(fibonacci(45)); // Blocks for several seconds!
console.log('End');
Non-Blocking Patterns
Break Up Long Operations
function processLargeArray(items, callback) {
let index = 0;
function processChunk() {
const chunkSize = 100;
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
processItem(items[index]);
}
if (index < items.length) {
setImmediate(processChunk); // Allow other tasks to run
} else {
callback();
}
}
processChunk();
}
Use Worker Threads for CPU-Intensive Work
const { Worker } = require('worker_threads');
const worker = new Worker('./heavy-computation.js');
worker.on('message', (result) => {
console.log('Result:', result);
});
worker.postMessage({ data: largeDataSet });
setImmediate vs setTimeout(0)
In Node.js, setImmediate and setTimeout(0) behave slightly differently:
// Order can vary at top level!
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Inside I/O callback, setImmediate is always first
const fs = require('fs');
fs.readFile('file.txt', () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Always: immediate, timeout
});
Event Loop Phases (Node.js)
- Timers: Execute setTimeout and setInterval callbacks
- Pending callbacks: Execute I/O callbacks deferred from previous iteration
- Idle, prepare: Internal use only
- Poll: Retrieve new I/O events; execute I/O related callbacks
- Check: Execute setImmediate callbacks
- Close callbacks: Execute close event callbacks
Practical Implications
1. Keep Operations Small
// Bad - blocks for too long
app.get('/heavy', (req, res) => {
const result = heavyComputation(); // Blocks!
res.json(result);
});
// Good - use async or workers
app.get('/heavy', async (req, res) => {
const result = await runInWorker(heavyComputation);
res.json(result);
});
2. Use Async I/O
// Bad - blocking I/O
const data = fs.readFileSync('large-file.txt');
// Good - async I/O
const data = await fs.promises.readFile('large-file.txt');
3. Handle Errors Properly
// Unhandled Promise rejections can crash Node.js!
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
});
Key Takeaways
- JavaScript is single-threaded but handles concurrency via the event loop
- The call stack executes synchronous code
- Async callbacks are queued and executed when the stack is empty
- Microtasks (Promises) run before macrotasks (setTimeout)
- process.nextTick() runs before Promise microtasks
- Blocking the event loop prevents all callbacks from running
- Use async I/O and break up CPU-intensive work
- Understanding the event loop helps debug timing issues
Summary
The event loop is what makes Node.js powerful for I/O-heavy applications. You now understand how the call stack, task queue, and microtask queue work together to enable non-blocking asynchronous programming. This knowledge is essential for writing efficient Node.js applications and debugging complex async behavior.
In the next module, you'll apply these async concepts to build a REST API.

