🚀 Beyond the Surface: Understanding the Node.js Event Loop

Most developers use Node.js every day because it is fast and non-blocking.
But if someone asks:
How does Node.js handle thousands of connections with a single thread?
Many developers struggle to explain it clearly.
To truly understand Node.js, we need to go beyond the surface and explore the engine that makes it all possible: the Event Loop.
1️⃣ JavaScript Is Just the Language
One of the biggest “Aha!” moments for many developers is realizing that JavaScript itself is very small.
JavaScript only provides:
variables
functions
objects
syntax
logic
Things like:
setTimeoutfetchconsole.logfile operations
networking
are NOT part of JavaScript.
They come from the runtime environment.
Two common runtimes are:
| Runtime | APIs Provided |
|---|---|
| Browser | DOM APIs, fetch, timers |
| Node.js | File system, networking, timers |
Node.js allows JavaScript to run outside the browser, especially on servers.
To do this efficiently, Node.js relies on a powerful library written in C++ called libuv.
2️⃣ The Role of libuv
libuv is the engine behind Node.js asynchronous behavior.
It provides:
Event Loop implementation
Non-blocking I/O
Thread Pool
Networking operations
File system operations
Because of libuv, Node.js can handle operations like:
reading files
database calls
network requests
timers
without blocking the main thread.
This is what makes Node.js scalable and efficient.
3️⃣ What Happens Before the Event Loop Starts?
Before the Event Loop even begins running, Node.js follows a specific startup process.
Step 1 — Initialize Environment
Node prepares the runtime environment.
Step 2 — Execute Top-Level Code
Your JavaScript file runs line by line.
Step 3 — Register Async Tasks
Async operations are registered with libuv.
Example:
setTimeout(() => console.log("Timer"), 0);
This does not execute immediately.
Instead it gets registered.
Step 4 — Start the Event Loop
Once all synchronous code is executed, the Event Loop starts processing tasks.
4️⃣ Event Loop Architecture
At a high level, Node.js works like this:
JavaScript Code
↓
V8 Engine
↓
Node.js APIs
↓
libuv
↓
Event Loop + Thread Pool
The Event Loop continuously checks queues and executes callbacks.
5️⃣ The 5 Phases of the Event Loop
The Event Loop is not random.
It processes tasks in a strict order of phases.
| Phase | Purpose | Example |
|---|---|---|
| Timers | Execute expired timers | setTimeout, setInterval |
| I/O Callbacks | Process system callbacks | TCP errors |
| Poll | Retrieve new I/O events | File read, network |
| Check | Execute setImmediate callbacks |
setImmediate() |
| Close | Cleanup callbacks | socket.close() |
Think of the event loop like a security guard walking through rooms in a building.
Each room is a phase, and the guard must check them in the same order every time.
6️⃣ Example: Understanding Execution Order
Consider this code:
import fs from "fs";
console.log("Start");
setTimeout(() => {
console.log("Timer");
}, 0);
setImmediate(() => {
console.log("Immediate");
});
fs.readFile("test.txt", () => {
console.log("File Read Complete");
});
console.log("End");
Possible output:
Start
End
Timer or Immediate
File Read Complete
Why?
Because:
1️⃣ Synchronous code runs first
2️⃣ Async tasks are registered
3️⃣ Event loop begins processing phases
7️⃣ The Timer vs Immediate Mystery
This is where many developers get confused.
Example:
setTimeout(() => console.log("Timer"), 0);
setImmediate(() => console.log("Immediate"));
At top level, the order is not guaranteed.
Sometimes you will see:
Timer
Immediate
Sometimes:
Immediate
Timer
This happens because the event loop scheduling depends on process timing.
8️⃣ Inside an I/O Callback
Now consider this:
import fs from "fs";
fs.readFile("test.txt", () => {
setTimeout(() => console.log("Timer"), 0);
setImmediate(() => console.log("Immediate"));
});
Output will always be:
Immediate
Timer
Why?
Because after the Poll phase (file read) the event loop moves directly to the Check phase.
And setImmediate runs in the Check phase.
Timers will execute in the next loop cycle.
9️⃣ Why Does setImmediate Exist?
If we already have setTimeout(fn, 0), why was setImmediate introduced?
Two major reasons:
1️⃣ Run Code After I/O
setImmediate guarantees execution right after I/O completes.
Example:
fs.readFile("data.txt", () => {
setImmediate(() => {
console.log("Process file immediately after read");
});
});
2️⃣ Prevent Event Loop Blocking
Large computations can block the event loop.
Example solution:
function processLargeData(data) {
let chunk = data.splice(0, 1000);
process(chunk);
if (data.length > 0) {
setImmediate(() => processLargeData(data));
}
}
This allows Node.js to process other incoming requests between chunks.
🔟 Thread Pool: The Hidden Workers
Even though Node.js runs JavaScript on a single thread, libuv includes a thread pool.
It handles heavy tasks like:
file system operations
DNS lookups
compression
cryptography
Default thread pool size:
4 threads
This allows Node.js to handle multiple blocking operations simultaneously.
11️⃣ Real-World Example
Imagine a server receiving thousands of requests.
Instead of blocking the thread:
Request arrives
↓
Node registers callback
↓
libuv performs I/O
↓
Event Loop executes callback
This design allows Node.js to scale efficiently.
📌 Key Takeaways
• JavaScript itself does not provide async APIs
• The runtime environment provides them
• Node.js relies on libuv for async operations
• The Event Loop runs tasks in phases
• Understanding phases helps write better asynchronous code
🎯 Final Thoughts
Understanding the Node.js Event Loop completely changed how I write asynchronous code.
Instead of guessing when callbacks will run, I now understand how the system schedules them.
Node.js isn’t magic.
It’s simply a well-designed system of queues, phases, and callbacks working together.
And once you understand it, the entire Node.js ecosystem suddenly becomes much clearer.




