Node.js
Part 1 — Understanding the Node.js Event Loop: The Engine Behind Async JavaScript
A deep technical explanation of the Node.js event loop, how asynchronous execution works internally, how libuv manages operations, and how understanding runtime behavior helps engineers build high-performance and scalable backend systems.
By Ishan Wickremasuriya
Published 2026-06-16 • 16 min read
Introduction: Why Does Node.js Need an Event Loop?
When a traditional backend application receives a request, a common model is:
Client Request
|
v
Backend Thread
|
v
Database Query
|
v
Response
The thread waits while the database is working.
Example:
const user = database.getUser(id);
return user;
The application cannot continue until the database responds.
If 10,000 users send requests at the same time:
Request 1 -> Thread waiting
Request 2 -> Thread waiting
Request 3 -> Thread waiting
...
Request 10000 -> Thread waiting
A common solution is to assign one thread per request:
1 Request = 1 Thread
But creating thousands of threads has real costs:
- Memory usage per thread
- Context switching overhead
- CPU scheduling complexity
- Thread lifecycle management
Node.js took a fundamentally different approach.
Instead of waiting, it says:
"Start the operation, continue doing other work, and come back when the result is ready."
This idea is the foundation of the event loop.
JavaScript Is Single Threaded — But Node.js Is Not
A common misunderstanding:
"Node.js is single threaded."
This statement is incomplete.
The JavaScript execution environment is single threaded. That means only one JavaScript instruction executes at a time.
Example:
console.log("A");
console.log("B");
console.log("C");
There is no parallel JavaScript execution — each line finishes before the next begins.
But Node.js itself is composed of several layers:
- V8 JavaScript engine
- Call stack
- Event Loop
- libuv runtime
- Operating system async APIs
- Background worker threads
Architecture:
Node.js Application
┌──────────────────────┐
│ JavaScript Code │
└──────────┬───────────┘
│
v
┌──────────────────────┐
│ V8 Engine + Stack │
└──────────┬───────────┘
│
v
┌──────────────────────┐
│ Event Loop │
└────────┬─────┬───────┘
│ │
┌─────────┘ └──────────┐
v v
Async OS Operations libuv Thread Pool
├── File System ├── Crypto
├── Network ├── Compression
└── DNS └── Large I/O
The JavaScript thread coordinates work. It does not perform every operation itself. To understand how it coordinates that work, we need to look at four building blocks in order: the call stack, Web APIs vs Node APIs, libuv, and the event loop phases.
The Call Stack: Where JavaScript Actually Executes
Every JavaScript engine — whether in a browser or in Node.js — runs code using a call stack.
A call stack is a Last-In-First-Out (LIFO) structure. When a function is called, it is pushed onto the stack. When it returns, it is popped off.
function third() {
console.log("Third");
}
function second() {
third();
}
function first() {
second();
}
first();
Execution, frame by frame:
Call Stack (grows downward as functions are called)
first()
└── second()
└── third()
└── console.log("Third") → Third
◄── third() returns, popped
◄── second() returns, popped
first() returns, popped
Stack is now empty
Two things matter here for understanding the event loop:
- Only one stack exists. This is the literal meaning of "single threaded" — there is one call stack, and JavaScript can only execute what's on it.
- The event loop only acts when the stack is empty. No callback, timer, or I/O result can be processed while the call stack has anything on it. This is why a long synchronous function — even one with no bugs — can make an entire server feel frozen: nothing else gets a turn until the stack clears.
This single fact explains nearly every "why didn't my callback run on time" question a Node.js developer will ever ask.
Web APIs vs Node APIs: Same Language, Different Engines
If you've written JavaScript in a browser before Node.js, you've already used
asynchronous APIs — setTimeout, fetch, DOM events. It's easy to assume these
work identically in Node.js. They don't, because they aren't part of JavaScript at all.
The ECMAScript specification (the actual definition of the JavaScript language)
does not include setTimeout, fetch, or document.
These are host environment APIs — provided by whatever runtime is executing
the JavaScript, not by JavaScript itself.
JavaScript Language (ECMAScript)
Variables, functions, objects, promises, syntax
│
┌───────────────┴───────────────┐
v v
Browser Host Node.js Host
┌─────────────────┐ ┌─────────────────┐
│ Web APIs │ │ Node APIs │
│ setTimeout │ │ setTimeout │
│ fetch │ │ fs.readFile │
│ DOM, document │ │ http, net │
│ XMLHttpRequest │ │ process │
│ Web Workers │ │ Worker Threads │
└─────────────────┘ └─────────────────┘
This distinction matters practically:
| Browser | Node.js | |
|---|---|---|
setTimeout | Implemented by the browser's task scheduler | Implemented by libuv's timer system |
| Network requests | fetch, XMLHttpRequest | http, https, or libuv-backed libraries |
| File access | Not available (sandboxed) | fs module, backed by libuv |
| Parallel work | Web Workers | Worker Threads, Child Processes |
Both environments give JavaScript an event loop to come back to once async work finishes — but the mechanism behind that loop is entirely different. In the browser, it's the browser engine. In Node.js, it's libuv.
libuv: The Engine Behind Node's Async I/O
libuv is a C library, originally built for Node.js, that gives JavaScript access to asynchronous, non-blocking I/O regardless of the underlying operating system.
Why does Node.js need this at all? Because operating systems don't all handle async I/O the same way:
Linux → epoll
macOS → kqueue
Windows → IOCP (I/O Completion Ports)
Without libuv, Node.js would need separate async implementations for every OS it supports. libuv abstracts these differences behind one consistent interface, so the JavaScript layer never has to know which operating system it's running on.
libuv is responsible for:
- The event loop itself — the phase-based loop described below is literally implemented inside libuv
- The thread pool — a fixed-size pool (default: 4 threads) used for operations the OS can't do asynchronously on its own, such as certain file system calls, DNS lookups, and CPU-bound crypto functions
- Timers — backing
setTimeoutandsetInterval - Network and file I/O — delegating to the OS's native async mechanism where available
libuv
┌─────────────────────────────────────┐
│ Event Loop │
│ (timers, poll, check, close...) │
└──────────────┬──────────────────────┘
│
┌──────────┴──────────┐
v v
OS Async APIs Thread Pool (4 threads default)
(network sockets, (fs operations, DNS lookups,
handled natively) some crypto, compression)
This is the piece most tutorials skip — and it's the reason "I/O is cheap" is true. Most I/O doesn't even need a thread; the OS handles it natively and simply notifies libuv when it's done. Only the operations the OS can't do async-natively get handed to the thread pool.
The Core Problem: Waiting
Consider a simple API endpoint:
app.get("/users", async (req, res) => {
const users = await database.getUsers();
res.json(users);
});
A beginner may think:
"While waiting for the database, Node.js is blocked."
Actually, this is what happens:
Request arrives
|
v
JavaScript initiates the database call
|
v
Call stack frame for this request completes — popped off the stack
|
v
Database operation is handed to libuv (OS async I/O or thread pool)
|
v
Event loop continues handling other incoming requests
|
v
Database finishes — libuv places the callback in the appropriate queue
|
v
Event loop picks it up, pushes it onto the call stack, executes it
|
v
Response is sent
The key insight:
Waiting does not block Node.js. Doing CPU work blocks Node.js.
This distinction is everything.
The Event Loop Mental Model
Think about a restaurant.
The waiter represents the JavaScript thread and its call stack.
A good waiter:
- Takes an order
- Sends it to the kitchen (libuv)
- Serves other customers in the meantime
- Receives a notification when the dish is ready
- Delivers it
Take order → Send to kitchen → Serve other customers
|
Notification: food is ready
|
Deliver dish
A bad waiter stands inside the kitchen waiting until cooking is done. One customer blocks everyone — because the waiter, like the call stack, can only do one thing at a time.
What Actually Happens When Code Runs?
Let's walk through a concrete example that surprises many developers:
console.log("Start");
setTimeout(() => {
console.log("Timer finished");
}, 0);
console.log("End");
Many developers expect:
Start
Timer finished
End
Because the timeout is zero milliseconds — so it should run immediately, right?
But the actual output is:
Start
End
Timer finished
Let's trace this using what we now know about the call stack and libuv.
Step 1 — Synchronous code runs first
console.log("Start") is pushed onto the call stack, runs, and is popped off. Output: Start.
Step 2 — Timer is registered, not executed
setTimeout(...) is a Node API. Calling it pushes a frame onto the stack briefly — just long enough to hand the callback and delay to libuv's timer system — then that frame is popped. The callback itself does not run yet.
libuv Timer System
┌───────────────────────────────┐
│ callback() — fire after 0ms │
└───────────────────────────────┘
Step 3 — Synchronous code continues
console.log("End") runs the same way. Output: End. The call stack is now empty.
Step 4 — Event loop takes over
With the call stack empty, the event loop checks its phases for ready callbacks.
The timer callback — even at 0ms — has to wait for the current synchronous
execution to finish completely, because the call stack takes priority over every queue.
Once empty, the callback is pushed onto the stack and runs.
Start
End
Timer finished
Event Loop Phases
The event loop is not one infinite queue. It moves through distinct phases in a fixed order, and each phase has its own queue of callbacks to process.
┌─────────────────────────────────────────┐
│ timers │
│ setTimeout / setInterval │
└──────────────────┬──────────────────────┘
│
┌──────────────────v──────────────────────┐
│ pending callbacks │
│ I/O errors from previous cycle │
└──────────────────┬──────────────────────┘
│
┌──────────────────v──────────────────────┐
│ poll │
│ Retrieve new I/O events │
│ Network / File System callbacks │
└──────────────────┬──────────────────────┘
│
┌──────────────────v──────────────────────┐
│ check │
│ setImmediate │
└──────────────────┬──────────────────────┘
│
┌──────────────────v──────────────────────┐
│ close events │
│ socket.on('close', ...) │
└─────────────────────────────────────────┘
The event loop moves through these phases repeatedly. At the end of each phase — and between phases — it drains the microtask queue before continuing.
These phases are exactly what libuv implements internally. Every callback that runs in one of these phases is collectively known as a macrotask — a term worth holding onto, because it's the direct counterpart to the microtask queue covered next.
Microtasks vs Macrotasks: The Priority System
This is where Node.js execution order becomes subtle, and where browser-JS vocabulary becomes genuinely useful.
Macrotasks are callbacks that run as part of an event loop phase: timers,
I/O callbacks, setImmediate, close events. Each macrotask runs to completion,
one at a time, and the event loop only moves to the next phase once the current
one's macrotasks are processed (subject to per-phase limits).
Microtasks are a separate, higher-priority queue: Promise.then/catch/finally
callbacks and queueMicrotask. process.nextTick is Node-specific and sits at
an even higher priority than Promise microtasks.
console.log("A");
setTimeout(() => {
console.log("B"); // macrotask
}, 0);
Promise.resolve().then(() => {
console.log("C"); // microtask
});
console.log("D");
Output:
A
D
C
B
Why does C (a microtask) run before B (a macrotask), even though both were
scheduled with effectively zero delay?
Because the microtask queue is fully drained before the event loop is allowed
to move into its next phase — including the timers phase where B is waiting.
Priority, from highest to lowest:
1. Current synchronous code (the call stack)
│
v
2. process.nextTick() callbacks
│
v
3. Promise microtasks (.then/.catch/.finally, queueMicrotask)
│
v
4. Macrotasks — event loop phases (timers, poll, check...)
Practical implication: if a microtask queues another microtask
(a Promise chain that keeps resolving into more Promises), the event loop
cannot proceed to the next macrotask phase until that chain stops.
This means timers, incoming I/O callbacks, and setImmediate
can all be starved — not by CPU-heavy code, but by an excess of chained microtasks.
This is one of the more subtle ways a Node.js application's responsiveness
can degrade without any single function looking like the obvious cause.
Summary: Part 1
| Concept | Key Point |
|---|---|
| Call stack | Single, LIFO — only one frame executes at a time; event loop waits for it to empty |
| Web APIs vs Node APIs | setTimeout, fetch, etc. are host-provided, not part of JavaScript itself |
| llibuvibuv | Abstracts OS-level async I/O differences (epoll/kqueue/IOCP); runs the event loop and thread pool |
| I/O operations | Delegated to libuv — JavaScript thread stays free |
| CPU operations | Executed directly on the call stack — blocking |
| Event loop phases | Timer → Pending → Poll → Check → Close (each phase = macrotasks) |
| Microtask queue | Drained completely between phases — higher priority than macrotasks |
process.nextTick | Highest priority of all — even above Promise microtasks |
Understanding the Event Loop Is the Beginning, Not the Goal
Knowing how the event loop works is not only useful for explaining why one callback executes before another.
The real engineering value is the ability to predict:
- Will this code block the application?
- Will this operation slow down every user simultaneously?
- Should this task run inside the request lifecycle?
- Should this work be moved to a background process?
- Is asynchronous programming actually helping here, or just creating the illusion of it?
A Node.js application is efficient because it spends most of its time coordinating work — call stack, libuv, and event loop phases working together — rather than performing heavy work itself.
The event loop is built around one core principle:
Keep the call stack and JavaScript execution thread available.
Every time the call stack stays occupied — by CPU work, by a runaway microtask chain, by anything that doesn't hand off to libuv — Node.js loses its biggest architectural advantage.
A healthy Node.js application:
Incoming Request
|
v
Start Operation
|
v
Delegate Waiting Work to libuv (I/O, DB, network)
|
v
Call stack empties — Event Loop Handles Other Requests
|
v
Resume When Result Is Ready
|
v
Send Response
A problematic Node.js application:
Incoming Request
|
v
Heavy CPU Processing on the Call Stack
|
v
Call Stack Never Empties
|
v
All Other Requests Wait
|
v
Response Eventually Sent
The difference between these two designs is not syntax. It is not framework choice. It is not which library you use.
It is engineering understanding — knowing exactly what's happening at the call stack, libuv, and event loop level when your code runs.
That understanding is also where the real production risk lives. Everything in Part 1 has been about how the event loop works. Part 2 is about how that knowledge fails in practice — the specific patterns that quietly occupy the call stack, starve other requests, and degrade a Node.js application under real traffic without ever throwing an error.
What's Coming in Part 2
"Part 2 — Writing Production Code With the Event Loop in Mind"
Now that the internals are clear, Part 2 turns to the patterns that exploit — or violate — everything covered here:
- Avoiding blocking the event loop — recognizing code that occupies the call stack longer than it should
- CPU-heavy operations — where they hide in seemingly ordinary backend code
- Synchronous APIs — the Node.js standard library functions that quietly skip libuv entirely (
fs.readFileSyncand friends) - JSON processing problems — why
JSON.parse/JSON.stringifyon large payloads is a common, invisible bottleneck - Regular expression traps — catastrophic backtracking and how a single regex can freeze a server
- Large loops — when iteration itself becomes the blocking operation
- Memory pressure — how allocation patterns interact with garbage collection and event loop responsiveness
- Async patterns — practical techniques (chunking, Worker Threads, queues) for keeping the call stack clear under real load
If Part 1 was about understanding the engine, Part 2 is about driving it without stalling.