Node.js Architecture
Learning Objectives
By the end of this page, you will be able to:
- Explain why Node.js runs JavaScript on a single thread yet still handles thousands of concurrent connections.
- Describe the six phases of the event loop and what runs in each.
- Distinguish between operations handled by libuv's thread pool and operations handled by the OS kernel directly.
- Trace how a non-blocking call like
fs.readFilemoves from your code, through libuv, and back to a callback. - Compare CommonJS and ES Modules and explain how
require()caching works. - Identify when a task should be offloaded to
worker_threadsor a child process instead of the main event loop.
Quick Answer
Node.js is a JavaScript runtime built on Google's V8 engine that executes your application code on a single thread, but achieves high concurrency by delegating I/O work (file access, network calls, DNS lookups) to a C++ library called libuv. Libuv runs an event loop that checks for completed work and invokes the right JavaScript callback, plus a small thread pool for I/O operations the OS can't do asynchronously. This design lets Node.js handle many simultaneous connections with low memory overhead, which is why it's a popular choice for APIs, real-time apps, and I/O-heavy services — but it also means one long-running synchronous computation can freeze the entire server.
The Big Picture: Why Node.js Is Built This Way
Traditional web servers (like early Apache) spawned a new thread or process per request. That's simple to reason about, but threads are expensive — each one needs its own stack (often megabytes), and switching between thousands of them costs CPU time. Node.js took a different bet: most server workloads spend the vast majority of their time waiting — for a database, a disk read, a network response — not computing. So instead of parking a whole thread per waiting request, Node.js uses one thread to run your JavaScript and hands off the "waiting" part to the operating system and a small internal thread pool. When the wait is over, your callback gets scheduled back onto that single thread.
This is why Node.js is described as single-threaded, event-driven, and non-blocking. Understanding the pieces below — the event loop, libuv, and the module system — is really understanding how those three words fit together in practice.
Core Concept 1: The Single-Threaded Model
Definition
Node.js executes all of your JavaScript code — your route handlers, your business logic, your callback functions — on one main thread, called the event loop thread.
Explanation
There is no automatic parallelism for your JS code the way there is in, say, a multi-threaded Java server. If you write a for loop that runs for 10 seconds, nothing else in your Node.js process can run during those 10 seconds — no other request gets handled, no timer fires, no I/O callback executes. This is intentional: a single thread means you never have to worry about two pieces of your JavaScript racing to mutate the same variable at the same time, which eliminates an entire category of concurrency bugs (race conditions, deadlocks on shared memory) that plague multi-threaded programming.
The trick that makes this workable at scale is that Node.js doesn't do the waiting on that thread. When you call an asynchronous function, Node.js hands the actual work (reading a file, querying a socket) off to the OS or to libuv's thread pool, and your main thread immediately moves on to the next piece of code. It only comes back to your callback once the work is done.
Example
console.log('Start');
setTimeout(() => {
console.log('Timer finished');
}, 0);
console.log('End');
// Output:
// Start
// End
// Timer finished
Even with a 0ms delay, "Timer finished" prints last. setTimeout hands the timer off and lets the main thread continue immediately; the callback only runs once the current synchronous code has finished and the event loop reaches the timers phase.
Real-World Example
Imagine an e-commerce API handling 5,000 concurrent users browsing products. Each request mostly waits on a database query. With a thread-per-request model, that's potentially 5,000 OS threads competing for CPU and memory. Node.js instead keeps a handful of threads total: one for your JS, plus libuv's pool for genuinely blocking work — while thousands of database queries are "in flight" at once, tracked as lightweight callback references rather than full threads.
Why It Matters
This model is why Node.js became the default choice for I/O-heavy services (REST APIs, chat servers, streaming platforms) — you get high concurrency without the memory cost of thousands of threads. It's also why Node.js is a poor fit for CPU-heavy work like image processing or large data transformations unless you explicitly offload that work.
Common Misunderstanding
Students often think "single-threaded" means "Node.js can only do one thing at a time, period." In reality, only your JavaScript execution is single-threaded. Behind the scenes, libuv's thread pool and the OS kernel are doing real work in parallel — Node.js just presents it to you as a queue of callbacks arriving on one thread.
Core Concept 2: The Event Loop
Definition
The event loop is the mechanism, implemented in libuv, that continuously checks whether there is work to do — timers to fire, I/O callbacks to run, pending closures — and executes the corresponding JavaScript callbacks on the main thread, one phase at a time.
Explanation
The event loop is not a single queue; it's a loop through six ordered phases, each with its own callback queue:
- Timers — runs callbacks scheduled by
setTimeout()andsetInterval()whose delay has elapsed. - Pending callbacks — executes I/O callbacks deferred from the previous loop iteration (some system-level operations, like certain TCP errors).
- Idle, prepare — internal use only, not relevant to application code.
- Poll — the busiest phase. Retrieves new I/O events (file reads, network data) and runs their callbacks; if the queue is empty, it will wait here for new events (unless timers are due).
- Check — runs callbacks scheduled with
setImmediate(), which always fire right after the poll phase. - Close callbacks — runs cleanup callbacks like
socket.on('close', ...).
Between every phase transition (and after each callback), Node.js drains two special queues first: process.nextTick() callbacks, then Promise microtasks (.then, async/await). This is why process.nextTick and resolved promises always run before the next event loop phase, even before timers.
Example
console.log('1: sync');
setTimeout(() => console.log('2: setTimeout'), 0);
setImmediate(() => console.log('3: setImmediate'));
Promise.resolve().then(() => console.log('4: promise microtask'));
process.nextTick(() => console.log('5: nextTick'));
console.log('6: sync');
// Output order:
// 1: sync
// 6: sync
// 5: nextTick
// 4: promise microtask
// 2: setTimeout (order vs. setImmediate can vary if not inside I/O)
// 3: setImmediate
nextTick and microtasks always drain before the loop moves into the timers phase, which is why they print before both timer-based callbacks.
Real-World Example
A chat server using WebSockets relies entirely on the poll phase: each incoming message is an I/O event. The event loop picks it up, runs the message handler (which might just enqueue a database write and return), and immediately moves on to the next connection's event — never blocking on any single user's message.
Why It Matters
Understanding the phases explains real bugs: why a setTimeout(fn, 0) doesn't run "immediately," why setImmediate fires before or after timers depending on context, and why an unbounded chain of process.nextTick() calls can starve I/O entirely (a bug known as "nextTick starvation").
Common Misunderstanding
A common assumption is that setTimeout(fn, 0) and setImmediate(fn) are basically the same thing. They're not interchangeable: setTimeout fires in the timers phase (first), and setImmediate fires in the check phase (after poll). Inside an I/O callback, setImmediate is actually guaranteed to run before any setTimeout(fn, 0), which surprises many developers.
Core Concept 3: Non-Blocking I/O and libuv
Definition
Non-blocking I/O means a function that performs input/output (disk, network, DNS) returns control to your code immediately instead of pausing execution until the operation finishes; libuv is the C library that implements this behavior and the event loop itself.
Explanation
Libuv handles I/O in two different ways depending on the operating system's capabilities:
- Network I/O (TCP/UDP sockets, most DNS via
dns.resolve) is handled asynchronously by the OS kernel itself (viaepollon Linux,kqueueon macOS, IOCP on Windows) — no extra threads needed. Libuv just polls the OS for completed events. - File system operations,
fs.readFile, and some DNS lookups (dns.lookup) don't have good async kernel primitives on all platforms, so libuv runs them on an internal thread pool (default size: 4 threads, configurable viaUV_THREADPOOL_SIZE).
Either way, your JavaScript callback only runs once the event loop's poll phase notices the work is complete — the actual waiting never happens on your main thread.
Example
const fs = require('fs');
console.log('Reading file...');
fs.readFile('data.txt', 'utf8', (err, data) => {
if (err) return console.error('Error:', err.message);
console.log('File contents:', data);
});
console.log('This runs before the file is read');
fs.readFile hands the actual disk read to libuv's thread pool. The main thread doesn't wait — it prints the last line immediately, and the callback with the file's contents runs later once the poll phase sees the read finished.
Real-World Example
A video streaming service reading chunks of a file to serve to clients uses non-blocking reads so that reading one user's video file never stalls the responses being sent to every other connected user on the same process.
Why It Matters
This is the entire reason Node.js can serve thousands of concurrent I/O-bound requests from a single process with a small memory footprint — a capability that made it popular for APIs and real-time backends (Netflix, LinkedIn, and Uber all use Node.js in production for exactly this reason).
Common Misunderstanding
Students often assume all asynchronous operations in Node.js use the thread pool. In fact, most network I/O bypasses the thread pool entirely and relies on OS-level async notification — the thread pool is really only the fallback for filesystem and a few other operations that lack a good async OS API.
Core Concept 4: Modules and the npm Ecosystem
Definition
A module is a self-contained unit of JavaScript code that can export functionality for other files to import. Node.js supports two module systems — CommonJS (CJS) and ECMAScript Modules (ESM) — plus npm, the package manager and registry that distributes reusable modules.
Explanation
CommonJS is Node's original module system: module.exports defines what a file exposes, and require() synchronously loads and caches it. Once a module is require()-d, subsequent calls return the same cached object rather than re-executing the file — this is why mutating an exported object in one place affects every other file that imported it.
ES Modules use import/export syntax (enabled via "type": "module" in package.json or a .mjs extension). Unlike require(), ES module resolution happens asynchronously and imports are live bindings, not copied values. import() (as a function) allows loading a module dynamically at runtime, returning a promise.
npm (or alternatives like yarn/pnpm) is how the vast majority of reusable Node.js code is shared. A package.json file declares dependencies and their version ranges; running npm install downloads them into node_modules. This ecosystem — over 2 million packages — is one of Node.js's biggest practical advantages: rarely do you need to build common functionality (routing, validation, ORM layers) from scratch.
Example
// math.js (CommonJS)
function add(a, b) {
return a + b;
}
module.exports = { add };
// app.js
const { add } = require('./math');
console.log(add(2, 3)); // 5
// math.mjs (ES Modules)
export function add(a, b) {
return a + b;
}
// app.mjs
import { add } from './math.mjs';
console.log(add(2, 3)); // 5
Real-World Example
A typical Express-based REST API depends on npm packages for nearly everything outside its own business logic: express for routing, mongoose or pg for the database layer, jsonwebtoken for auth tokens, and dotenv for configuration — all installed and version-pinned through package.json.
Why It Matters
Module caching affects application behavior (e.g., singletons implemented via require() caching) and startup performance. Choosing CJS vs. ESM affects tooling compatibility, tree-shaking in bundlers, and whether top-level await is available (only in ESM).
Common Misunderstanding
Many beginners think require() re-reads and re-executes a file's code every time it's called. It doesn't — after the first require(), Node.js returns the cached module.exports object. This is why side effects (like starting a database connection) inside a required file only ever run once, no matter how many files import it.
The Event Loop, Visualized
CPU-Bound Work: Escaping the Single Thread
Because the event loop only has one thread for JavaScript, a genuinely CPU-heavy task (image resizing, complex calculations, parsing huge JSON) will block everything else. Node.js gives you two escape hatches:
worker_threads— spins up additional threads that each get their own V8 instance and event loop, letting you run CPU-bound JS in parallel and message results back to the main thread.clustermodule / child processes — forks multiple OS processes (typically one per CPU core), each running its own copy of your app and sharing a server port, so incoming requests are load-balanced across cores.
// worker_threads example: offloading a CPU-heavy task
const { Worker } = require('worker_threads');
const worker = new Worker('./heavy-task.js');
worker.on('message', (result) => console.log('Result:', result));
worker.postMessage({ n: 40 }); // e.g., compute fibonacci(40) without blocking the main thread
Key Terms
| Term | Definition |
|---|---|
| Event Loop | The libuv-driven loop that cycles through phases, executing callbacks for timers, I/O, and other queued work on the main thread. |
| libuv | The C library underlying Node.js that implements the event loop, the thread pool, and cross-platform asynchronous I/O. |
| Non-blocking I/O | An I/O call that returns immediately and delivers its result later via a callback, instead of pausing the calling thread. |
| Thread Pool | A small set of worker threads (default 4) that libuv uses for operations without a native async OS API, mainly file system calls. |
process.nextTick() | Schedules a callback to run immediately after the current operation, before the event loop proceeds to the next phase — even before promise microtasks. |
| Microtask Queue | The queue of resolved Promise callbacks (.then/async-await continuations), drained after nextTick and before the next event loop phase. |
| CommonJS (CJS) | Node's original synchronous module system using require() and module.exports. |
| ES Modules (ESM) | The standard JavaScript module system using import/export, resolved asynchronously. |
worker_threads | A core module for running JavaScript in parallel threads, used to offload CPU-intensive work. |
| Cluster Module | A core module for forking multiple Node.js processes to share a server port across CPU cores. |
| Event Emitter | The class (EventEmitter) Node.js uses to implement its publish/subscribe pattern for custom events. |
| V8 | Google's JavaScript engine, embedded in Node.js, that compiles JS to machine code and manages memory/garbage collection. |
Common Mistakes
Misconception 1: "Node.js is multi-threaded because it handles many requests at once."
Why it's wrong: Handling many concurrent requests is not the same as running JavaScript on multiple threads. The concurrency comes from non-blocking I/O and the event loop, not from parallel JS execution.
Correct explanation: Your JavaScript always runs on a single thread. Concurrency comes from delegating waiting-heavy work to libuv/the OS and resuming via callbacks — true parallel JS execution only happens if you explicitly use worker_threads or cluster.
Misconception 2: "setTimeout(fn, 0) runs immediately, like a synchronous call."
Why it's wrong: A timer delay of 0 still means "run in the timers phase of the next available event loop iteration," not "run right now."
Correct explanation: Node.js always finishes executing the current synchronous code block (and drains nextTick/microtask queues) before it even looks at the timers phase, so a 0ms timeout callback runs after all currently queued synchronous and microtask work.
Misconception 3: "Since Node.js is non-blocking, my CPU-heavy code won't slow down other requests."
Why it's wrong: Non-blocking I/O only applies to I/O operations that libuv/the OS can do asynchronously. A tight for loop or a synchronous JSON.parse on a huge string still runs on the single main thread and blocks everything else.
Correct explanation: CPU-bound work must be explicitly offloaded (via worker_threads, child_process, or cluster) to avoid freezing the event loop; non-blocking I/O does nothing to help pure computation.
Comparison and Connections
| Aspect | Node.js (Event Loop Model) | Traditional Thread-per-Request Server |
|---|---|---|
| Concurrency model | Single thread + event loop + libuv thread pool | One OS thread (or process) per request |
| Memory per connection | Low (a callback reference, not a full stack) | Higher (each thread needs its own stack) |
| Best suited for | I/O-heavy workloads (APIs, streaming, chat) | CPU-heavy or blocking workloads |
| Failure mode | One long synchronous task blocks all requests | One slow request only blocks its own thread |
| Parallelism for CPU work | Requires explicit worker_threads/cluster | Native, via OS thread scheduling |
| Aspect | setTimeout | setImmediate | process.nextTick |
|---|---|---|---|
| Runs in phase | Timers | Check | Not a phase — drains before every phase transition |
| Typical use | Delay execution by N ms | Run right after I/O in the current iteration | Run before I/O, essentially "as soon as possible" |
| Priority relative to others | Lowest of the three | Middle | Highest |
| Aspect | CommonJS (require) | ES Modules (import) |
|---|---|---|
| Loading | Synchronous | Asynchronous |
| Syntax | module.exports, require() | export, import |
Top-level await | Not supported | Supported |
| File extension | .js (default) | .mjs or .js with "type": "module" |
Practice Questions
Recall
- What are the six phases of the Node.js event loop, in order? Answer guidance: Timers → Pending callbacks → Idle/Prepare → Poll → Check → Close callbacks.
- Which core module does Node.js use to offload CPU-intensive JavaScript onto separate threads?
Answer guidance:
worker_threads.
Understanding
- Explain why Node.js can serve thousands of concurrent connections despite running JavaScript on a single thread. Answer guidance: The single thread only runs JS logic; the actual waiting for I/O (disk, network) is delegated to libuv's thread pool or the OS kernel's async APIs, and callbacks are scheduled back onto the main thread only once results are ready — so no thread sits idle waiting.
- Why does
process.nextTick()run before Promise microtasks, and why do both run before the next event loop phase? Answer guidance: Node.js drains thenextTickqueue first, then the microtask queue, after every callback and before moving the event loop to its next phase — this ordering is a Node-specific extension of the JS spec's microtask behavior.
Application
- You're building a Node.js API that resizes uploaded images synchronously in each request handler, and the server becomes unresponsive under moderate load. What's happening, and how would you fix it?
Answer guidance: The synchronous image-processing code blocks the single event loop thread, so no other request (or even health checks) can be processed while it runs. Fix by offloading the resizing to
worker_threads, a child process, or a dedicated job queue/service. - A teammate writes
fs.readFileSync()inside an Express route handler used by every request. Why is this risky in production, and what would you use instead? Answer guidance:readFileSyncblocks the main thread until the read finishes, stalling every other request; use the asynchronousfs.readFile(callback orfs.promiseswithasync/await) so the event loop stays free.
Analysis
- Compare the failure modes of the event-loop model versus a thread-per-request server when a single request runs an expensive synchronous computation. Answer guidance: In Node.js, that computation blocks the one shared thread, so it degrades the entire server. In a thread-per-request model, only that request's thread is affected; other threads keep serving requests, though at higher total memory cost per connection.
- Given the ordering rules of the event loop, predict the output of code that mixes
setTimeout(fn, 0),setImmediate(fn),Promise.resolve().then(fn), andprocess.nextTick(fn), and justify the order. Answer guidance: Synchronous code first, then allprocess.nextTickcallbacks, then all resolved Promise microtasks, then the event loop proceeds to timers (setTimeout) before check (setImmediate) — unless this all happens inside an I/O callback, in which casesetImmediateis guaranteed to fire beforesetTimeout.
FAQ
Is Node.js single-threaded or multi-threaded? Both, depending on what you mean. Your JavaScript application code runs on a single thread. But under the hood, Node.js (via libuv) uses multiple threads in its I/O thread pool, and the OS handles network I/O asynchronously at the kernel level — so the runtime as a whole is multi-threaded even though your code isn't.
Why doesn't Node.js just use more threads for everything? Threads are expensive: each needs its own memory stack, and context-switching between many threads costs CPU. For I/O-bound work — which is most of what web servers do — you don't need a thread per task, because the task spends nearly all its time waiting, not computing. Node's model avoids paying the thread cost for that waiting time.
What happens if I block the event loop? Every other operation in that process — other requests, timers, I/O callbacks — has to wait until the blocking code finishes, because there's only one thread to run it all on. This is the single biggest performance pitfall in Node.js applications.
Should I always use worker_threads for expensive operations?
Only for CPU-bound work (heavy computation, encryption, image/video processing). For I/O-bound work — even "slow" I/O like a database query — the built-in non-blocking model already handles it efficiently without extra threads.
What's the difference between the thread pool and the event loop? The event loop runs on the main thread and drives your JavaScript callbacks. The thread pool is a separate set of background threads (managed by libuv) that actually perform certain blocking operations, like file system calls, so the main thread never has to wait for them directly.
Quick Revision
- Node.js runs your JavaScript on a single thread; concurrency comes from non-blocking I/O, not parallel JS execution.
- The event loop has six phases: Timers → Pending callbacks → Idle/Prepare → Poll → Check → Close callbacks.
process.nextTick()and Promise microtasks drain before every phase transition —nextTickhas priority over microtasks.setTimeout(fn, 0)fires in the timers phase;setImmediate(fn)fires in the check phase, right after poll.- libuv handles most network I/O via OS-level async APIs (epoll/kqueue/IOCP); file system and DNS lookups use libuv's thread pool (default 4 threads).
- CPU-bound work blocks the entire event loop — offload it with
worker_threadsorchild_process/cluster. require()(CommonJS) is synchronous and caches modules after first load;import(ESM) is asynchronous and supports top-levelawait.- The
clustermodule forks multiple processes sharing one port, enabling multi-core usage for a Node.js app. - npm is Node's package manager/registry; dependencies are declared in
package.jsonand installed intonode_modules. - Error-first callbacks follow the
(err, result)convention; Promises andasync/awaitare the modern alternative. - V8 is the JavaScript engine that compiles JS to machine code and handles garbage collection — separate from libuv, which handles the event loop and I/O.
Related Topics
Prerequisites
- JavaScript fundamentals: functions, closures, and asynchronous callbacks
- Basic understanding of processes, threads, and how operating systems schedule work
Related Topics
- Promises and async/await syntax in JavaScript
- The
EventEmitterpattern and publish/subscribe architecture - HTTP servers and the
http/Express frameworks in Node.js
Next Topics
- Building REST APIs with Express.js
- Node.js streams and buffers for handling large data efficiently
- Scaling Node.js applications with the
clustermodule and load balancers