If you've ever wondered why Node.js is considered fast even though it runs JavaScript on a single thread, you've probably come across the term Event Loop.
Many tutorials explain it as a simple cycle of phases. That's useful, but to truly understand Node.js, you need to know why those phases exist and how they work together with V8, Libuv, the Operating System, and the Worker Pool.
Let's break it down step by step.
1. The Core Philosophy: "Don't Wait"
Node.js executes JavaScript on a single thread.
That means it can only execute one piece of JavaScript at a time.
Imagine your server needs to:
- Read a 5 GB file
- Query a slow database
- Wait for an API response
If Node.js waited for each of these operations to finish before doing anything else, your entire server would freeze.
The Solution: Delegation
Think of Node.js as a restaurant manager.
When a customer places an order, the manager doesn't go into the kitchen to cook.
Instead:
- The manager takes the order.
- Hands it to the kitchen.
- Immediately serves the next customer.
The manager never waits for the food to be cooked.
Node.js works exactly the same way.
Whenever possible, it delegates slow operations and continues serving other requests.
2. The Relationship: Node.js vs. V8 vs. Libuv
A common question is:
"Is the Event Loop part of Libuv?"
Yes.
Here's the relationship:
- V8 Engine → The JavaScript engine. It compiles your JavaScript into machine code.
- Libuv → The C/C++ library that provides asynchronous I/O, the Worker Pool, and the Event Loop.
- The Event Loop → The heartbeat inside Libuv. It repeatedly checks for work and executes callbacks in a well-defined order.
When your JavaScript performs an asynchronous operation, Libuv decides who should do the work.
- If the operating system can handle it asynchronously (for example, network sockets), Libuv asks the operating system to do it.
- If the operating system can't handle it asynchronously (such as many file system operations, cryptography, compression, and some DNS operations), Libuv sends the work to its Worker Pool.
3. The Anatomy of the Event Loop
The Event Loop moves through these six phases in order.
Think of this as the engine's daily routine.
1. Timers
The Event Loop checks whether any setTimeout() or setInterval() timers have reached their scheduled time.
If they have, their callbacks are executed.
2. Pending Callbacks
Sometimes the operating system reports special events, such as certain network errors.
Instead of handling them immediately, Node.js places their callbacks into a waiting queue.
When the Event Loop reaches the Pending Callbacks phase, it executes them.
This keeps Node.js organized and ensures these callbacks run in a predictable order.
3. Idle, Prepare
This is an internal housekeeping phase.
Node.js prepares for the next iteration of the Event Loop.
You never write code for this phase—it happens automatically behind the scenes.
4. Poll
This is where the Event Loop spends most of its time.
It retrieves completed I/O events and executes their corresponding callbacks.
Examples of completed I/O events include:
- Finished file reads
- Database responses
- Incoming HTTP requests
- Network events
If there is no work to do, the Poll phase waits efficiently for the operating system to notify it when something new happens.
Instead of constantly checking for new work, Node.js sleeps efficiently and uses almost no CPU while waiting. This is why an idle Node.js server can remain running for days while consuming very little CPU.
5. Check
This phase executes callbacks scheduled with:
setImmediate(() => {
console.log("Runs during the Check phase");
});
setImmediate() callbacks always run after the Poll phase finishes.
6. Close Callbacks
This is the cleanup phase.
When resources such as sockets or streams are closed, callbacks registered with:
socket.on("close", () => {
console.log("Connection closed");
});
are executed here.
4. What Happens When the Server Is Idle?
One of the most common questions is:
"If nobody sends requests to my server, does the Event Loop keep spinning?"
No.
If there is no work to do, Node.js does not waste CPU cycles.
Instead, when the Event Loop reaches the Poll phase, it asks the operating system to wait.
It follows two simple rules.
If a timer exists
Node.js calculates when the nearest timer should run and tells the operating system:
"Wake me up when this timer expires."
If no timer exists
Node.js tells the operating system:
"Wake me up only when a new event arrives."
While waiting, the operating system watches for things like:
- New HTTP requests
- Network activity
- File I/O completion
Node.js itself is not constantly checking.
The operating system does the watching.
On Linux this is done using epoll.
On macOS it uses kqueue.
On Windows it uses I/O Completion Ports (IOCP).
When an event occurs (or the next timer expires), the operating system wakes Node.js, and the Event Loop continues processing.
This is why an idle Node.js server consumes almost no CPU.
5. Why Does the Pending Callbacks Phase Exist?
A common misunderstanding is that this phase is simply an "error handler."
It isn't.
It exists for certain deferred system-level callbacks.
Imagine the operating system reports a special socket event while JavaScript is already running.
Rather than trying to execute that callback immediately, Node.js places it into the Pending Callbacks queue.
When the Event Loop reaches that phase, it executes those callbacks.
This keeps execution organized and predictable.
6. The Missing Piece: The Worker Pool
One important thing to understand is that the Event Loop is not the worker.
Its job is to execute JavaScript callbacks when they are ready to run.
For certain operations, such as many file system tasks, cryptographic operations, compression, and certain DNS lookups, Libuv delegates the work to its Worker Pool.
JavaScript
│
▼
V8
│
▼
Libuv
│
▼
Worker Pool
│
(Task completes)
│
▼
Callback queued
│
▼
Event Loop
│
▼
Your callback runs
While the Worker Pool performs the task, the Event Loop remains free to serve other users.
This is one reason Node.js scales so well.
Remember, not every asynchronous operation uses the Worker Pool. For example, network operations are typically handled directly by the operating system.
7. The Biggest Gotcha: Blocking the Event Loop
The Event Loop only works well if it stays free.
Suppose you write:
while (true) {}
or perform a massive CPU-intensive calculation directly in JavaScript.
Now the Event Loop can't continue.
No requests are processed.
No timers run.
No callbacks execute.
Everything waits until your code finishes.
Think of it like locking the restaurant manager inside the office.
Customers continue arriving, but nobody is available to serve them.
This is known as blocking the Event Loop.
8. Summary
| Phase | What Runs Here? | Why It Exists |
|---|---|---|
| Timers |
setTimeout(), setInterval()
|
Execute scheduled callbacks |
| Pending Callbacks | Certain deferred system callbacks | Keep system callbacks organized and predictable |
| Idle, Prepare | Internal Node.js work | Prepare for the next loop iteration |
| Poll | I/O callbacks, network events | Process completed I/O and wait efficiently for new events |
| Check | setImmediate() |
Execute callbacks immediately after Poll |
| Close Callbacks |
.on('close') callbacks |
Clean up closed resources |
Final Thoughts
The Event Loop is much more than a list of six phases.
Think of it as the engine that keeps Node.js moving.
It continuously moves through its phases, executing callbacks whenever work is available. When there isn't any work, it waits efficiently inside the Poll phase until the operating system reports a new event.
Combined with Libuv, the Worker Pool, and the operating system's asynchronous capabilities, the Event Loop enables a single JavaScript thread to handle thousands of concurrent connections without wasting CPU.
That's one of the biggest reasons Node.js can handle thousands of concurrent connections efficiently.
Top comments (0)