CodeOath
← All posts
Node.js36 min total · 14 parts

Node.js Fundamentals: The Runtime, the Event Loop, and Building Real APIs

Part 2 of 14 · ~3 min

What Node.js Actually Is

Node is two major pieces glued together, plus a set of JavaScript APIs (fs, http, process, and friends) written on top of them:

  • V8 — the same JavaScript engine Chrome uses. It parses and executes your JavaScript, manages the heap, and runs garbage collection. V8 knows nothing about files, sockets, or timers; on its own it's just a JS execution engine, the same one whether it's running inside a browser tab or inside Node.
  • libuv — a C library that gives Node everything V8 doesn't: an event loop, a thread pool for work that can't be done asynchronously by the OS, and cross-platform bindings for file I/O, networking, DNS lookups, and timers. libuv is what actually turns "JavaScript" into "a JavaScript program that can serve ten thousand concurrent connections."
Your JavaScript code
        │
        ▼
   Node.js APIs (fs, http, net, process, ...)
        │
   ┌────┴─────┐
   ▼          ▼
  V8        libuv
(runs JS)  (event loop, thread pool, async I/O, timers)

The reason this distinction matters: your JavaScript still runs on a single thread, exactly as it does in a browser tab. There is one call stack, and V8 executes one JS statement at a time, full stop — Node does not give you free multithreading just because it's a server runtime. What Node adds is a separate layer, living in libuv, that handles I/O without tying up that one JS thread while it waits.

Concretely: when you call fs.readFile(), Node doesn't pause your JS thread to wait for the disk. It hands the request to libuv, which either asks the OS to do the read asynchronously (the common case for network sockets on Linux/macOS via epoll/kqueue) or, for operations the OS can't do async-natively — most filesystem calls, DNS lookups via getaddrinfo, some crypto — runs it on a small internal thread pool (four threads by default). Either way, your JS thread is immediately free to keep running other code. When the disk read (or network response, or DNS lookup) finishes, libuv queues a callback, and the event loop picks it up and runs it on the JS thread when its turn comes.

const fs = require("fs");

console.log("1: reading file...");
fs.readFile("./big-file.txt", "utf8", (err, data) => {
  console.log("3: file read finished");
});
console.log("2: this runs before the file finishes reading");
// Output: 1, 2, 3 — the read happens off the JS thread; the callback
// only runs once the JS thread is free and the event loop gets to it

This is the whole model in one sentence: one thread runs your JavaScript, and a separate system handles I/O concurrency, notifying that one thread via callbacks when results are ready. It's why Node can hold open thousands of idle keep-alive connections cheaply — they cost a socket and a bit of memory, not a thread — but it's also exactly why a single expensive synchronous computation on that one JS thread blocks every other request the process is handling, something the next section covers in more depth, and something the async-patterns chapter comes back to with a concrete example.

Common mistake: assuming "Node is single-threaded" means "Node can't do anything concurrently." It can — extremely well, for I/O. What it can't do is run two pieces of your JavaScript at the same literal instant. Those are very different claims, and conflating them is why people either overestimate Node's ability to handle CPU-bound work or wrongly assume it can't scale at all.