The Node.js runtime and event loop
Understand how Node.js runs JavaScript: V8, libuv, the call stack, and the event loop.
On this page
- 1. Core Principle: Node Has Only 1 Thread Executing JavaScript
- 2. libuv: The Engine Behind "Non-Blocking I/O"
- 3. Distinguishing the 2 Types of I/O (Most Common Confusion)
- 4. The Biggest Trap: CPU-Bound Tasks Block the Main Thread
- 5. Solution for CPU-Bound Work: workerthreads
- 6. Microtasks, Macrotasks, and process.nextTick (vs. Frontend)
- 7. Comprehensive Mental Model
Summary: Node.js, Event Loop, I/O, and Threading
Node.js Core Fundamentals
1. Core Principle: Node Has Only 1 Thread Executing JavaScript
- The JavaScript engine (V8) only executes pure JS code; it does not know how to read files or handle network requests by itself—that is not the job of a programming language.
- All of your JS code (route handlers, business logic, loops, etc.) executes on a single main thread.
- This single-threaded nature is the root of both Node's primary strengths and its biggest traps.
2. libuv: The Engine Behind "Non-Blocking I/O"
libuv is a C library embedded in the Node runtime that plays a role similar to Web APIs in the browser (frontend).
libuv manages two key components:
- Event Loop: A multi-phase loop (timers, pending callbacks, poll, check, close callbacks, etc.) that continuously checks whether asynchronous operations have finished so it can push callbacks to the main thread.
- Thread Pool: A pool of auxiliary worker threads (defaults to 4 threads) used for types of I/O where the OS does not provide true asynchronous/non-blocking support.
3. Distinguishing the 2 Types of I/O (Most Common Confusion)
| I/O Type | Examples | Handling Mechanism | Consumes Thread Pool? |
|---|---|---|---|
| Network I/O | fetch(), API calls, database network queries | The OS uses native kernel mechanisms (epoll on Linux, kqueue on macOS) to listen on sockets; no thread needs to wait. | No |
| File / Disk I/O | fs.readFile(), DNS lookups | Most OS kernels lack reliable non-blocking filesystem APIs, so Node simulates async behavior by delegating work to worker threads. | Yes, uses 1 of the 4 threads in the thread pool |
Practical Impact: If an application processes more than 4 concurrent file-reading or DNS tasks, surplus tasks must queue and wait for an idle thread, even if the main thread is completely idle.
4. The Biggest Trap: CPU-Bound Tasks Block the Main Thread
- Node's non-blocking model applies only to I/O, not to pure computation (CPU-bound tasks).
- Example: A
forloop processing 50,000 items (with zero file, network, or DB calls) is pure JS executing directly on the main thread. - While that loop executes, the main thread is fully monopolized. All other incoming requests—even lightweight requests (like fetching a single product)—are forced to wait because only one thread executes JS.
- This is known as "blocking the event loop", a common reason a Node server with low I/O traffic can still freeze or degrade under load.
5. Solution for CPU-Bound Work: worker_threads
The built-in worker_threads module allows you to spawn true secondary JS threads. Each worker runs its own isolated V8 instance and its own event loop, achieving genuine parallel processing.
Key differences from libuv's 4-thread pool:
- libuv Thread Pool: Hidden internally, cannot be manually spawned or controlled, used exclusively for specific I/O (file operations, DNS lookups, crypto, etc.).
worker_threads: Explicitly created and managed by the developer, used for CPU-bound tasks (image resizing, intensive math, complex PDF generation, etc.).
Communication between the main thread and worker threads:
Done via postMessage, identical to Web Workers in the browser:
- Main thread sends data:
worker.postMessage(data) - Worker finishes and replies:
parentPort.postMessage(result) - Main thread receives output:
worker.on('message', callback)
Because threads do not share memory by default, data exchanged across thread boundaries must be serialized/cloned.
6. Microtasks, Macrotasks, and process.nextTick (vs. Frontend)
Similarities to Frontend (Browser):
- Microtasks (e.g.,
Promise.then) run before macrotasks (e.g.,setTimeout, I/O callbacks). - Execution follows the rule: exhaust a given queue completely before switching to the next queue type.
Differences from Frontend (Node-Exclusive):
- Node includes a separate
process.nextTickqueue that operates independently of the Promise microtask queue and takes higher priority than Promises. - Full execution order per cycle: Synchronous code $\rightarrow$
nextTickqueue (runs to completion, including newly queued ticks) $\rightarrow$ Promise microtask queue (runs to completion) $\rightarrow$ 1 phase of macrotasks (timers, I/O, etc.) $\rightarrow$ repeat.
Example:
console.log('1: start');
setTimeout(() => console.log('2: setTimeout'), 0);
Promise.resolve().then(() => console.log('3: promise'));
process.nextTick(() => console.log('4: nextTick'));
console.log('5: end');
Execution Order: 1 $\rightarrow$ 5 $\rightarrow$ 4 $\rightarrow$ 3 $\rightarrow$ 2
Practical Risk: I/O Starvation
Because the nextTick queue has absolute priority, calling process.nextTick() recursively will prevent the event loop from ever transitioning to Promises, timers, or I/O callbacks.
This is called starvation: the CPU continues running (unlike a synchronous infinite for loop), but higher-priority tick tasks perpetually starve pending I/O and timers. Frontend Web APIs do not have a queue with this level of absolute priority.
7. Comprehensive Mental Model
Core Components
| Component | Role |
|---|---|
| Memory Heap | Memory space storing objects, closures, variables |
| Call Stack | Executes pure JS code; strictly 1 thread |
| libuv | C library outside V8 that handles asynchronous tasks dispatched from the call stack |
| ↳ Thread Pool (in libuv) | Handles file and DNS I/O (defaults to 4 threads) |
| ↳ OS Async I/O (in libuv) | Handles network I/O via native OS mechanisms (epoll / kqueue) |
| 3 Callback Queues | nextTick queue, Microtask queue (Promises), Macrotask queues (timers / poll / check) |
| Event Loop | Pulls callbacks from the 3 queues by priority and pushes them to the call stack when empty |
Important Note: The Thread Pool and OS Async I/O are two independent, parallel paths, not sequential steps. libuv routes each I/O operation to exactly one path based on task type. No single task passes through both.
Architecture Diagram
┌─────────────────────────────────────────────────────┐
│ NODE.JS RUNTIME │
│ │
│ ┌───────────────┐ ┌───────────────────┐ │
│ │ Memory Heap │ │ Call Stack │ │
│ │ (objects, ...)│ │ (ONLY 1 THREAD │ │
│ │ │ │ executing JS) │ │
│ └───────────────┘ └───────────────────┘ │
└─────────────────────────────────────────────────────┘
│ ▲
Dispatches async │ │ Pushes callback to
operations ▼ │ stack when empty
┌─────────────────────────────────────┐ │
│ libuv │ │
│ (2 INDEPENDENT PATHS, pick 1) │ │
│ │ │
│ ┌────────────────┐ ┌─────────────┐ │ │
│ │ Thread Pool │ │ OS Async I/O│ │ │
│ │ (4 threads) │ │(epoll/kqueue│ │ │
│ │ file, DNS │ │ network │ │ │
│ └────────────────┘ └─────────────┘ │ │
└─────────────────────────────────────┘ │
│ │
On completion, │ │
pushes callback ▼ │
┌─────────────────────────────────────┐ │
│ CALLBACK QUEUES │ │
│ (in priority order) │ │
│ │ │
│ 1. nextTick queue (Highest) │ │
│ process.nextTick() │ │
│ │ │
│ 2. Microtask queue │ │
│ Promise.then / catch / finally │ │
│ queueMicrotask() │ │
│ │ │
│ 3. Macrotask queues (by phase): │ │
│ - timers : setTimeout, │ │
│ setInterval │ │
│ - pending : deferred I/O │ │
│ callbacks │ │
│ - poll : I/O callbacks │ │
│ (file, network) │ │
│ - check : setImmediate │ │
│ - close : socket.on('close')│ │
└─────────────────────────────────────┘ │
│ │
▼ │
┌───────────────┐ │
│ EVENT LOOP │──────────────┘
│ (infinite loop│
│ fetching │
│ callbacks) │
└───────────────┘
How to interpret the diagram: After every single macrotask callback executes, the event loop returns to drain the nextTick and Microtask queues before executing the next macrotask callback. It does not check microtasks only once per loop cycle.
Execution Flow by Task Type:
- Network I/O (
fetch, API calls, database network queries) $\rightarrow$ libuv delegates to OS Async I/O $\rightarrow$ consumes zero threads $\rightarrow$ main thread remains free to handle concurrent requests. - File / DNS I/O (
fs.readFile, DNS lookups) $\rightarrow$ libuv delegates to the Thread Pool $\rightarrow$ consumes 1 of the 4 auxiliary threads $\rightarrow$ main thread remains free (though requests queue up if all 4 threads are occupied). - Pure CPU-Bound Tasks (computational loops with no I/O) $\rightarrow$ executes directly on the Call Stack (main thread) $\rightarrow$ blocks all concurrent requests until completed $\rightarrow$ Solution: offload to
worker_threads.
When any of the operations above complete, libuv routes their callbacks into the corresponding queue (nextTick / Microtask / Macrotask). As soon as the call stack empties, the event loop retrieves callbacks following strict priority order: nextTick $\rightarrow$ Microtask $\rightarrow$ Macrotask, pushing them onto the call stack to run.
Phase's order: timers → pending → poll → check → close
