§ 0 · The Short Answer FirstThe thread we actually care about
This is the foundational lesson. Everything that comes later in the series — TCP, HTTP, Express, WebSockets, streams, timers, microtasks, crypto, child processes, worker threads — assumes the mental model built here.
We are going to trace this single line:
const fs = require("fs"); fs.readFile("file.txt", (err, data) => { console.log(data); });
…all the way from your JavaScript text down to the CPU and the storage device, and all the way back up to your callback. Along the way, we build the entire CPU → OS → process → thread → syscall → libuv → V8 picture from first principles.
When people say:
"Node.js must not block the thread"
they almost always mean:
the main JavaScript execution thread — the OS thread inside the Node process where V8 runs your JavaScript call stack.
For fs.readFile("file.txt", cb):
- The main JS thread initiates the work.
- A worker libuv worker thread (a separate OS thread) actually performs the blocking filesystem syscalls.
- The kernel OS kernel owns the file, file descriptors, page cache, and storage device.
- When the read finishes, a completion is queued.
- The event loop picks it up and the main JS thread runs your callback.
fs.readFile does not magically eliminate blocking. It moves the blocking off the main JS thread and onto a libuv worker thread.§ 1 · The Full Ownership ChainEvery layer, top to bottom
Every operation in this series follows the same layered chain. Memorize this shape — every later part is a variation of it.
Interactive — click any layer
Step by step
JavaScript API ↓ Node JS wrapper / library layer ↓ Node C++ binding / native runtime implementation ↓ libuv / OS async API / database engine / network stack ↓ kernel / hardware / external service ↓ completion path back upward ↓ callback / promise / request handler runs on the main JS thread
Keep this picture next to you for the rest of the lesson. We are going to walk down it, then walk back up it.
§ 2 · Start at the CPUWhat does the CPU actually do?
The CPU is just an instruction-executing machine. Forget JavaScript for a moment.
Interactive — animate the cycle
Step by step
FETCH ──▶ DECODE ──▶ EXECUTE ──▶ ADVANCE IP ──┐ ▲ │ └────────────────────────────────────────────┘
The CPU understands things like:
- LOAD a value from memory into a register
- ADD / SUB / MUL on registers
- COMPARE two values
- JUMP to another address
- CALL / RETURN
- syscall instructions (
SYSCALL,INT 0x80,SVC, depending on architecture)
The CPU does not understand:
- JavaScript
- promises
- callbacks
- Express
- files
- sockets
- HTTP
fs.readFile
Those are abstractions built on top of raw instructions.
§ 3 · How JavaScript Reaches the CPUSource → bytecode → machine code
Your JavaScript text isn't executed directly. It is compiled by V8 into machine code, and that is what the CPU runs.
Step by step
JavaScript source │ ▼ V8 parser / bytecode (Ignition) │ ▼ V8 optimizing compiler (TurboFan) ← for hot code │ ▼ machine instructions │ ▼ CPU executes them
The important point: by the time fs.readFile(...) is "running," it is real machine instructions on a real CPU core, on a real OS thread.
§ 4 · The CPU is Synchronous by DefaultOne instruction at a time
A thread is, at first approximation, "a sequence of instructions executing over time."
Time ─────────────────────────────────────────────► Thread: [Instr 1] → [Instr 2] → [Instr 3] → [Instr 4] → ...
Each instruction starts, finishes, and only then does the next one start. There is no built-in "do this in the background" at the CPU level — that has to be engineered above.
This is exactly why "blocking" is dangerous: if a single instruction path can't continue (because it's waiting on disk, network, etc.), the thread stops progressing — even though the CPU itself is fine.
§ 5 · What is a Process?The OS resource container
When you run:
node index.js
the OS creates a process. A process is a resource container.
The pieces involved
┌──────────────────────────────────────┐ │ Process: node index.js │ │ │ │ ├─ virtual memory space │ │ ├─ file descriptor table │ │ ├─ environment variables │ │ ├─ loaded native libraries │ │ ├─ permissions / user / group │ │ ├─ one or more threads │ │ └─ OS-managed metadata (PID, etc.) │ └──────────────────────────────────────┘
How a process is created
shell types `node index.js` ↓ shell calls fork() / exec() ↓ kernel allocates PID, virtual address space, fd table ↓ loader maps Node executable into memory ↓ main thread starts at Node's entry point
§ 6 · What is a Thread?An execution path inside a process
A thread is an execution path inside a process.
What one thread's state consists of
┌───────────────────────────────┐ │ Thread │ │ │ │ - instruction pointer │ ← where in the code am I? │ - call stack │ ← what functions am I inside? │ - CPU registers (saved) │ ← current working values │ - scheduler state │ ← RUNNING / RUNNABLE / BLOCKED │ - access to process memory │ ← shared with siblings └───────────────────────────────┘
A process can have many threads. They all share the same memory.
┌──────────────────────────────────────────────────────────────┐ │ Process │ │ │ │ shared: memory, fd table, env, libraries │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Thread A │ │ Thread B │ │ Thread C │ │ │ │ stack/regs │ │ stack/regs │ │ stack/regs │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ └──────────────────────────────────────────────────────────────┘
§ 7 · Where Does JavaScript Run in Node?The threads inside the process
When Node starts, the OS gives it a process. Inside that process, Node creates several threads. The most important is the main thread, where V8 lives and your JavaScript runs.
The pieces involved
┌─────────────────────────────────────────────────────────┐ │ Node Process │ │ │ │ ┌───────────────────────────────────────────────────┐ │ │ │ Main JS Thread │ │ │ │ - V8 engine │ │ │ │ - JS call stack │ │ │ │ - Node JS APIs │ │ │ │ - libuv event loop │ │ │ └───────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────┐ │ │ │ libuv worker thread pool (default size 4) │ │ │ │ - worker 1 │ │ │ │ - worker 2 │ │ │ │ - worker 3 │ │ │ │ - worker 4 │ │ │ └───────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────┐ │ │ │ Other internal threads (V8 GC helpers, etc.) │ │ │ └───────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────┘
When people say "JavaScript is single-threaded," they mean: your JavaScript runs on exactly one thread — the main JS thread. They do not mean the Node process has only one thread. It clearly does not.
UV_THREADPOOL_SIZEThe pool size is configurable via UV_THREADPOOL_SIZE (default 4, max 1024 in modern libuv). That detail matters later when we talk about crypto.pbkdf2 and zlib.
§ 8 · The JavaScript Call Stack Lives on the Main JS ThreadFrames on top of an OS thread's native stack
function a() { b(); } function b() { c(); } function c() { console.log("hello"); } a();
This runs on the main JS thread. The call stack grows and shrinks:
Start: After a() calls b(): After b() calls c():
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ (empty) │ │ b() │ │ c() │
└───────────────┘ ├───────────────┤ ├───────────────┤
│ a() │ │ b() │
└───────────────┘ ├───────────────┤
│ a() │
└───────────────┘Underneath, this JS call stack is sitting inside the native OS thread's stack:
┌────────────────────────────────────┐ │ OS Thread (main JS thread) │ │ │ │ Native stack: │ │ ┌──────────────────────────┐ │ │ │ V8 internal frames │ │ │ │ ┌──────────────────────┐ │ │ │ │ │ JS frame: c() │ │ │ │ │ │ JS frame: b() │ │ │ │ │ │ JS frame: a() │ │ │ │ │ └──────────────────────┘ │ │ │ └──────────────────────────┘ │ └────────────────────────────────────┘
§ 9 · Now Bring in the Operating SystemUser space vs kernel space
Your program cannot directly touch hardware or privileged resources. It cannot:
- read raw bytes from a disk
- talk directly to a network card
- schedule CPU cores
- map physical memory pages
- open a file from the filesystem
Those are the OS kernel's jobs. Your program asks the kernel to do them.
User space and kernel space, side by side
┌──────────────────────────────────────┐
│ User space │
│ │
│ your JS │
│ Node │
│ V8 │
│ libuv │
│ C runtime │
└──────────────┬───────────────────────┘
│ ← syscall boundary
▼
┌──────────────────────────────────────┐
│ Kernel space │
│ │
│ process / thread scheduler │
│ virtual memory manager │
│ VFS + filesystem drivers │
│ network stack │
│ device drivers │
└──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────┐
│ Hardware │
│ CPU, RAM, NIC, disk controller │
└──────────────────────────────────────┘§ 10 · What is a Syscall?Trapping into kernel mode
A syscall is the mechanism by which user-space code asks the kernel to do something privileged. From C, you write:
read(fd, buffer, size);
At the CPU level this becomes a special instruction (syscall on x86_64 Linux) that traps into kernel mode.
Step by step
user-space code calls read() │ ▼ CPU executes syscall instruction │ ▼ CPU switches to kernel mode │ ▼ kernel handles the request │ ▼ CPU switches back to user mode │ ▼ control returns to user-space code
§ 11 · Why Can a Syscall Block?External reality has latency
Some syscalls finish immediately. getpid() just returns a number the kernel already knows.
Others depend on external reality:
- disk read
- network packet
- terminal input
- another process
For those, the kernel has two choices:
- Return immediately with "not ready, try later" (non-blocking mode).
- Put the calling thread to sleep until the data is available (blocking mode).
fs.readFile ultimately ends up in blocking filesystem syscalls under the hood. Here is what blocking looks like:
Thread calls read(fd, buf, n)
│
▼
enters kernel
│
▼
kernel checks: data available?
│
├── YES → copy bytes, return immediately
│
└── NO → mark thread BLOCKED
│
▼
scheduler picks another runnable thread
│
▼
... time passes, disk I/O completes ...
│
▼
kernel marks thread RUNNABLE
│
▼
scheduler eventually picks it again
│
▼
read() returns with dataInteractive — thread state machine
The thread state machine
┌──────────────┐ blocking syscall ┌──────────────┐
│ RUNNING │ ───────────────────▶ │ BLOCKED │
└──────┬───────┘ └──────┬───────┘
│ │ I/O completes
│ scheduler picks me ▼
│ ┌──────────────┐
└────────────────────────────── │ RUNNABLE │
└──────────────┘§ 12 · Which Thread Gets Blocked?Only the one that called the syscall
The thread that made the syscall. Only that one.
Thread A: read(fd, buf, n) ← blocks Thread A only Thread B: still running Thread C: still running
This is the whole reason libuv has a worker thread pool. If Node's main JS thread made a blocking read() directly, the entire JavaScript world would freeze. Instead, Node hands the work to a worker thread, which can safely block without anyone in JS noticing.
§ 13 · Why Is That Bad for Node?The wrong-design version
Imagine a hypothetical Node where fs.readFile blocks the main JS thread:
// hypothetical bad design http.createServer((req, res) => { const data = blockingReadFile("big.txt"); // blocks main JS thread res.end(data); }).listen(3000);
Time ────────────────────────────────────────────────────────► Request A: [start] [BLOCKING READ ............................] Request B: [waits — main thread is asleep .....] Request C: [waits — main thread is asleep] Main JS thread: [A starts] [BLOCKED — cannot run anything else ..............]
While the main thread sleeps, no JS runs at all. Not your handler, not timers, not other request handlers, not promise continuations. The server is effectively dead until the read finishes.
Now the correct async design:
Time ────────────────────────────────────────────────────────►
Request A: [start] [delegate I/O] ............... [callback]
Request B: [handle B]
Request C: [handle C]
Main JS thread:
[A start] [free] [B] [C] ............ [A callback runs later]
Worker thread:
[open/read/close on disk ......]
§ 14 · The Problem libuv Solves, Stated PreciselyConstraints & solution
Constraints:
- JavaScript runs on a single main JS thread (a hard V8/Node design choice).
- That thread must remain responsive — it has to keep handling requests, timers, callbacks.
- Many useful operations (disk I/O, DNS, TLS handshakes, crypto, compression) take time.
Solution:
A cross-platform layer (libuv) that:
- exposes an event loop
- exposes a worker thread pool for blocking operations
- wraps OS readiness/completion APIs (epoll on Linux, kqueue on macOS/BSD, IOCP on Windows) for sockets
- queues completions so the main JS thread can pick them up at safe points
The pieces involved
┌─────────────────────────────────────────────────────────────┐ │ libuv │ │ │ │ ┌────────────────────────────┐ ┌───────────────────────┐ │ │ │ Event loop (on main thread)│ │ Worker thread pool │ │ │ │ - timers phase │ │ - blocking fs ops │ │ │ │ - pending callbacks │ │ - DNS getaddrinfo │ │ │ │ - poll I/O phase │ │ - crypto pbkdf2 │ │ │ │ - check phase │ │ - zlib compression │ │ │ │ - close callbacks │ │ - user uv_queue_work │ │ │ └────────────────────────────┘ └───────────────────────┘ │ │ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ OS abstraction layer │ │ │ │ epoll (Linux) / kqueue (macOS) / IOCP (Windows) │ │ │ └────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘
For fs.readFile, the path is worker pool, not epoll. Filesystem I/O on POSIX systems doesn't have a great non-blocking story, so libuv just throws the work onto a worker thread and lets it block there.
For sockets, libuv uses readiness APIs instead. We will hit that in Parts 4, 8 and 9.
§ 15 · The Runtime Walkthrough Beginsfs.readFile("file.txt", cb)
Now we trace the line concretely. We'll annotate which thread owns each step.
const fs = require("fs"); fs.readFile("file.txt", (err, data) => { console.log(data); });
The whole path, end to end
[MAIN JS THREAD] [LIBUV / OS]
your JS
│
▼
fs.readFile("file.txt", cb)
│
▼
Node JS wrapper (lib/fs.js)
│
▼
Node C++ binding (FSReqCallback)
│
▼
libuv uv_fs_read request created
│
├─── submitted to worker pool ─────────▶ worker thread picks up job
│ │
▼ ├── open("file.txt")
fs.readFile returns ├── read(fd, buf, n)
│ ├── close(fd)
your JS continues running │
▼
completion queued
│
◄─────────── completion delivered ──────────┘
│
event loop poll/check phase processes completion
│
▼
Node prepares JS args (err, Buffer)
│
▼
V8 invokes cb(err, data) on main JS thread
│
▼
console.log(data)▶ Interactive trace stepper — see §33 · The Full Annotated Thread Ownership for the complete 24-step interactive walkthrough.
§ 16 · Step 1 — The JavaScript Line ItselfA function call, a function reference
fs.readFile("file.txt", (err, data) => { console.log(data); });
At runtime, this is just a function call. The main JS thread is currently executing JavaScript. The arrow function on the second line is a JavaScript function object sitting in V8's heap.
A snapshot at this instant
MAIN JS THREAD
JS call stack:
┌────────────────────────────────────┐
│ fs.readFile("file.txt", cb) │
├────────────────────────────────────┤
│ <top-level module code> │
└────────────────────────────────────┘
V8 heap:
┌────────────────────────────────────┐
│ Function object: cb │
│ [[Code]]: (err,data)=>console... │
│ [[Scope]]: closure over module │
└────────────────────────────────────┘Crucial point: the callback does not run now. You are passing a value (a function reference). It will be invoked later by Node, on the main JS thread, after the file has been read.
§ 17 · Step 2 — Node's JS Wrapper (lib/fs.js)Validation & request allocation
fs.readFile is implemented in JavaScript first. The wrapper:
- validates the path argument (string, Buffer, or URL)
- validates the callback (must be a function)
- normalizes options (encoding, flag, signal, etc.)
- allocates an internal request object
- calls down into the native binding
What the main JS thread does, in order
fs.readFile("file.txt", cb)
│
▼
validate path ← still on main JS thread
│
▼
validate callback
│
▼
allocate FSReqCallback (native)
│
▼
attach JS callback to FSReqCallback
│
▼
call binding.read() ← about to cross into C++FSReqCallback is that storage.§ 18 · Step 3 — Crossing into Native CodeThe JS↔C++ bridge
JavaScript cannot directly issue OS syscalls. There is no JS instruction kernel.read(fd, buf, n). So Node provides a bridge: C++ bindings registered with V8 as native functions callable from JS.
┌──────────────────────────────┐
│ JavaScript world (V8) │
│ safe, GC'd, high-level │
└──────────────┬───────────────┘
│ V8 native function bridge
▼
┌──────────────────────────────┐
│ Node C++ internals │
│ can call C/C++ libraries │
│ can call libuv │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ libuv → OS kernel │
└──────────────────────────────┘When the JS wrapper calls binding.open(path, flags, mode, req):
- V8 sees that
binding.openis a registered native function. - V8 marshals the JS arguments into C++ values.
- V8 enters the registered C++ function.
- The C++ function runs on the main JS thread (still — we haven't gone async yet).
§ 19 · Step 4 — The C++ Binding Builds a libuv RequestThree layers of callback, stacked
In Node's source (src/node_file.cc), the binding for filesystem ops looks roughly like:
- take the
FSReqCallback*from JS - fill out a
uv_fs_trequest structure - call
uv_fs_open/uv_fs_read/uv_fs_closewith that request, passing libuv a completion callback
How the pieces relate at this moment
┌──────────────────────────────────────────────────────────┐ │ Node Process — main JS thread │ │ │ │ V8 heap: │ │ ┌────────────────────┐ │ │ │ JS callback `cb` │ │ │ └────────┬───────────┘ │ │ │ referenced by │ │ ▼ │ │ ┌────────────────────────────┐ │ │ │ FSReqCallback (C++) │ │ │ │ - persistent JS handle │ │ │ │ - oncomplete trampoline │ │ │ └────────┬───────────────────┘ │ │ │ owns │ │ ▼ │ │ ┌────────────────────────────┐ │ │ │ uv_fs_t request (libuv) │ │ │ │ - path: "file.txt" │ │ │ │ - operation type │ │ │ │ - result fields │ │ │ │ - data ptr → FSReqCallback│ │ │ │ - cb: AfterRead C++ fn │ │ │ └────────────────────────────┘ │ └──────────────────────────────────────────────────────────┘
Three layers of "callback" exist, stacked:
- The JS arrow function
cb(your code). - The C++
oncompletemethod onFSReqCallback(Node-internal). - The
uv_fs_t.cbfield that libuv will call when work finishes.
Each one calls the next on the way back up. We'll see this on the return path.
§ 20 · Step 5 — Submit the Work to libuvThe thread boundary is crossed
Now the C++ binding calls something like:
uv_fs_read(loop, req, fd, &iov, 1, offset, AfterRead);
libuv looks at this and decides: "filesystem op → put it on the worker pool."
Step by step
[MAIN JS THREAD] [LIBUV] C++ binding │ ▼ uv_fs_read(...) │ ├──── submit work item ───────────────▶ libuv work queue │ │ ▼ │ worker thread return up through C++ → JS │ picks up the │ │ job ▼ ▼ fs.readFile() returns to your code worker runs the syscalls
Inside the work queue
┌────────────────────────────────────────────────────┐
│ libuv work queue │
│ │
│ ┌──────────────────────────────┐ │
│ │ uv_fs_t (read "file.txt") │ ◀─ just queued │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ uv_fs_t (other pending work) │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────────────────────┘
│
│ worker threads pull from this queue
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ worker 1 │ │ worker 2 │ │ worker 3 │ │ worker 4 │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘After submitting, the main JS thread does not wait. Control returns up through the C++ binding, back into the JS wrapper, and fs.readFile() returns. Your JavaScript continues immediately to the next statement.
§ 21 · The Critical DiagramMemorize the shape
This is the diagram that sits at the center of this lesson. Memorize the shape — every later operation in the series will be a variation of it.
The same path, compressed
MAIN JS THREAD LIBUV WORKER THREAD
fs.readFile("file", cb)
│
├── create request
├── store callback
├── submit to libuv ───────────────▶ worker picks up job
│ │
│ ├── open()
│ ├── read()
│ ├── close()
│ │
main thread continues │
│ ▼
│ ◀──────────── completion queued ─── done
│
event loop processes completion
│
cb(err, data) runs on main JS thread§ 22 · Step 6 — "Main Thread Continues" in DetailWhat runs while the worker works
After fs.readFile() returns, your JS keeps running. Concretely:
fs.readFile("file.txt", cb); console.log("after readFile");
Almost always prints:
after readFile <file contents later>
The main JS thread, on a timeline
Time ───────────────────────────────────────────────►
[fs.readFile called]
│
├─ submit job to libuv
│
[fs.readFile returns]
│
[console.log("after readFile")]
│
[other top-level code finishes]
│
[JS call stack empties]
│
[event loop runs]
│
[eventually: cb(err, data)]The JS call stack across time
During fs.readFile call: After it returns: After top-level done: ┌────────────────────────────┐ ┌────────────────────────────┐ ┌────────────────────────────┐ │ binding.open / uv_fs_* │ │ <top-level module code> │ │ (empty) │ ├────────────────────────────┤ └────────────────────────────┘ └────────────────────────────┘ │ Node fs.readFile wrapper │ │ ├────────────────────────────┤ ▼ │ <top-level module code> │ event loop now runs └────────────────────────────┘ completed callbacks
The callback cannot run until the call stack is empty and the event loop reaches the relevant phase.
§ 23 · Step 7 — Inside the Worker ThreadA real OS thread, scheduled like any other
A libuv worker thread is a real OS thread, with its own stack and registers, scheduled by the OS just like any other.
┌────────────────────────────────────┐ │ Libuv Worker Thread │ │ │ │ - instruction pointer │ │ - native call stack │ │ - CPU registers (when running) │ │ - scheduler state │ │ - SHARES process memory with all │ │ other threads (including main) │ └────────────────────────────────────┘
When this worker pulls our job, it executes (roughly):
LIBUV WORKER THREAD
pulled uv_fs_t for "file.txt"
│
▼
┌────────────────────────────────────┐
│ open("file.txt", O_RDONLY) ─▶ fd │
└────────────┬───────────────────────┘
▼
┌────────────────────────────────────┐
│ stat to get file size │
└────────────┬───────────────────────┘
▼
┌────────────────────────────────────┐
│ allocate buffer of right size │
└────────────┬───────────────────────┘
▼
┌────────────────────────────────────┐
│ read(fd, buf, size) │
└────────────┬───────────────────────┘
▼
┌────────────────────────────────────┐
│ close(fd) │
└────────────┬───────────────────────┘
▼
┌────────────────────────────────────┐
│ write result/error into uv_fs_t │
└────────────┬───────────────────────┘
▼
┌────────────────────────────────────┐
│ signal event loop: completion! │
└────────────────────────────────────┘fs.readFile specifically does open + stat + read + close as a single conceptual operation. That is why it can be expensive on huge files — it reads the entire file into memory before invoking your callback. (fs.createReadStream is the streaming alternative; we cover it in Part 3.)
§ 24 · Step 8 — open() and the file descriptor table
open("file.txt", O_RDONLY) asks the kernel:
- does this file exist?
- does this process have permission to read it?
- find the on-disk location (inode)
- create a kernel "open file" object
- give the process a small integer handle into its fd table
That integer is the file descriptor.
┌─────────────────────────────────────────────────┐
│ Node process — file descriptor table │
│ │
│ fd → kernel object │
│ ───────────────────── │
│ 0 → stdin │
│ 1 → stdout │
│ 2 → stderr │
│ 7 → TCP socket (epoll-watched) │
│ 17 → open file: file.txt ◀── new! │
│ │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Kernel "open file" object │
│ - inode reference │
│ - current offset │
│ - access mode (read-only) │
│ - reference count │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Inode (filesystem-level) │
│ - file metadata │
│ - pointers to data blocks │
└─────────────────────────────────────────────────┘17) is not the file. It is a process-local index into a kernel-managed table of open resources. Sockets, pipes, files, devices — they all show up as fds.§ 25 · Step 9 — read(), the kernel, and the page cache
The worker calls:
read(fd, buf, size);
Meaning: "Kernel, copy bytes from the open file at fd into this user-space buffer."
worker thread
│
▼
read(fd, buf, size) (user space)
│
▼ syscall trap
─────────────────────────────────────────────
kernel space
│
▼
sys_read()
│
├── lookup fd → kernel "open file" object
├── compute file offset
│
▼
filesystem driver: ext4 / APFS / NTFS / ...
│
├── check page cache for needed pages
│
├── HIT → copy from RAM to user buf
│
└── MISS → submit block I/O request to disk
│
▼
worker thread sleeps (BLOCKED)
│
▼
disk controller fetches blocks
│
▼
DMA transfers blocks into page cache
│
▼
kernel wakes worker thread
│
▼
copy page-cache bytes to user buf
▼
return n bytes read
─────────────────────────────────────────────
▼
back in user space, worker continuesEither side of the boundary:
┌─────────────────────────────────────────────────┐
│ Kernel (in-RAM) │
│ │
│ ┌──────────────────────┐ │
│ │ Page cache │ ← cached file pages │
│ │ page A | page B ... │ │
│ └────────┬─────────────┘ │
│ │ hit → memcpy │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ User buf in worker │ │
│ └──────────────────────┘ │
│ │
│ miss path: │
│ ┌──────────────────────┐ │
│ │ Block I/O scheduler │ │
│ └────────┬─────────────┘ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ Storage driver │ │
│ └────────┬─────────────┘ │
└───────────┼─────────────────────────────────────┘
▼
┌──────────────────────┐
│ SSD / HDD / NVMe │
└──────────────────────┘read() blocks waiting on disk, it blocks the libuv worker thread in kernel mode. The main JS thread is unaffected — it's running JavaScript or sitting in epoll_wait in the event loop, depending on what's in flight.§ 26 · Step 10 — Scheduler states during the read
While the worker waits on disk, the OS scheduler may move it through these states:
┌──────────────┐
│ RUNNING │ ← actively executing on a CPU core
└──────┬───────┘
│ blocking syscall (page cache miss)
▼
┌──────────────┐
│ BLOCKED │ ← removed from run queue, waiting on disk
└──────┬───────┘
│ disk completes, kernel wakes thread
▼
┌──────────────┐
│ RUNNABLE │ ← eligible to run, in run queue
└──────┬───────┘
│ scheduler picks it
▼
┌──────────────┐
│ RUNNING │
└──────────────┘Interactive · Thread State Machine
Click each state to follow a worker thread through a page-cache-miss read:
The CPU isn't idle — it runs whatever else is runnable:
Run queue (per-CPU, simplified): before block: [main JS thread] [worker 1] [worker 2] [browser thread] worker 1 calls read() → cache miss: [main JS thread] [worker 2] [browser thread] blocked: [worker 1] disk done: [main JS thread] [worker 2] [browser thread] [worker 1]
§ 27 · Step 11 — Worker reports completion
The worker finishes (open → stat → read → close). It writes the result into the uv_fs_t:
- success: bytes are in a libuv-allocated buffer;
resultfield = number of bytes - failure:
resultis negative;errno-style error code is set (e.g.UV_ENOENT,UV_EACCES)
It then signals the event loop. On Linux this typically uses an eventfd or self-pipe that epoll_wait is watching. When the main thread is sleeping in epoll_wait, that signal wakes it up.
Boxed view of the handoff:
┌──────────────────────────────┐ ┌──────────────────────────────┐ │ LIBUV WORKER THREAD │ │ MAIN JS THREAD (event loop) │ │ │ │ │ │ done with syscalls │ │ sleeping in epoll_wait() │ │ │ │ │ ▲ │ │ ▼ │ │ │ wakes up │ │ write result into uv_fs_t │ │ │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ │ push uv_fs_t onto │ │ │ │ │ "completed work" queue │ │ │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ │ write to eventfd ─────────────┼────────▶│ epoll signals readiness │ │ │ │ │ │ │ │ ▼ │ │ ▼ │ │ go idle, wait for next job │ │ drain completed queue │ └──────────────────────────────┘ └──────────────────────────────┘
§ 28 · Step 12 — Completion sits in a queue
The completed uv_fs_t lives in libuv's "pending callbacks" / completed-work queue:
┌─────────────────────────────────────────────┐ │ libuv completed-work queue (main thread) │ │ │ │ ┌──────────────────────────────┐ │ │ │ uv_fs_t for "file.txt" │ ◀─ ours │ │ │ result: 4096 │ │ │ │ buf: <bytes> │ │ │ │ cb: AfterRead (C++) │ │ │ └──────────────────────────────┘ │ │ ┌──────────────────────────────┐ │ │ │ other completed reqs ... │ │ │ └──────────────────────────────┘ │ └─────────────────────────────────────────────┘
It does not run yet. It just exists, waiting for the event loop to process it.
§ 29 · Step 13 — The callback cannot interrupt running JS
This is the single most-important property of Node's concurrency model.
fs.readFile("file.txt", cb); for (let i = 0; i < 5_000_000_000; i++) {} console.log("done");
Even if the worker finishes the read in 2ms, the callback cannot fire while the loop is running. JavaScript executes run-to-completion.
Time ──────────────────────────────────────────────────────────►
Main JS thread:
[fs.readFile] [BIG LOOP ..............................] [done] [cb]
Worker thread:
[open/read/close] [completion queued]
Callback queue:
[cb waiting...........] [runs]§ 30 · Step 14 — Event loop picks up the completion
When your synchronous JS finishes, the JS call stack empties. The main thread returns into libuv's event loop, which cycles through phases:
┌────────────────────────────────────────────────────┐ │ libuv event loop iteration (one "tick") │ │ │ │ 1. timers ─ run due setTimeout/Interval │ │ 2. pending callbacks ─ some I/O callbacks │ │ 3. idle / prepare ─ internal │ │ 4. poll ─ epoll_wait for new I/O │ │ 5. check ─ run setImmediate │ │ 6. close callbacks ─ run 'close' events │ │ │ └────────────────────────────────────────────────────┘
Interactive · Event Loop Phases
Each phase runs in order; fs.readFile completions land in poll/pending. Click a phase to expand its purpose.
setTimeout / setIntervalepoll_wait for new I/O — fs.readFile completes heresetImmediate callbacks'close' eventsFor fs.readFile, the completion is processed when libuv runs queued I/O callbacks (during poll/pending phases, depending on platform and timing).
JS stack empty │ ▼ event loop tick begins │ ▼ poll/pending phase │ ▼ find completed uv_fs_t │ ▼ invoke uv_fs_t.cb → AfterRead (C++) │ ▼ AfterRead extracts result, invokes FSReqCallback::oncomplete │ ▼ oncomplete builds JS args, calls into V8 │ ▼ V8 pushes JS frame for cb(err, data) onto call stack │ ▼ your JS callback runs
§ 31 · Step 15 — Native result → JS values
The native side has raw bytes and an error code. Your JS callback expects (err, data) where data is a Buffer. Node converts:
[native side] [JS side]
uv_fs_t.result = -2 (UV_ENOENT) ─▶ err = Error("ENOENT: ...")
data = undefined
uv_fs_t.result = 4096 err = null
uv_fs_t.bufs[0] = <native bytes> ─▶ data = Buffer (wraps those bytes)┌──────────────────────────────────────┐
│ Native (libuv / C++ binding) │
│ │
│ uv_fs_t │
│ ├── result: 4096 │
│ ├── error: none │
│ └── buffer: <raw bytes> │
│ │
└──────────────┬───────────────────────┘
│ AfterRead translates
▼
┌──────────────────────────────────────┐
│ JS (V8) │
│ │
│ err = null │
│ data = Buffer ─┐ │
│ │ wraps │
│ ▼ │
│ <native byte storage> │
└──────────────────────────────────────┘The Buffer is special: it is a JS object whose backing storage is the native memory libuv allocated. It is not a copy. It is a view.
§ 32 · Step 16 — V8 actually runs your callback on the main JS thread
Now Node calls into V8 to invoke cb(err, data). V8 pushes a fresh JS frame onto the main thread's call stack:
MAIN JS THREAD — call stack snapshots (empty) → V8 invokes cb: ┌────────────────────────────┐ │ cb(err, data) │ └────────────────────────────┘ → cb body runs `console.log(data)`: ┌────────────────────────────┐ │ console.log(data) │ ├────────────────────────────┤ │ cb(err, data) │ └────────────────────────────┘ → console.log returns: ┌────────────────────────────┐ │ cb(err, data) │ └────────────────────────────┘ → cb returns: (empty) → event loop continues to next callback / next tick / next phase
§ 33 · The Full Annotated Thread Ownership21 steps · the whole operation
This is the ownership table for the whole operation. In every later part of the series we will annotate operations the same way.
Step Thread / Owner
──────────────────────────────────────────── ───────────────────────────────
fs.readFile("file.txt", cb) Main JS thread
Node JS wrapper validation Main JS thread
Allocate FSReqCallback Main JS thread (calls into C++)
Build uv_fs_t Main JS thread (in C++ binding)
Submit work to libuv queue Main JS thread
fs.readFile() returns Main JS thread
your subsequent JS runs Main JS thread
Worker pulls job Libuv worker thread
open("file.txt") syscall Libuv worker thread → kernel
stat() syscall Libuv worker thread → kernel
read() syscall (may block) Libuv worker thread → kernel
(page cache miss → disk I/O) Kernel + storage device (DMA)
close() syscall Libuv worker thread → kernel
Write result into uv_fs_t Libuv worker thread
Signal event loop (eventfd write) Libuv worker thread
Main thread wakes from epoll_wait Main JS thread
Run AfterRead (C++) Main JS thread
Run FSReqCallback::oncomplete Main JS thread
Build JS Buffer / Error Main JS thread (V8)
Invoke cb(err, data) Main JS thread
console.log(data) Main JS threadInteractive · 24-step Trace Stepper
Step through the entire fs.readFile lifecycle. Watch which thread owns each step.
Boxed summary:
┌─────────────────────────────────────────────────────────────────────────┐
│ NODE PROCESS │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Main JS Thread │ │
│ │ - all JS execution │ │
│ │ - Node JS wrappers │ │
│ │ - C++ bindings (entry/exit only) │ │
│ │ - libuv event loop (poll, timers, check, close) │ │
│ │ - V8 invocation of your callback │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Libuv Worker Thread Pool │ │
│ │ - blocking fs syscalls (open/read/close) │ │
│ │ - getaddrinfo (DNS) │ │
│ │ - crypto.pbkdf2 / scrypt │ │
│ │ - zlib compression/decompression │ │
│ │ - user code via uv_queue_work / Worker Threads (separate concept)│ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ OS KERNEL │
│ scheduler · VFS · filesystem · page cache · block I/O · device drivers│
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ HARDWARE — SSD / HDD / NVMe │
└─────────────────────────────────────────────────────────────────────────┘§ 34 · Wrong mental model vs correct mental model
fs.readFile is non-blocking, so the disk read doesn't block anything."read() inside the kernel. "Non-blocking" in Node-speak means "non-blocking with respect to the main JS thread," not "non-blocking with respect to the kernel."AfterRead). That routine, on the main JS thread, eventually asks V8 to invoke your JS callback.await fs.promises.readFile, it's somehow different at the OS level."fs.promises.readFile is a thin wrapper that calls the same C++ binding and submits the same uv_fs_t to the same worker pool. The only difference is the JS-level shape of the result (Promise vs callback).✓ Correct mental model
JS world: starts the work, gets the result later via callback/promise Native/libuv: moves blocking work off the main JS thread, queues completion OS kernel: owns files, fds, page cache, scheduler; can block worker threads Hardware: actually moves bytes Main JS thread: the thread that must remain free; only it can run your JS
§ 35 · CPU-bound vs I/O-boundWhat libuv can and cannot fix
libuv solves a specific problem: waiting on slow OS work. It does not solve "your JavaScript is hogging the CPU."
I/O-bound (libuv helps):
Time ───────────────────────────────────────────────►
Main JS thread:
[fs.readFile] [delegate] [other JS] [callback]
Worker thread:
[open/read/close ......]CPU-bound (libuv does not help):
fs.readFile("file.txt", cb); for (let i = 0; i < 5_000_000_000; i++) {}
Time ───────────────────────────────────────────────►
Main JS thread:
[readFile] [BIG LOOP — can't return to event loop ............]
Worker thread:
[done with read]
[completion queued, but cb cannot run]For CPU-bound work, you need Worker Threads (Part 10), not libuv's worker pool (which is for blocking syscalls, not arbitrary user JS).
§ 36 · The Cashier AnalogyThe cleanest mental model
- Main JS thread = the cashier.
- Libuv worker threads / OS readiness = the warehouse staff.
- OS kernel = the warehouse itself.
- Hardware = the shelves where stock physically lives.
┌──────────────┐ request ┌──────────────────┐ fetch ┌──────────────┐ │ Customer │ ───────────────▶ │ Cashier │ ───────────▶ │ Warehouse │ │ (your JS) │ │ (main JS thread) │ │ worker (libuv│ │ │ │ │ │ worker) │ │ │ │ serves next │ │ │ │ │ ◀─── notify ──── │ customer in line │ ◀─ delivery─ │ does slow │ │ │ (callback) │ while waiting │ │ disk work │ └──────────────┘ └──────────────────┘ └──────────────┘
The cashier never walks to the warehouse. The warehouse worker may stand in a corridor for an hour waiting on a forklift, but the cashier keeps serving customers.
§ 37 · Final Compressed Map of fs.readFile
[JavaScript]
fs.readFile("file.txt", cb)
│
[Node JS wrapper] (main JS thread)
validate args, store cb in FSReqCallback
│
[Node C++ binding] (main JS thread)
build uv_fs_t, hand to libuv, return
│
[libuv] (still main JS thread to enqueue)
push uv_fs_t onto worker queue
│
─────────────── thread boundary ─────────────────
│
[Libuv worker thread]
open() → stat() → read() → close() (may block in kernel)
│
[Kernel]
VFS → filesystem → page cache (or disk)
│
[Hardware]
SSD/HDD serves blocks, DMA into page cache
│
[Libuv worker thread]
write result/error into uv_fs_t, signal eventfd
│
─────────────── back across thread boundary ─────
│
[Main JS thread, in epoll_wait]
wakes up, drains completed-work queue
│
[C++ AfterRead → FSReqCallback::oncomplete]
build JS Buffer / Error
│
[V8]
invoke cb(err, data) on main JS thread
│
[your JS]
console.log(data)§ 38 · Final "Lock This In" Summary
fs.readFile does: main JS thread sets up the work; a libuv worker thread performs the blocking filesystem syscalls; the main JS thread later runs your callback.fs.readFile, the thread that gets blocked is a worker thread, not the main JS thread.- JavaScript initiates async work.
- Node's C++ bindings + libuv perform or monitor the slow work.
- The OS kernel manages the real resources and may block worker threads.
- Completions are queued for the main thread.
- V8 runs your callback on the main JS thread when it's safe.
That is the full fs.readFile() story from the CPU through the OS, libuv, the worker pool, and back to your callback.