Node.js Event Loop Explained for Beginners
You've probably heard that Node.js is single-threaded and uses an event loop to handle thousands of concurrent connections. But what does that actually mean? If you're new to Node.js, the event loop can seem like a mysterious black box. This article breaks it down in plain language, so you can write better, faster Node.js code.
Why the Event Loop Exists
Traditional web servers (like Apache with PHP) create a new thread or process for each request. That works, but it doesn't scale well when you have many concurrent connections—each thread consumes memory and CPU. Node.js takes a different approach: it runs your JavaScript in a single thread and uses an event loop to handle I/O operations asynchronously. This means your code doesn't wait for a database query or file read to finish; it continues executing other tasks and comes back when the result is ready.
The Core Components
Before diving into the event loop, let's clarify the key players:
- Call Stack: Where your synchronous JavaScript code runs. Functions are pushed and popped as they execute.
- Node APIs: C++ APIs that handle expensive operations like file I/O, network requests, and timers. They run in the background (using the libuv thread pool).
- Callback Queue: Where callbacks from completed Node API operations wait to be executed.
- Event Loop: The orchestrator that continuously checks if the call stack is empty and, if so, moves callbacks from the queue to the stack.
How the Event Loop Works: A Simple Example
Consider this code:
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 0);
console.log('End');
What's the output? If you guessed Start, End, Timeout, you're right. Even though the timeout is 0 milliseconds, the callback is not executed immediately. Here's what happens:
console.log('Start')is pushed to the call stack, executed, and popped.setTimeoutis called. Node.js registers the timer and sets it to expire after 0ms. The callback is stored, and the function returns.console.log('End')is pushed, executed, and popped.- Now the call stack is empty. The event loop checks the timer phase and sees the timer has expired. It moves the callback to the call stack, which logs
Timeout.
This demonstrates the non-blocking nature: the timer doesn't block the rest of the code.
The Phases of the Event Loop
The event loop processes callbacks in several phases, each with a specific purpose. Understanding these phases helps you predict execution order.
| Phase | Description |
|---|---|
| Timers | Executes callbacks scheduled by setTimeout() and setInterval(). |
| Pending Callbacks | Executes I/O callbacks deferred to the next loop iteration. |
| Idle, Prepare | Used internally by Node.js. |
| Poll | Retrieves new I/O events; executes I/O related callbacks (almost all except timers, setImmediate, and close callbacks). |
| Check | Executes callbacks scheduled by setImmediate(). |
| Close Callbacks | Executes close callbacks, e.g., socket.on('close', ...). |
Between each phase, Node.js checks for microtasks: process.nextTick() callbacks and Promises. These have higher priority and are executed immediately after the current operation completes, before moving to the next phase.
Microtasks: nextTick and Promises
Microtasks are not part of the event loop phases; they are processed after each phase and after each callback. This makes them run before timers and I/O callbacks. For example:
setTimeout(() => console.log('Timeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
console.log('Sync');
Output: Sync, nextTick, Promise, Timeout. The synchronous code runs first, then microtasks (nextTick before Promises), then the timer.
Warning: Recursive process.nextTick() calls can starve the event loop, preventing I/O from happening. Use setImmediate() for recursive operations.
setImmediate vs setTimeout
setImmediate() is designed to execute a callback immediately after the current poll phase completes. In contrast, setTimeout() with 0ms waits for the next timer phase. The order between them can vary depending on the context, but inside an I/O callback, setImmediate() always runs first.
Why This Matters for Your Code
Understanding the event loop helps you avoid common pitfalls:
- Don't block the event loop: Synchronous operations like
fs.readFileSyncor heavy computations block the entire loop, making your app unresponsive. Use asynchronous versions or offload to worker threads. - Use microtasks wisely: They run before I/O, so heavy microtask processing can delay I/O callbacks.
- Understand callback ordering: When mixing timers, Promises, and I/O, know the phases to predict output.
Practical Example: Non-Blocking File Read
Here's how the event loop enables non-blocking I/O:
const fs = require('fs');
console.log('Before read');
fs.readFile('large-file.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log('File read complete');
});
console.log('After read');
Output: Before read, After read, then File read complete. The file read happens in the background, and the callback is queued when done. Meanwhile, other code can run.
FAQ
Is Node.js truly single-threaded?
Node.js runs your JavaScript in a single thread, but it uses multiple threads in the libuv thread pool for file I/O, DNS, and other operations. So it's not entirely single-threaded under the hood.
What is the difference between the call stack and the event loop?
The call stack executes synchronous code. The event loop manages asynchronous callbacks, moving them from queues to the call stack when it's empty.
Can I create multiple event loops?
No, each Node.js process has one event loop. However, you can use worker threads to run separate JavaScript threads, each with its own event loop.
If you're working with server logs to debug performance issues, try our Nginx Log Analyzer to quickly parse and visualize log data.