On this page

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?

BenefitWhy it helps
No race conditions in your codeTwo pieces of JS never change the same variable at the same time
No locksYou don't manage threads or locks yourself
Low memory per requestNo 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

LayerWhat it does
Your JavaScript codeYour app
Node.js APIsfs, http, crypto, timers, written in JavaScript
Node.js bindingsC++ code that connects JavaScript to the layers below
V8Runs your JavaScript: call stack, memory heap, compilation
libuvEvent 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 workHow libuv does itExamples
Network I/OAsks the operating system to do it asynchronously (epoll on Linux, kqueue on macOS, IOCP on Windows). No extra thread neededTCP, HTTP requests, sockets
Work the OS can't do asynchronouslyRuns 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
  1. Start prints.
  2. fs.readFile hands the file read to libuv. The main thread does not wait.
  3. End prints.
  4. When the file is read, libuv queues the callback, and the event loop runs it. File read prints.

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.

PhaseWhat runs here
1. TimerssetTimeout and setInterval callbacks whose time is up
2. Pending callbacksSome system-level I/O callbacks deferred from the last loop
3. Idle, prepareUsed internally by Node.js
4. PollGets new I/O events and runs I/O callbacks (files, network). Waits here if there is nothing else to do
5. ChecksetImmediate callbacks
6. Close callbacksClose events, such as socket.on("close", ...)

Between every callback, Node.js empties two microtask queues, in this order:

  1. The process.nextTick queue (highest priority)
  2. 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
  1. The readFile callback runs in the poll phase.
  2. When it finishes, nextTick runs, then promise (microtasks first).
  3. The loop moves to the check phase, so immediate runs.
  4. The loop comes back around to the timers phase, so timeout runs last.

Note: outside an I/O callback (in the main module), the order of setTimeout(fn, 0) and setImmediate is not guaranteed. Inside an I/O callback, setImmediate always 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 request
for (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.lookup use libuv's thread pool.
  • The Node event loop has six phases: timers, pending callbacks, idle/prepare, poll, check, and close callbacks.
  • process.nextTick and promise microtasks run between every callback, with nextTick first.
  • 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.

Next TopicThread Pool