Mission 05 · Node.js Internals Campaign

fs.readFile() — CPU, OS, libuv worker pool, and the callback return path

The foundational lesson. We trace a single line — fs.readFile("file.txt", cb) — 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.

39 sections52 figures9 interactive24-step trace

§ 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:

JavaScript the line we trace
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.
!
Lock this in
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

▦ Ownership stack click each layer for detail

Step by step

▸ ownership-chain.txtflow · top-down
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

▦ Instruction cycle click play to animate
Fetch
read next instruction
→
Decode
what does opcode mean?
→
Execute
run on registers/memory
→
Next IP
advance instruction pointer

Step by step

▸ instruction-cycle-arrows.txtflow
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.

!
Lock this in
The CPU does not understand "JavaScript callbacks." It only executes machine instructions on whichever thread the OS scheduler hands it.

§ 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

▸ js-to-cpu-arrows.txtcompile pipeline
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."

▸ thread-timeline.txtlinear execution
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:

shell
node index.js

the OS creates a process. A process is a resource container.

The pieces involved

▸ process-container.txtresource layout
┌──────────────────────────────────────┐
│ 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

▸ process-creation.txtfork/exec sequence
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
!
Lock this in
A process is a resource container. It does not "run." Threads run. The process holds the resources the threads use.

§ 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-state.txtper-thread fields
┌───────────────────────────────┐
│ 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-with-threads.txtshared memory model
┌──────────────────────────────────────────────────────────────┐
│ Process                                                      │
│                                                              │
│  shared: memory, fd table, env, libraries                    │
│                                                              │
│  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐      │
│  │ Thread A     │   │ Thread B     │   │ Thread C     │      │
│  │ stack/regs   │   │ stack/regs   │   │ stack/regs   │      │
│  └──────────────┘   └──────────────┘   └──────────────┘      │
│                                                              │
└──────────────────────────────────────────────────────────────┘
!
Lock this in
A process owns resources. A thread executes instructions using those resources. Blocking applies to a thread, not a process.

§ 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-threads.txtmain + worker pool + helpers
┌─────────────────────────────────────────────────────────┐
│ 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.

Default pool size
4 workers
Max pool size
1024 workers
Configured via
UV_THREADPOOL_SIZE

The 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

JavaScript
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:

▸ call-stack-growth.txtthree snapshots
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:

▸ js-frames-inside-os-stack.txtnested
┌────────────────────────────────────┐
│ OS Thread (main JS thread)         │
│                                    │
│  Native stack:                     │
│   ┌──────────────────────────┐     │
│   │ V8 internal frames       │     │
│   │ ┌──────────────────────┐ │     │
│   │ │ JS frame: c()        │ │     │
│   │ │ JS frame: b()        │ │     │
│   │ │ JS frame: a()        │ │     │
│   │ └──────────────────────┘ │     │
│   └──────────────────────────┘     │
└────────────────────────────────────┘
!
Lock this in
The "JS thread" is the OS thread currently running V8 with your JavaScript call frames on top of its native stack.

§ 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-vs-kernel-space.txtprivilege boundary
┌──────────────────────────────────────┐
│ 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:

C
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

▸ syscall-flow.txtuser→kernel→user
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:

  1. Return immediately with "not ready, try later" (non-blocking mode).
  2. 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:

▸ blocking-syscall-flow.txtdata ready or sleep
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 data

Interactive — thread state machine

▦ Thread states click a transition button
▶RUNNING
blocking syscall →
⏸BLOCKED
↑ scheduler picks me
I/O completes ↓
⏵RUNNABLE

The thread state machine

▸ thread-state-machine.txtRUNNING ⇄ BLOCKED ⇄ RUNNABLE
┌──────────────┐   blocking syscall    ┌──────────────┐
│  RUNNING     │  ───────────────────▶ │   BLOCKED    │
└──────┬───────┘                        └──────┬───────┘
       │                                       │ I/O completes
       │ scheduler picks me                    ▼
       │                                ┌──────────────┐
       └──────────────────────────────  │  RUNNABLE    │
                                        └──────────────┘
!
Lock this in
"Blocking" does not mean the CPU freezes. It means this specific thread is removed from the run queue until the kernel wakes it up. Other threads keep running. Other processes keep running. The CPU is fine.

§ 12 · Which Thread Gets Blocked?Only the one that called the syscall

The thread that made the syscall. Only that one.

▸ which-thread.txtper-thread blocking
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:

JavaScripthypothetical · do not do this
// hypothetical bad design
http.createServer((req, res) => {
  const data = blockingReadFile("big.txt");  // blocks main JS thread
  res.end(data);
}).listen(3000);
✗ Wrong
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:

✓ Correct
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 ......]
!
Lock this in
Node's entire concurrency story depends on the main JS thread never sitting and waiting for slow OS work. That is the problem libuv solves.

§ 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-architecture.txtevent loop + worker pool + OS abstraction
┌─────────────────────────────────────────────────────────────┐
│ 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.

JavaScript
const fs = require("fs");

fs.readFile("file.txt", (err, data) => {
  console.log(data);
});

The whole path, end to end

▸ big-picture-map.txtboth threads side by side
[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

JavaScript
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

▸ js-thread-snapshot.txtstack + heap
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

▸ wrapper-flow.txtstill on main JS thread
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++
!
Lock this in
Async APIs must store your callback somewhere persistent, because the original function call returns long before the callback runs. The 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.

▸ js-cpp-bridge.txtthree-layer descent
┌──────────────────────────────┐
│ 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):

  1. V8 sees that binding.open is a registered native function.
  2. V8 marshals the JS arguments into C++ values.
  3. V8 enters the registered C++ function.
  4. The C++ function runs on the main JS thread (still — we haven't gone async yet).
!
Lock this in
Crossing the JS↔C++ boundary does not switch threads. It just switches what kind of code is running on the same OS thread.

§ 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_t request structure
  • call uv_fs_open / uv_fs_read / uv_fs_close with that request, passing libuv a completion callback

How the pieces relate at this moment

▸ request-relationships.txtJS cb → FSReqCallback → uv_fs_t
┌──────────────────────────────────────────────────────────┐
│ 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:

  1. The JS arrow function cb (your code).
  2. The C++ oncomplete method on FSReqCallback (Node-internal).
  3. The uv_fs_t.cb field 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:

C
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

▸ submit-to-libuv.txtmain thread → work queue → worker
[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

▸ work-queue.txtqueue + 4 workers
┌────────────────────────────────────────────────────┐
│ 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

▸ critical-diagram.txt canonical shape
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
!
Lock this in
The blocking filesystem syscalls really do block — they block the worker thread, not the main JS thread. The main JS thread is free the entire time, running other JS, handling other requests, processing other timers.

§ 22 · Step 6 — "Main Thread Continues" in DetailWhat runs while the worker works

After fs.readFile() returns, your JS keeps running. Concretely:

JavaScript
fs.readFile("file.txt", cb);
console.log("after readFile");

Almost always prints:

stdout
after readFile
<file contents later>

The main JS thread, on a timeline

▸ main-thread-timeline.txtlinear, then event loop
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

▸ stack-snapshots.txtthree points in 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.

▸ worker-thread.txtanatomy
┌────────────────────────────────────┐
│ 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):

▸ worker-execution.txtopen · stat · read · close · signal
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.

▸ fd-table.txtprocess · kernel · inode
┌─────────────────────────────────────────────────┐
│ 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                     │
└─────────────────────────────────────────────────┘
!
Lock this in
A file descriptor (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:

C
read(fd, buf, size);

Meaning: "Kernel, copy bytes from the open file at fd into this user-space buffer."

▸ read-syscall-flow.txtuser space → kernel → device
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 continues

Either side of the boundary:

▸ page-cache.txtkernel ram · storage
┌─────────────────────────────────────────────────┐
│ 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     │
└──────────────────────┘
!
Lock this in
Even if 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:

▸ scheduler-states.txtRUNNING · BLOCKED · RUNNABLE
┌──────────────┐
│  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:

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 Back on CPU, continues after read returns.

The CPU isn't idle — it runs whatever else is runnable:

▸ run-queue-snapshots.txtbefore · during · after block
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]
!
Lock this in
The OS scheduler decides which runnable thread gets CPU time. Blocking a worker simply removes it from contention until its data arrives.

§ 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; result field = number of bytes
  • failure: result is 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:

▸ worker-handoff.txtworker thread ⇄ main thread
┌──────────────────────────────┐         ┌──────────────────────────────┐
│ 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       │
└──────────────────────────────┘         └──────────────────────────────┘
!
Lock this in
Worker threads do native work. They never run your JavaScript callback. They only deposit results where the main JS thread can find them.

§ 28 · Step 12 — Completion sits in a queue

The completed uv_fs_t lives in libuv's "pending callbacks" / completed-work queue:

▸ completed-work-queue.txtmain thread · 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.

JavaScript
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.

▸ interrupt-timeline.txtmain thread vs worker vs cb queue
Time ──────────────────────────────────────────────────────────►

Main JS thread:
[fs.readFile] [BIG LOOP ..............................] [done] [cb]

Worker thread:
              [open/read/close] [completion queued]

Callback queue:
                                  [cb waiting...........] [runs]
!
Lock this in
Async completion means "eligible to run later." It never preempts currently running JavaScript. The event loop only gets control when the JS call stack empties.

§ 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:

▸ event-loop-phases.txtone tick · 6 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.

1
timers
run due setTimeout / setInterval
2
pending callbacks
some I/O callbacks deferred from previous loop
3
idle / prepare
internal libuv bookkeeping
4
poll
epoll_wait for new I/O — fs.readFile completes here
5
check
run setImmediate callbacks
6
close callbacks
run 'close' events

For fs.readFile, the completion is processed when libuv runs queued I/O callbacks (during poll/pending phases, depending on platform and timing).

▸ event-loop-arrow.txtJS empty → cb runs
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-to-js.txttranslation table
[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-to-js-box.txtnative ⇄ V8
┌──────────────────────────────────────┐
│ 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.

!
Lock this in
The callback receives JavaScript values, but those values are constructed from native memory at the JS↔C++ boundary by Node's binding layer.

§ 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:

▸ v8-stack.txtcall-stack snapshots
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
!
Lock this in
Your callback is physically executed by the main JS thread. The libuv worker thread that did the actual disk read never touches V8 or your JavaScript.

§ 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.

▸ ownership-table.txtstep → thread/owner
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 thread

Interactive · 24-step Trace Stepper

Step through the entire fs.readFile lifecycle. Watch which thread owns each step.

step 0 / 21
Main JS Thread
Libuv Worker Thread

Boxed summary:

▸ ownership-boxed.txtprocess · kernel · hardware
┌─────────────────────────────────────────────────────────────────────────┐
│ 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

✗ Wrong
"fs.readFile is non-blocking, so the disk read doesn't block anything."
The disk read absolutely blocks something — it blocks a libuv worker thread in 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."
✗ Wrong
"The callback fires as soon as the file is read."
It fires as soon as (a) the file is read and (b) the JS call stack is empty and (c) the event loop reaches the relevant phase. If you have a giant synchronous loop running, the callback waits.
✗ Wrong
"JavaScript is single-threaded means Node has one thread."
Node has many threads. JavaScript runs on one of them — the main JS thread. Worker threads, V8 GC helpers, and libuv pool threads all coexist in the same process.
✗ Wrong
"libuv runs my callback."
libuv runs the C++ completion routine (AfterRead). That routine, on the main JS thread, eventually asks V8 to invoke your JS callback.
✗ Wrong
"If I await fs.promises.readFile, it's somehow different at the OS level."
It is not. 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

▸ correct-mental.txt5 layers
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):

▸ io-bound-timeline.txtdelegated · main free
Time ───────────────────────────────────────────────►

Main JS thread:
[fs.readFile] [delegate] [other JS] [callback]

Worker thread:
              [open/read/close ......]

CPU-bound (libuv does not help):

JavaScript
fs.readFile("file.txt", cb);
for (let i = 0; i < 5_000_000_000; i++) {}
▸ cpu-bound-timeline.txtmain thread starved
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).

!
Lock this in
libuv's worker pool runs native blocking work on behalf of Node-internal APIs. It is not where your JavaScript runs. To move JavaScript off the main thread, you need real Worker Threads.

§ 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.
▸ cashier-analogy.txtcustomer · cashier · warehouse
┌──────────────┐     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.

!
Lock this in
libuv is the delegation system that lets the JavaScript cashier keep serving while slow OS work happens elsewhere.

§ 37 · Final Compressed Map of fs.readFile

▸ final-map.txtJS → kernel → hardware → JS
[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

!
Lock this in
The thread we care about is the main JS thread — the OS thread inside the Node process where V8 runs your call stack.
!
Lock this in
What 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.
!
Lock this in
What "blocking" means: the OS removes a thread from the run queue until the operation can continue. For fs.readFile, the thread that gets blocked is a worker thread, not the main JS thread.
!
Lock this in
Why the callback runs later: it can only run when (a) the file work is done, (b) completion is queued, (c) the main JS call stack is empty, and (d) the event loop reaches the relevant phase.
The full invariant
  1. JavaScript initiates async work.
  2. Node's C++ bindings + libuv perform or monitor the slow work.
  3. The OS kernel manages the real resources and may block worker threads.
  4. Completions are queued for the main thread.
  5. 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.

End of Mission 5
Next: Part 8 — The Event Loop in Detail
Back to JIT-Atlas