Node.js is built around a single, foundational idea: instead of waiting for slow operations to finish before moving on, it registers what needs to happen when they do — and immediately gets back to work. Everything about how Node.js behaves flows from this idea.
To understand it properly, you need to understand what is happening inside the runtime when your code runs.
The Two Problems Node.js Was Built to Solve
Most programs spend the majority of their time doing one of two things: computing or waiting.
Computing means the CPU is actively working — processing data, running calculations, transforming input into output. This is fast.
Waiting means the CPU is idle — a file is being read from disk, a database query is running, a network response is on its way. This is slow. Not slow in human terms, but slow relative to how fast a CPU operates. A modern CPU can execute billions of instructions per second. Reading from a hard disk takes millions of times longer than reading from memory. A network round trip can take tens of millions of CPU cycles.
In a traditional, synchronous server model, every time your program hits one of these slow operations, it stops. It blocks the thread and waits. If a thousand users are making requests simultaneously and each request triggers a database query, you need a thousand threads — one for each waiting request. Threads are not free. Each one consumes memory. Under heavy load, the server runs out of resources.
Node.js solves this with two mechanisms working together: the event loop and non-blocking I/O.
The Architecture of Node.js
Node.js is composed of several layers, each with a specific responsibility.
V8 — the JavaScript Engine
At the core of Node.js is V8, the JavaScript engine developed by Google for Chrome. V8 is responsible for one thing: taking JavaScript source code and executing it. It parses the code, compiles it to machine code using a just-in-time (JIT) compiler, and runs it.
V8 is extremely fast. It is one of the primary reasons Node.js is performant — JavaScript running on V8 is not interpreted line by line; it is compiled and optimised at runtime.
V8 maintains the call stack — the data structure that tracks which function is currently executing and which functions called it. Every time a function is invoked, a frame is pushed onto the stack. When the function returns, its frame is popped off. JavaScript execution happens entirely through this stack, and V8 can only execute one thing at a time.
This is the core constraint: JavaScript in Node.js is single-threaded. There is one call stack, and only one thing runs on it at any given moment.
libuv — the Asynchronous I/O Library
V8 alone cannot read files, open network sockets, or set timers. Those operations require access to the operating system, and that is where libuv comes in.
libuv is a C library that provides Node.js with cross-platform access to the operating system's I/O capabilities. It is responsible for:
- The event loop — the mechanism that keeps Node.js running and coordinates asynchronous operations
- The thread pool — a set of background threads that handle operations the OS cannot do asynchronously
- Asynchronous networking — using the OS's own async I/O interfaces (epoll on Linux, kqueue on macOS, IOCP on Windows)
- Timers —
setTimeoutandsetInterval - File system operations
libuv is what makes non-blocking I/O possible in Node.js. When your JavaScript code initiates a file read or a network request, it is libuv — not V8 — that handles the actual waiting. V8 is freed to keep running other JavaScript in the meantime.
Node.js Core Modules and Bindings
Between V8/libuv and the JavaScript you write sits a layer of C++ bindings — code that bridges the JavaScript world and the C++ world. These bindings are what allow JavaScript functions like fs.readFile() to trigger actual operating system calls.
On top of the bindings are the Node.js core modules — fs, http, path, stream, events, and others — written in a combination of JavaScript and C++. These are the built-in APIs that ship with Node.js and are available without installing anything.
The Event Loop
The event loop is the heart of Node.js. It is what allows a single-threaded runtime to handle many things seemingly at once.
The event loop is a continuous loop that runs as long as there is work to do. Its job is to check whether any asynchronous operations have completed and, if they have, to run the callback functions associated with them.
A Simplified Mental Model
When Node.js starts, it runs your JavaScript file from top to bottom. During this initial execution:
- Synchronous code runs immediately on the call stack
- Asynchronous operations (file reads, HTTP requests, timers) are handed off to libuv
- Callback functions associated with those operations are registered
Once the initial synchronous code finishes, the call stack is empty. The event loop takes over. It checks whether any of the asynchronous operations that were handed off have completed. If they have, it takes the associated callback and pushes it onto the call stack for V8 to execute.
This continues indefinitely — checking for completed operations, running their callbacks, handling new asynchronous operations that the callbacks themselves trigger — until there is nothing left to do and Node.js exits.
The Phases of the Event Loop
The event loop moves through a fixed sequence of phases on each iteration. Each phase has a queue of callbacks to execute.
| Phase | What It Handles |
|---|---|
| Timers | Callbacks from setTimeout and setInterval whose delay has elapsed |
| Pending callbacks | I/O callbacks deferred from the previous iteration |
| Idle, prepare | Internal use only |
| Poll | Retrieves new I/O events and executes their callbacks |
| Check | Callbacks from setImmediate |
| Close callbacks | Callbacks for closed connections and resources (e.g. socket.on('close', ...)) |
The poll phase is where most of the action happens. Node.js sits here waiting for I/O events when there is nothing else to do. When an event arrives — a file has been read, a network packet has come in — it moves to the relevant callback and executes it.
Microtasks
Separate from the event loop phases are microtasks — small units of work that run between phases, before the event loop moves on. There are two microtask queues:
process.nextTickqueue — runs first, before anything else- Promise callbacks (
.then,.catch) — runs afterprocess.nextTick
This matters in practice. A process.nextTick callback will always run before a setTimeout callback, even if the timer delay is zero. Understanding microtasks is essential when the order of asynchronous operations matters.
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
// Output:
// nextTick
// Promise
// setTimeout
Non-Blocking I/O
When Node.js code calls an I/O operation — reading a file, making an HTTP request, querying a database — it does not block the thread and wait for the result. Instead, it hands the operation to libuv, provides a callback function to run when the result is ready, and returns immediately.
const fs = require('fs');
fs.readFile('data.txt', 'utf8', (err, data) => {
console.log(data);
});
console.log('This runs before the file is read');
Output:
This runs before the file is read
(contents of data.txt)
The readFile call is dispatched to libuv. Node.js does not wait for the file to be read — it moves immediately to the next line and logs the message. When the file read completes, libuv places the callback in the event loop's poll phase queue. The event loop picks it up and runs it.
This is what non-blocking means: the thread is never sitting idle waiting. It is always either running JavaScript or available to run more JavaScript.
The Thread Pool
Not all I/O can be handled asynchronously at the OS level. File system operations — despite the fs module's async API — are actually executed on a thread pool managed by libuv, because most operating systems do not provide truly asynchronous file I/O in all cases.
By default, libuv maintains a pool of 4 threads (configurable via the UV_THREADPOOL_SIZE environment variable). When a file system operation is initiated, libuv assigns it to one of these threads. The thread performs the blocking operation in the background, and when it finishes, the result is passed back to the event loop as a completed I/O event.
This is an important detail: Node.js's JavaScript runtime is single-threaded, but Node.js itself — the full runtime — uses multiple threads under the hood for I/O. The thread pool is invisible to your JavaScript code, but understanding it exists helps explain why file system operations are genuinely non-blocking from JavaScript's perspective.
For network I/O — TCP connections, HTTP requests, DNS lookups — libuv uses the operating system's native async interfaces and does not need the thread pool. These are handled entirely without background threads.
What Happens When You Run a Node.js File
Tracing through a concrete execution helps make the model concrete:
const fs = require('fs');
const https = require('https');
console.log('Start');
fs.readFile('config.json', 'utf8', (err, data) => {
console.log('File read complete');
});
https.get('https://api.example.com/data', (res) => {
console.log('HTTP response received');
});
setTimeout(() => {
console.log('Timer fired');
}, 100);
console.log('End');
Here is what Node.js does:
- Loads the
fsandhttpscore modules - Runs
console.log('Start')— synchronous, executes immediately - Calls
fs.readFile— hands the operation to libuv's thread pool and registers the callback - Calls
https.get— hands the operation to the OS's async networking interface and registers the callback - Calls
setTimeout— registers a timer in libuv with a 100ms delay - Runs
console.log('End')— synchronous, executes immediately - The call stack is now empty — the event loop takes over
- The event loop waits in the poll phase for I/O events
- As the file read, HTTP response, and timer complete (in whatever order they finish), their callbacks are picked up by the event loop and executed
Output:
Start
End
File read complete ← order of these three depends on which
HTTP response received ← operation completes first
Timer fired
The Consequences of Single-Threaded Execution
The event loop model has one critical implication that every Node.js developer must internalise: if you block the event loop, you block everything.
If a callback runs a slow synchronous operation — a complex calculation, a large JSON parse, a tight loop — the event loop cannot move on until that operation completes. No other callbacks run. No other requests are handled. The entire server stalls.
// This blocks the event loop for the entire duration
const result = heavyComputation(); // takes 5 seconds
During those five seconds, every other pending operation waits. A server under load that runs expensive synchronous code in its callbacks will degrade for every concurrent user.
This is why Node.js is described as well-suited for I/O-bound workloads (lots of waiting, little CPU work) and poorly suited for CPU-bound workloads (heavy computation). For CPU-intensive tasks, the solutions are worker threads, child processes, or offloading the work to a separate service.
Summary
| Concept | What It Means |
|---|---|
| V8 | Compiles and executes JavaScript; maintains the call stack |
| libuv | Provides the event loop, thread pool, and async I/O |
| Single-threaded | Only one piece of JavaScript runs at a time |
| Non-blocking I/O | I/O operations are delegated; the thread does not wait |
| Event loop | Continuously checks for completed async operations and runs their callbacks |
| Thread pool | libuv's background threads for file system and other blocking operations |
| Microtasks | process.nextTick and Promise callbacks that run between event loop phases |
| Blocking the event loop | Running slow synchronous code that prevents other callbacks from executing |