Search lands in PR-5.1 (Pagefind).

Explanation Intermediate

Chapter 6 Updated

libuv & Async I/O

V8 is synchronous. Node feels concurrent. This is how libuv and the event loop bridge the gap.

  • Full 22m
  • Revision 5m
  • Flow 2m

The Foundation — JavaScript Is Synchronous & Single-Threaded

Before we can understand how Node.js handles async operations, we need to be crystal clear about what JavaScript actually is at its core.

Three properties of JavaScript matter here:

  • Single-threaded. JavaScript runs on one thread with a single call stack. At any given moment, only one piece of code is being processed. There is no parallelism inside the JS engine itself.
  • Synchronous. Code executes line by line, in order. Line 2 only runs after Line 1 finishes completely. If a function takes time, it blocks everything after it.
  • V8 engine. The V8 engine (used in Chrome and Node.js) runs on this single thread, parsing and executing JavaScript sequentially. It manages the call stack, memory heap, and garbage collection.

In languages like C++ or Java, code can run across multiple threads simultaneously — one part on Thread 1, another on Thread 2. JavaScript doesn’t do this. It executes one line, then the next, then the next. If you’re executing line 2, it will only run after line 1 has completely finished. That is the essence of synchronous execution.

Synchronous vs Asynchronous — The Ice Cream Shop

AxisSynchronousAsynchronous
WorkersOne hand, N tasksN hands, N tasks
OrderingStrictly sequentialIndependent, parallel
Total timeSum of all tasksLongest single task
BehaviourLater tasks waitNo task waits for another

Here are two minimal code examples that show the split:

synchronous-example.js
let a = 10786;
let b = 20987;
 
function multiplyFn(x, y) {
  const result = x * y;
  return result;
}
 
let c = multiplyFn(a, b);
console.log(c); // 226215682
asynchronous-example.js
// 1. API Call — network request, takes time
https.get("https://api.fbi.com", (res) => {
  console.log("secret data:", res.secret);
});
 
// 2. File Read — disk I/O, takes time
fs.readFile("./gossip.txt", "utf8", (data) => {
  console.log("File Data", data);
});
 
// 3. Timer — waits 5 seconds
setTimeout(() => {
  console.log("wait here for 5 seconds");
}, 5000);

The synchronous code executes immediately — plain math, variable assignment, function calls. The asynchronous code involves waiting: for a network response, for a file to be read from disk, for a timer to expire. These are the operations that Node.js offloads to libuv.

Inside V8 — Call Stack, Memory Heap & Garbage Collector

The V8 engine has three key components that work together to run your JavaScript:

  • Call stack (LIFO). A stack data structure where function calls are pushed and popped. The currently executing function sits on top. Only one function runs at a time — this is why JS is single-threaded.
  • Memory heap. An unstructured pool of memory where variables, objects, closures, and function definitions are stored. This is where your data lives while the program runs.
  • Garbage collector. Automatically identifies variables and objects that are no longer referenced and frees their memory. Unlike C++, you never manage memory manually in JavaScript.
V8 JS engine (single thread)
┌─────────────────────────────────────────────────────────┐
│                   V8 JS ENGINE (Single Thread)           │
│                                                          │
│  ┌──────────────┐   ┌──────────────────┐   ┌──────────┐ │
│  │  Call Stack  │   │   Memory Heap    │   │ Garbage  │ │
│  │   (LIFO)     │   │ (objects, vars,  │   │Collector │ │
│  │              │   │  closures, fns)  │   │          │ │
│  │ multiplyFn() │   │                  │   │ Finds &  │ │
│  │ ──────────── │   │  a = 10786       │   │ frees    │ │
│  │   GEC        │   │  b = 20987       │   │ unused   │ │
│  │              │   │  multiplyFn=fn{} │   │ memory   │ │
│  └──────────────┘   └──────────────────┘   └──────────┘ │
│        ↑                                                 │
│   Executes code line by line                             │
└─────────────────────────────────────────────────────────┘

Key insight: when you write synchronous code, it all happens step by step in the call stack, using memory from the heap, and the garbage collector quietly tidies up behind the scenes. No interruptions, no parallel tasks — just one thing at a time.

How Synchronous Code Executes — Step by Step

Let’s trace exactly how V8 executes this synchronous code through the call stack:

The code we're tracing
let a = 10786;         // Line 1
let b = 20987;         // Line 2
 
function multiplyFn(x, y) {  // Line 4
  const result = x * y;       // Line 5
  return result;              // Line 6
}                              // Line 7
 
let c = multiplyFn(a, b);  // Line 9
console.log(c);            // Line 10 → outputs 226215682
  1. Global Execution Context (GEC) created. As soon as V8 begins, it creates the GEC — the main environment for top-level code. This is always the first thing pushed onto the call stack. The stack now has: [GEC].
  2. Memory creation phase (hoisting). Before any code runs, V8 scans the entire file. Variables a and b are allocated in memory and set to undefined. The function multiplyFn is stored in memory with its entire function definition — this is why function declarations can be called before they appear in code.
  3. Code execution phase begins. let a = 10786 executes: a is now assigned 10786. Then let b = 20987 executes: b gets 20987. V8 processes these line by line, top to bottom. The function definition on line 4 is skipped (already stored in memory).
  4. Function invocation — new execution context. When multiplyFn(a, b) is called on line 9, V8 creates a new Function Execution Context (FEC) and pushes it onto the top of the call stack. Parameters x and y receive the values of a (10786) and b (20987). Call stack: [FEC(multiplyFn), GEC].
  5. Execute inside multiplyFn. Inside the function, result is initialized as undefined (memory phase), then assigned 10786 × 20987 = 226215682 (execution phase). Then return result is hit — the function returns 226215682 and its execution context is popped off the call stack. The garbage collector may now clean up memory used by this context.
  6. Resume GEC — assign return value. Back in the GEC, the returned value 226215682 is assigned to variable c. Then console.log(c) prints the result to the console. Call stack: [GEC].
  7. GEC popped — call stack empty. All code has been executed. The GEC is removed from the call stack. The stack is now completely empty, and the JS engine is idle. Program ends.

The Superhero — libuv & How Async Code Executes

Here’s the million-dollar question: if V8 is single-threaded and synchronous, how does Node.js handle things like API calls, file reads, and timers without blocking everything?

The answer: V8 doesn’t do it alone. The JavaScript engine has no concept of time or waiting. It needs superpowers. This is where Node.js comes in, and it grants those powers through a C library called libuv.

V8 ↔ libuv ↔ OS
┌──────────────────┐          ┌──────────┐          ┌──────────────┐
│    V8 ENGINE     │          │  LIBUV   │          │      OS      │
│                  │  async   │          │  talks   │              │
│  Call Stack      │ ──────→  │ Event    │ ──────→  │  Files       │
│  Memory Heap     │  offload │ Loop     │   to     │  Network     │
│  Garbage         │          │ Thread   │          │  Database    │
│  Collector       │ ←──────  │ Pool     │ ←──────  │  Timers      │
│                  │ callback │          │ results  │              │
└──────────────────┘          └──────────┘          └──────────────┘
 
       JS code runs here      Manages async          System-level
       synchronously          operations             resources

Complete Async Walkthrough — The Big Picture

Let’s trace through a mixed sync + async program to see how V8 and libuv work together:

mixed-code.js — sync + async together
let a = 10786;
let b = 20987;
 
// Async operation 1: API call (callback A)
https.get("https://api.fbi.com", (res) => {       // ← Callback A
  console.log(res.secret);
});
 
// Async operation 2: Timer (callback B)
setTimeout(() => {                                // ← Callback B
  console.log("setTimeout");
}, 5000);
 
// Async operation 3: File read (callback C)
fs.readFile("./gossip.txt", "utf8", (data) => {  // ← Callback C
  console.log("File Data", data);
});
 
// Sync: function definition + call
function multiplyFn(x, y) {
  const result = x * y;
  return result;
}
 
let c = multiplyFn(a, b);
console.log(c);  // 226215682

Now line by line — here’s where each one goes:

  1. GEC created — a and b assigned. The two let lines execute synchronously in the call stack within the GEC. Simple variable assignments — V8 handles them directly.
  2. https.get() hit — async, offloaded to libuv. V8 encounters the API call and recognizes it as asynchronous. It signals libuv to handle it. libuv registers the API call with its associated callback A in its event loop. V8 does not wait — it immediately moves on.
  3. setTimeout() hit — async, offloaded to libuv. V8 sees another async operation. It hands the timer off to libuv with callback B. libuv starts tracking the 5-second timer. V8 keeps going.
  4. fs.readFile() hit — async, offloaded to libuv. Yet another async operation. V8 offloads the file read to libuv with callback C. libuv uses its thread pool to perform the file system operation in the background. V8 continues without waiting.
  5. multiplyFn(a, b) — sync, runs on call stack. V8 encounters the function call. A new Function Execution Context is created and pushed onto the call stack. x gets 10786, y gets 20987. result = 10786 * 20987 = 226215682. The function returns, its context is popped, and c receives the value.
  6. console.log(c) — prints 226215682. This is the last synchronous line. The GEC is popped off the call stack. The call stack is empty. V8 is idle.
  7. libuv takes over — heavy lifting in the background. While V8 relaxes, libuv is busy: processing the 5-second timer, waiting for the network response from the API, and reading gossip.txt from disk. It handles all these operations by communicating with the OS.
  8. Async operations complete — callbacks return via event loop. As each async operation finishes, libuv pushes the corresponding callback onto the callback queue. The event loop checks: “Is the call stack empty?” If yes, it moves the callback from the queue onto the call stack, and V8 executes it.
  9. Callbacks execute one by one. Callback C (file read) might finish first — it runs. Then Callback A (API response) comes back — it runs. Finally Callback B (timer) fires after 5 seconds — it runs. Each callback is executed one at a time on the single-threaded call stack, but the waiting happened in parallel via libuv.

The Event Loop & Callback Queue

One final layer connects the two halves: the event loop and its task queues.

Main thread ↔ event loop ↔ task queues
┌─────────────────────────────────────────────────────────┐
│                 Main Thread (V8)                          │
│  ┌──────────────┐       ┌──────────────────────┐         │
│  │  Call Stack  │       │    Memory Heap       │         │
│  │   (LIFO)     │       │  (objects, closures) │         │
│  └──────────────┘       └──────────────────────┘         │
│         ↑                                                 │
│    Executes code line by line                             │
└─────────────────────────────────────────────────────────┘

          │ Async operations handed off to Web APIs / Node APIs

┌─────────────────────────────────────────────────────────┐
│            Event Loop & Task Queues                       │
│                                                          │
│   ┌──────────────────┐    ┌──────────────────┐          │
│   │ Macrotask Queue  │    │ Microtask Queue  │          │
│   │  (setTimeout,    │    │ (Promises,       │          │
│   │   setInterval,   │    │  queueMicrotask, │          │
│   │   I/O callbacks) │    │  MutationObserver)│         │
│   └──────────────────┘    └──────────────────┘          │
│                                                          │
│   Event loop checks: "Is call stack empty?"              │
│   If YES → move next callback to call stack              │
│   Microtasks always run before the next macrotask        │
└─────────────────────────────────────────────────────────┘

The event loop follows a simple but powerful rule: it continuously checks whether the call stack is empty. If it is, it picks the next callback from the queue and pushes it onto the call stack for V8 to execute. Microtasks (like Promise.then callbacks) always have priority over macrotasks (like setTimeout callbacks).

Wherever V8 encounters an asynchronous operation, it offloads it to libuv. libuv does the further processing, and once the operation is complete, it returns the result to V8 through the event loop. V8 then quickly executes the callback. Examples of async operations: API calls, setTimeout, file read/write, database queries, DNS lookups.

Episode 06 — At a Glance

ConceptKey detail
ThreadSmallest unit of execution in a process; JS uses a single thread.
SynchronousCode runs line by line; each operation blocks the next until complete.
AsynchronousTasks run independently; no waiting for others to finish.
JS natureSynchronous + single-threaded — but Node.js adds async powers.
Call stackLIFO stack where function calls are pushed/popped; one at a time.
Memory heapStores variables, objects, functions — unstructured memory pool.
Garbage collectorAuto-frees unused memory; no manual deallocation needed (unlike C++).
GECGlobal Execution Context — first thing pushed on the call stack.
Execution phasesMemory creation (vars = undefined, fns stored) → code execution (assign + run).
libuvC library giving Node.js async powers: event loop, thread pool, OS communication.
Async flowV8 offloads → libuv handles in background → callbacks return via event loop.
Non-blocking I/OMain thread never waits; async work happens in libuv while V8 continues.
Event loopContinuously checks if call stack is empty, then moves callbacks from queue.
Microtasks vs macrotasksPromises (microtask) always execute before setTimeout (macrotask) callbacks.

Comments

Comments are disabled in this environment. Set PUBLIC_GISCUS_REPO, PUBLIC_GISCUS_REPO_ID, and PUBLIC_GISCUS_CATEGORY_ID to enable.