Single-Threaded Model and libuv
JavaScript runs on one thread, yet a Node.js server can read files, query databases, and handle thousands of requests at the same time. The library that makes this possible is libuv.
Definition
- Single-threaded model: Node.js runs your JavaScript on one main thread, with one call stack. Only one piece of your code runs at a time.
- libuv: a C library inside Node.js that provides the event loop and handles asynchronous I/O (files, network, timers) outside the main thread.
Analogy: think of a restaurant with one waiter. The waiter (main thread) takes an order, passes it to the kitchen (libuv), and immediately serves the next table instead of standing and waiting. When a dish is ready, the kitchen rings a bell, and the waiter delivers it.
Why Single-Threaded?
| Benefit | Why it helps |
|---|---|
| No race conditions in your code | Two pieces of JS never change the same variable at the same time |
| No locks | You don't manage threads or locks yourself |
| Low memory per request | No new thread is created for each request |
The catch: if your JavaScript does heavy CPU work, the single thread is blocked, and every request waits.
Note: "single-threaded" means your JavaScript runs on one thread. The Node.js process itself uses other threads behind the scenes, for libuv's thread pool and for V8's garbage collector.
Node.js Architecture
| Layer | What it does |
|---|---|
| Your JavaScript code | Your app |
| Node.js APIs | fs, http, crypto, timers, written in JavaScript |
| Node.js bindings | C++ code that connects JavaScript to the layers below |
| V8 | Runs your JavaScript: call stack, memory heap, compilation |
| libuv | Event loop, async I/O, and the thread pool |
V8 runs the code. libuv handles the waiting.
How libuv Handles Async Work
When you call an async function such as fs.readFile, Node.js hands the work to libuv and continues running the next line. libuv does the work in one of two ways:
| Kind of work | How libuv does it | Examples |
|---|---|---|
| Network I/O | Asks the operating system to do it asynchronously (epoll on Linux, kqueue on macOS, IOCP on Windows). No extra thread needed | TCP, HTTP requests, sockets |
| Work the OS can't do asynchronously | Runs it on the thread pool (4 threads by default) | File system (fs), crypto, zlib, dns.lookup |
When the work finishes, libuv puts the callback in a queue, and the event loop runs it on the main thread.
const fs = require("fs");console.log("Start");fs.readFile("data.txt", "utf8", (err, data) => {console.log("File read");});console.log("End");// Output: Start, End, File read
Startprints.fs.readFilehands the file read to libuv. The main thread does not wait.Endprints.- When the file is read, libuv queues the callback, and the event loop runs it.
File readprints.
The Node.js Event Loop Phases
The browser event loop has one callback queue. The Node.js event loop, provided by libuv, has phases, and each phase has its own queue.
| Phase | What runs here |
|---|---|
| 1. Timers | setTimeout and setInterval callbacks whose time is up |
| 2. Pending callbacks | Some system-level I/O callbacks deferred from the last loop |
| 3. Idle, prepare | Used internally by Node.js |
| 4. Poll | Gets new I/O events and runs I/O callbacks (files, network). Waits here if there is nothing else to do |
| 5. Check | setImmediate callbacks |
| 6. Close callbacks | Close events, such as socket.on("close", ...) |
Between every callback, Node.js empties two microtask queues, in this order:
- The
process.nextTickqueue (highest priority) - The promise microtask queue
const fs = require("fs");fs.readFile(__filename, () => {setTimeout(() => console.log("timeout"), 0);setImmediate(() => console.log("immediate"));process.nextTick(() => console.log("nextTick"));Promise.resolve().then(() => console.log("promise"));});// Output: nextTick, promise, immediate, timeout
- The
readFilecallback runs in the poll phase. - When it finishes,
nextTickruns, thenpromise(microtasks first). - The loop moves to the check phase, so
immediateruns. - The loop comes back around to the timers phase, so
timeoutruns last.
Note: outside an I/O callback (in the main module), the order of
setTimeout(fn, 0)andsetImmediateis not guaranteed. Inside an I/O callback,setImmediatealways runs first.
Don't Block the Event Loop
Because there is only one main thread, long synchronous code blocks everything:
const http = require("http");http.createServer((req, res) => {// Blocks the main thread for every requestfor (let i = 0; i < 5_000_000_000; i++) {}res.end("Done");}).listen(3000);
While this loop runs, no other request can be handled. Avoid heavy CPU work on the main thread, and avoid sync APIs such as fs.readFileSync inside request handlers.
Summary
- Node.js runs your JavaScript on one main thread, with one call stack.
- libuv is a C library that provides the event loop and async I/O.
- V8 runs the code, and libuv handles the waiting.
- Network I/O uses the operating system's async features. File system, crypto, zlib, and
dns.lookupuse libuv's thread pool. - The Node event loop has six phases: timers, pending callbacks, idle/prepare, poll, check, and close callbacks.
process.nextTickand promise microtasks run between every callback, withnextTickfirst.- Never block the main thread with heavy synchronous work.
Interview Answer: How Does Node.js Handle Async Work If It Is Single-Threaded?
Node.js runs JavaScript on a single main thread, but it uses libuv, a C library, for asynchronous work. When I call an async function such as fs.readFile, Node hands the work to libuv and keeps running the next line.
libuv handles network I/O using the operating system's non-blocking features, such as epoll on Linux, so no extra thread is needed. For work the OS can't do asynchronously, such as file system operations, crypto, and zlib, libuv uses a thread pool of 4 threads by default. When the work finishes, the callback is queued, and the event loop runs it on the main thread.
The Node event loop runs in phases: timers, pending callbacks, idle/prepare, poll, check, and close callbacks. Between every callback, Node empties the process.nextTick queue and then the promise microtask queue. Because everything runs on one thread, I avoid blocking the event loop with heavy synchronous code.
The next topic is the thread pool in detail.