Runtime Reference · The JavaScript Engine Pipeline · v2

From first principles: how a .js file in your editor becomes bytes the CPU executes.

A JavaScript program begins as human-readable text, but the CPU cannot execute text. Unlike C, JavaScript has no separate compile step — the entire engine exists to transform console.log("Hello"); into bytes the CPU understands, at the moment the program runs, in multiple representations, getting faster each time. C is translated once on the developer's machine. JavaScript is translated continuously, on the user's machine, and adapts as it runs. This guide follows that journey through V8 — Chrome, Node.js, Edge — with real bytecode and real machine code captured live.

21 sections · verbatim 7 pipeline stages real V8 bytecode real TurboFan output
click a stage → details panel ▶ play → sequential lighting + narration click any code/section → jump to detail
Engine V8 · Node 22 · Lineage ECMAScript · 1995 · Runs in Chrome · Node · Deno · Bun · Authored at Google · Munich
JS engine pipeline — text → bytecode → JIT → execution
stage 0 / 7
→
→
→
→
→
→
Glossary
term
Section 00 · Overview
The central truth, the IDE, and the bytes on disk.
v2 adds: real V8 bytecode bytes, real V8 machine code, internal data structure layouts, V8 startup, the threaded interpreter, GC write barriers and remembered sets, JS calling conventions, and a full three-file ESM example. All V8 output here was captured from a real Node.js 22 process — not invented.

SECTION 00The central truth

A JavaScript program begins as human-readable text, but the CPU cannot execute text. The CPU only executes machine instructions, which are bytes with specific meanings.

Unlike C, JavaScript has no separate compile step you run by hand. There is no .exe produced ahead of time, no linker you invoke, no object file sitting on disk. The entire JavaScript engine exists to transform this:

JavaScripthuman-readable text
console.log("Hello");

into bytes the CPU understands, at the moment the program runs, in multiple representations, getting faster each time.

That is the central difference from C:

  • C: translation happens once, ahead of time, on the developer's machine.
  • JavaScript: translation happens at runtime, on the user's machine, repeatedly, adaptively.

SECTION 01The IDE is not the engine

When you write JavaScript in VS Code, WebStorm, or Cursor, the IDE is mostly a convenience layer. It edits text, highlights syntax, and runs language servers. It does not execute your JavaScript.

That goes to a host environment which embeds a JavaScript engine:

IDE → HOST → ENGINE → CPU · the handoff chain who does what
LAYER 1 · IDE VS Code · WebStorm · Cursor · Vim edits text · highlights syntax · runs language servers does NOT execute your JS runs `node main.js` · opens index.html LAYER 2 · HOST ENVIRONMENT Node.js · Chrome · Deno · Bun · Electron provides APIs · drives the event loop DOM · fs · fetch · setTimeout · process · workers hands source bytes to the engine · invokes JS callbacks LAYER 3 · JAVASCRIPT ENGINE V8 · SpiderMonkey · JavaScriptCore parses · compiles · runs · manages memory parser Ignition profiler Sparkplug · Maglev · TurboFan GC emits machine code into code space, jumps to it LAYER 4 · CPU x86-64 · ARM64 · … executes the machine code the engine produced · nanoseconds per instruction each layer is a strict handoff · no layer skips the next
!
Lock this in
The IDE edits. The host provides APIs (DOM, fs, fetch) and drives the event loop. The engine parses, compiles, runs, and manages memory. The CPU executes.

This document focuses on V8 (Chrome, Node.js, Edge, Deno) — the most documented engine. SpiderMonkey (Firefox) and JavaScriptCore (Safari, Bun) have similar architectures with different names for the parts.

SECTION 02Your .js file is just text bytes

main.jsUTF-8
function add(a, b) {
  return a + b;
}

At the file system level, main.js is just bytes stored on disk, encoded in UTF-8. For example, the first characters:

Example · the first three bytes of main.js

POSITION
CHARACTER
STORED AS BYTE
UTF-8 MEANING
0
f
→
0x66
letter f
1
u
→
0x75
letter u
2
n
→
0x6E
letter n

So your source file is not "code" to the engine yet. It is just text data.

MAIN.JS · ON DISK raw byte view of the first 15 bytes
66 75 6E 63 74 69 6F 6E 20 61 64 64 28 61 …
decodes to function add(a…
These bytes mean text characters, not CPU instructions, and not even bytecode yet.

The engine reads these text bytes and interprets them according to the ECMAScript grammar.

SECTION 03The full pipeline

END-TO-END TRANSFORMATION · runtime, on every machine source bytes → CPU instructions
PHASE SOURCE 01 · ON DISK main.js — UTF-8 text bytes HOST 02 · HOST LOADS SOURCE read() bytes — hand off to engine STARTUP 03 · ENGINE COMES ONLINE Isolate · Context · snapshot deserialized · builtins ready PARSE 04 · SCANNER · LEXER text bytes → tokens 05 · PARSER · WITH LAZY PREPARSE tokens → AST BYTECODE 06 · IGNITION · GENERATE + INTERPRET AST → BytecodeArray + FeedbackVector — runs every function at least once PROFILE 07 · TYPE FEEDBACK COLLECTED while interpreter runs · ICs warm up JIT 08 · SPARKPLUG — when warm bytecode → unoptimized machine code · 1:1 09 · MAGLEV — when hot bytecode + feedback → mid-tier machine code 10 · TURBOFAN — when very hot fully optimized machine code · speculative EXECUTE 11 · CPU EXECUTES MACHINE CODE tag-checked, untagged, added, re-tagged · nanoseconds DEOPT when an assumption breaks
JS
V8 · Walkthrough
Step through the same function from text to CPU
Stage
01/06
TEXT BYTES
Your .js file on disk
A .js file is just text bytes encoded in UTF-8. The character "f" is byte 0x66, "u" is 0x75, "n" is 0x6E. Nothing about this is "code" to the CPU yet — it is data the engine is about to read.
main.js · UTF-8
function add(a, b) { return a + b; }
f
66
u
75
n
6E
c
63
t
74
i
69
o
6F
n
6E
·
20
a
61
d
64
d
64
(
28
a
61
,
2C
·
20
b
62
)
29
function add(a, b) { return a + b; }
keyword
function
ident
add
punct
(
ident
a
punct
,
ident
b
punct
)
punct
{
keyword
return
ident
a
op
+
ident
b
punct
;
punct
}
14 tokens. Whitespace and comments dropped. Position info kept for stack traces.
[function] [add] [(] [a] [,] [b] [)] [{] [return] [a] [+] [b] [;] [}]
FunctionDeclaration params body Id "a" Id "b" ReturnStatement BinaryExpression "+" Id "a" Id "b"
FunctionDeclaration("add", params=[a,b], body=Return(BinaryExpression("+", a, b)))
0b
Ldar
load reg
04
a1
param 1 (b)
3b
Add
acc, reg
03
a0
param 0 (a)
00
fb#0
feedback slot
ae
Return
return acc
Ldar a1 ; load b → accumulator
Add a0, [0] ; acc += a, record feedback
Return ; return accumulator
capture this yourself: node --print-bytecode -e "function add(a,b){return a+b}; add(1,2)"
0b 04 3b 03 00 ae · bytecode · 6 bytes
; r8 holds a, rcx holds b · both Smi-tagged
test dl, 0x1
jnz <deopt: not a Smi>
test cl, 0x1
jnz <deopt: not a Smi>
sar rcx, 32 ; untag b
addl r8d, ecx ; the actual add
jo <deopt: overflow>
shl r8, 32 ; re-tag result
ret
Tag check
One instruction. If low bit ≠ 0, jump to deopt.
Deopt sites
"not a Smi" × 2, "overflow" × 1. Reasons V8 will rebuild the interpreter frame.
addl r8d, ecx · the optimized machine code, ready to run
rcx · arg b
0x4
= Smi(2)
r8 · arg a
0x2
= Smi(1)
r8d after add
3
re-tagged, returned
What just happened
Two tag checks (~1ns each). One untag shift. One 32-bit add. One overflow check. One re-tag shift. One return. Six instructions for what looked like a high-level dynamic operation. JavaScript ran at the speed of C — because V8 watched, profiled, and bet correctly.
← prev · → next · esc reset

There is no separate "linking" stage. JavaScript modules are wired up by the engine at runtime, and external functions (like console.log) are properties of objects looked up dynamically.

!
Lock this in
C resolves symbols statically, before the program runs. JavaScript resolves names dynamically, while it runs.
Section 01 · Startup
Stage -1: V8 starts up.
Before V8 sees a single byte of your source, it has to bring itself online. This step has no analogue in the C document — it's the reason Node startup feels different from a compiled binary.

SECTION 04Stage -1: V8 starts up

4.1 Isolate

An Isolate is a single instance of V8: one heap, one GC, one set of compiled code, one execution thread.

V8 ISOLATE · nested containers one per Node main thread / Worker
ISOLATE one per main thread / Worker · cannot share JS objects with other Isolates HEAP · all JS objects live here YOUNG GEN scavenger · semi-spaces · new objects bump-allocated, copying GC OLD GEN long-lived objects · mark-compact tri-color · incremental + concurrent CODE SPACE · R-X JIT machine code · Sparkplug / Maglev / TurboFan READ-ONLY SPACE · RO builtins, snapshot, immortal singletons BYTECODE SPACE BytecodeArrays from Ignition GC STATE marking bitmap · remembered set · workers COMPILATION PIPELINE Parser · Ignition · Sparkplug · Maglev · TurboFan BUILTIN CODE · shared with all Contexts Array.prototype.push, Promise, JSON.parse, … CONTEXTS · 1 or many each has its own globalThis CONTEXT A globalThis_A · own Object/Array/… main module + globals CONTEXT B globalThis_B · separate constructors e.g. vm.createContext() · iframe … more if vm or iframes …

In Node.js, the main thread has one Isolate. Each worker_threads.Worker gets its own. They cannot share JS objects directly — only structured-cloned messages.

In a browser, each renderer process has one Isolate per "process group," shared across same-origin frames.

4.2 Context

A Context is an execution environment with its own global object. Inside one Isolate you can have many Contexts. Each browser iframe is a Context. Node's vm.createContext() creates one. When code in Context A calls into Context B, GC and the heap are shared, but the global objects (window, globalThis) are different.

4.3 The snapshot

Here's where it gets interesting.

V8's built-in objects — Array.prototype.push, Object, Math.sin, JSON.parse, Promise, all of them — are themselves heap objects with hidden classes and machine code. Building all of that from scratch on every startup would take ~100ms.

So V8 builds a snapshot at compile time: a binary blob representing the initial heap state with all the builtins already constructed.

SNAPSHOT LIFECYCLE · click any node build time → runtime
BUILD TIME · once when Node is built STEP 1 Run V8 in init mode create the JS world STEP 2 Compile builtins Object · Array · Promise… STEP 3 Serialize heap → snapshot_blob.bin STEP 4 Embed in node binary RUNTIME · every time you run `node` STEP A mmap snapshot blob virtual memory mapped, no copy STEP B Fix up pointers relocation READY Fully-formed JS world ~50ms instead of ~500ms Builtin code (Array.prototype.push, Promise, etc.) is pre-compiled C++/Torque, sitting in read-only memory. Most "JavaScript" you call is mmapped from a snapshot — only your code goes through Ignition.

This is why node -e "console.log(1)" starts in ~50ms instead of ~500ms. Most of the work was done at V8 build time.

4.4 Builtins

Array.prototype.push is not interpreted JavaScript. It's a C++ function compiled to machine code, written in V8's internal DSL called Torque (which compiles to a thing called CodeStubAssembler, which lowers to machine code).

When your code does arr.push(x), the engine:

  1. Looks up push on arr.__proto__ (Array.prototype)
  2. Finds a function whose code pointer points into V8's pre-compiled builtin code
  3. Calls it directly — same call instruction it would use for any function

Builtins live in a special read-only memory region inside the Isolate. They're shared across all Contexts in that Isolate.

!
Lock this in
Most "JavaScript" you call is actually pre-compiled C++/Torque, mmapped from a snapshot. Only your code goes through Ignition + JIT.
Deep Dive · Snapshot Internals
How the snapshot lifecycle actually works
The diagram in §4.3 is the map. These seven sub-sections are the territory — what each step physically does, the data structures involved, and why the design works. Each can be jumped to directly from the modal that opens on the diagram.

What "special bootstrapping mode" actually is

When V8 is built (which happens when Node, Chrome, or any embedder is built), the build system produces two binaries: regular V8, and a one-shot tool called mksnapshot. mksnapshot is V8 with a single trick: it's started with the flag --no-snapshot, which tells V8 it must construct everything from primitives instead of loading a pre-built world.

Inside mksnapshot, V8 calls a function in its source called Genesis. Genesis is the only place in the codebase that builds the JavaScript world from scratch. Every other binary skips this entirely.

MKSNAPSHOT · what it does runs once, at V8 build time
MKSNAPSHOT flag: --no-snapshot 1. new Isolate 2. new Context 3. Genesis() 4. compile builtins 5. serialize heap → snapshot_blob.bin INSIDE Genesis() · bootstrap order FIRST Object.prototype REQUIRES Object Function.prototype Array.prototype String.prototype Promise.prototype Map · Set … ~30 more constructors and prototypes … Native Context fully populated each with its Map, methods, descriptor array, prototype chain OUTPUT snapshot_blob.bin ~1-3 MB binary version-specific

What "the initial JavaScript world" contains

The bootstrap creates everything that exists on globalThis before your code runs. Specifically:

A
The Roots — V8 maintains a fixed-size table of pointers to "well-known" objects: true, false, null, undefined, the empty string, the empty FixedArray, every prototype Map. Roots are how the GC and serializer find everything else.
B
The Native Context — Per-Realm storage for Object, Array, Function, Promise, Map, Set, Error and ~30 more constructors. When you write new Array(), V8 looks the constructor up here.
C
Prototype objects — Object.prototype, Array.prototype, Function.prototype, etc. Each with all its method properties already attached.
D
Hidden classes (Maps in V8 terms) — One per shape. The Map for Object.prototype, the Map for an empty plain object, the Map for an empty Array, and so on.
E
Compiled code — Every builtin's machine code (covered in Step 2).

Roughly 20,000–30,000 heap objects in total, all wired together with the right prototype chains and the right Maps.

Why "the slow way" is slow

Imagine doing all of this on every Node startup. The bootstrap is a sequential dance with hard ordering constraints — you can't shortcut it because each step depends on the previous one being already on the heap.

Genesis · simplified pseudocode
// 1. Allocate Object.prototype's Map (the empty Map)
empty_map = Map.create(instance_type=JS_OBJECT, size=0)

// 2. Allocate Object.prototype itself, using that Map
obj_proto = JSObject.alloc(empty_map)

// 3. Add hasOwnProperty, toString, valueOf, isPrototypeOf...
// each addition triggers a Map TRANSITION (new Map)
add_property(obj_proto, "hasOwnProperty", has_own_proto_fn)
add_property(obj_proto, "toString", to_string_fn)
add_property(obj_proto, "valueOf", value_of_fn)
// ... ~10 more properties, ~10 more Map transitions

// 4. NOW we can build Function.prototype, since its [[Prototype]]
// must be Object.prototype which now exists.
fn_proto = JSFunction.alloc(...)
fn_proto.__proto__ = obj_proto

// 5. Now Array.prototype (also requires Object.prototype)
arr_proto = JSArray.prototype.alloc(...)
arr_proto.__proto__ = obj_proto

// ... ~30 more constructors, all with strict ordering

Every add_property call allocates from the heap, computes a string hash, finds or creates a Map transition, and builds a new descriptor entry. Multiply by hundreds of properties across thousands of objects and you get hundreds of milliseconds of work — pure CPU, no I/O, on every startup.

The snapshot is V8's solution: do this once, freeze the result, never repeat it.

!
Lock this in
mksnapshot is the only V8 binary that ever does cold bootstrap. Every other run — including every node invocation — inherits its frozen output. When you start Node, you're not building a JavaScript world. You're loading one.

What Torque is

Torque is a domain-specific language V8 introduced in 2018 to write builtins safely. It looks like TypeScript with V8-specific types: Smi, HeapObject, JSArray, String, JSAny, etc. The compiler enforces these types, so you can't accidentally pass a Smi where a HeapObject is expected, and you can't forget a write barrier.

Before Torque, builtins were written in CodeStubAssembler (CSA) — a C++ API for emitting machine-code stubs directly. CSA was bug-prone (tag mismatches, missing barriers); Torque compiles down to CSA and adds the type checking automatically. Most builtins migrated to Torque between 2018 and 2022.

The compilation pipeline · step by step

TORQUE COMPILATION · pipeline runs once during V8 build
SOURCE → BYTES IN BINARY · 4 stages STAGE 1 · SOURCE array.tq Torque source ~typed ~hand-written ~~100 lines/file STAGE 2 · TORQUE torque compiler type-check + lower → generated C++ that calls CSA ~adds barriers STAGE 3 · BACKEND CSA + TurboFan build SSA graph ~optimize ~register-allocate ~emit asm STAGE 4 · OUTPUT machine code x86-64 / ARM64 ~lives in code space ~callable directly ~zero JS overhead → thousands of builtins, all compiled, all sitting in the heap, ready for serialization this whole process happens inside mksnapshot, then gets captured by Step 3

A real Torque builtin

This is roughly what Array.prototype.indexOf looks like in Torque (simplified):

torque · array.tq
// Real V8 source, slightly trimmed
transitioning javascript builtin
ArrayPrototypeIndexOf(
  js-implicit context: NativeContext, receiver: JSAny)
  (...arguments): JSAny {
  const length: Number = GetLengthProperty(receiver);

  if (length == 0) return -1;

  const searchElement: JSAny =
    arguments[0];

  try {
    // Fast path: dense Smi/Object array
    const array: FastJSArray =
      Cast<FastJSArray>(receiver) otherwise Slow;
    return FastIndexOf(array, length, fromIndex, searchElement);
  } label Slow {
    return GenericIndexOf(receiver, length, fromIndex, searchElement);
  }
}

Notice the typed casts (Cast<FastJSArray>), the otherwise labels for fall-through paths, and the implicit NativeContext parameter. The Torque compiler enforces all of this at compile time.

Why a DSL instead of plain C++?

1
Type safety V8 cares about. Plain C++ types don't distinguish Smi from HeapObject pointer. Torque does. It's impossible to accidentally SmiUntag a HeapObject in Torque source.
2
Automatic write barriers. When you store a pointer into a heap object, V8's incremental GC needs notification. Torque inserts these calls automatically; in CSA you had to remember manually.
3
Better optimization. Because Torque knows types statically, the backend can specialize calling conventions. Smi adds compile to a single add; HeapObject loads compile with the correct tag-clearing.
4
Cross-platform asm output. One .tq file produces x86-64, ARM64, and other architectures' machine code automatically through the TurboFan backend.

By the end of Step 2, every builtin function is sitting in the heap as a Code object — a piece of machine code wrapped in a V8 heap header. Calling Array.prototype.push.call(arr, 1) is a direct jump to that code, no interpretation in between.

!
Lock this in
When you call arr.push(x), you are calling pre-compiled machine code that was produced from typed Torque source during V8's own build. Most of "JavaScript" is not interpreted JavaScript — it's static native code.

What's in the heap at this point

By the end of bootstrap + builtin compilation, the heap looks roughly like this:

SERIALIZATION · live heap → flat blob walk reachable from Roots
LIVE HEAP · in mksnapshot ROOTS Object.prototype Function.prototype Code: push Map (empty) String "length" Array.prototype ~30,000 more heap objects NATIVE CONTEXT Object, Array, Promise, … SERIALIZER (BFS from Roots) walk & encode BLOB · linear bytes HEADER · version, magic, sizes v8 v12.4.0 · arch x64 · builtin_count 1452 OBJECTS · in encounter order, with type tags [#0] kRoots: ptr-array { ... } [#1] kJSObject: Map=#42, props=[#13, #14, #15] [#2] kJSFunction: code=#101, prototype=#1 [#3] kCode: instructions=[0x55, 0x48, 0x89, ...] [#4] kString: "length" hash=0x73a4... [#5] kMap: instance_type=JS_OBJECT, ... … ~30,000 more entries … POINTER TABLE · object # → blob offset #0 → 0x40 #1 → 0x60 #2 → 0xa8 #3 → 0xc4 #4 → 0x14e #5 → 0x162 references between objects use these IDs RELOCATION TABLE · runtime fixups needed offset 0x1234 (Code #3) ← needs base+ at runtime

The serialization walk · step by step

1
Start at the Roots. The Roots table is a fixed list (~250 entries) of well-known pointers V8 always knows about: true, false, undefined, every prototype Map, the empty FixedArray. Everything reachable in the JS world is reachable from the Roots.
2
Breadth-first walk. The serializer maintains a queue. For each object pulled out, it: assigns an integer ID, writes the object's type tag (e.g. kJSObject, kCode, kString, kMap), then writes its fields. Pointer fields cause referenced objects to get queued.
3
Pointers become IDs. Inside the blob, an object's pointer to another object isn't a memory address — it's the ID assigned in step 2. A separate pointer table maps each ID to its byte offset in the blob.
4
Absolute pointers go in the relocation table. Some pointers can't be relativized — for example, machine code that contains an embedded address to an external C function. Those get recorded in a relocation table along with where they appear in the blob, so they can be patched at runtime once the actual base address is known.
5
Write a header. Version string, magic bytes, target architecture, total size, builtin count. The header is how V8 confirms the blob matches the binary at load time. Mismatched version → fatal error.
6
Output the file. Everything concatenated → snapshot_blob.bin, typically 1-3 MB.

Why pointer-rewriting is the hard part

A live heap is full of memory addresses. Object A at 0x7f3a:1240 points to object B at 0x7f3a:5680. But the next time you load this heap into a process, those addresses won't be valid — every process gets a different virtual layout.

The serializer's job is to strip away the addresses and replace them with portable references (object IDs, blob offsets, plus the relocation table for pointers that can't be relativized). The result is position-independent: it can be loaded at any address.

!
Lock this in
The blob is V8-version-specific. The exact bit-layout of every heap object can change between V8 versions, so a snapshot from V8 12.4 is unreadable to V8 12.5. This is why node_modules's native bindings sometimes need recompiling — same constraint applies more loosely.

From blob bytes to executable bytes

EMBEDDING · build pipeline link blob into final node binary
INPUT snapshot_blob.bin ~1-3 MB raw bytes embed GENERATED C++ embedded.cc const uint8_t snapshot_ blob_data[] = { 0xde, 0xad, 0xbe, 0xef, 0x12, 0x34, /* 1.8 MB more */ }; compile OBJECT FILE embedded.o .rodata section link FINAL · node binary .text · executable .data · global vars .rodata [snapshot here] ~1.8 MB .bss · zero-init other sections total binary: ~80 MB snapshot is part of it there is no separate snapshot file shipped — it's inside the binary

The four sub-stages · in detail

1
Generate a C++ source file from the blob. A small tool (often called blob_to_cc or similar) reads snapshot_blob.bin and writes it as a .cc file containing one giant const uint8_t array. This is the only "magic" — it's just bytes-as-array literal.
2
Compile the C++ to an object file. The compiler (clang/gcc) turns embedded.cc into embedded.o. Because the array is const, it gets placed in the .rodata section — read-only data. The compiler doesn't care that it's a snapshot; to it, it's just a big byte array.
3
Link with the rest of Node. The linker concatenates embedded.o with all the other object files that make up Node (V8's source files, libuv, OpenSSL, etc.). The final result is a single node executable, and the .rodata section now contains the snapshot bytes alongside other read-only data.
4
Reference symbols. The C++ array name (v8_snapshot_blob_data) becomes a linker symbol — V8's runtime code references it by name. At link time, the linker resolves the symbol to point at the embedded bytes. From V8's perspective, accessing the snapshot is just dereferencing a global variable.

Inspecting the embedded snapshot

You can see the snapshot inside your installed Node binary. On Linux:

bash · inspect node binary
# size of each ELF section
$ size $(which node)
   text       data        bss        dec        hex  filename
83145872  1893128   1842296   86881296  52d5d50  node

# dump section contents — look for the snapshot symbol
$ objdump -t $(which node) | grep -i snapshot_blob
0000000004a8c000 g     O .rodata 0000000000228000 v8_snapshot_blob_data
                                  ↑                ↑
                                  loaded at        ~2.2 MB long

# the .rodata section is mapped read-only at runtime
$ readelf -S $(which node) | grep rodata
  [ 17] .rodata    PROGBITS  ...   0028e310  ALLOC
                                            ↑ mapped into memory at process start

Why static linking instead of a separate file?

1
One artifact to ship. No "where is snapshot.bin" path resolution. No risk of mismatched files. Distribute one Node binary and the snapshot is already inside.
2
Free I/O. The OS already maps .rodata when it loads the binary's segments at process start. No additional open() + read() needed.
3
Version safety. A V8 12.4 binary literally cannot load a V8 12.5 snapshot — they're physically inseparable. No version skew possible.
4
ASLR-compatible. The OS can place the binary anywhere in virtual memory; the snapshot moves with it. Combined with the relocation table (Step B), this is how the snapshot tolerates random load addresses.
!
Lock this in
A meaningful chunk of your Node binary's ~80 MB on disk is just the snapshot — a frozen JavaScript world sitting in .rodata. Open the binary in a hex editor and you can find Object.prototype's Map, the bytes of Array.prototype.push's machine code, and the string "length". They're all in there.

What mmap does

mmap is a POSIX syscall that establishes a mapping between a region of virtual memory in your process and either a file on disk or anonymous memory. After mmap returns:

A
Your process has a virtual address range that "contains" the file's bytes — but the bytes haven't been read into RAM yet. The kernel just records the mapping in the process's page table.
B
The first time you read a virtual page, the CPU triggers a page fault (because the page isn't physically backed yet). The kernel handles the fault by reading the right disk sector(s) into RAM and updating the page table.
C
Subsequent reads of that page hit RAM — they're as fast as any other memory access.
D
If memory pressure rises, the kernel can evict mapped pages without writing them back (they're clean — the file is the source of truth). Re-reading them just causes another fault and another disk read.

The runtime sequence · what happens when you run node

PROCESS ADDRESS SPACE · pages backed lazily no read() · no malloc · no copy
PROCESS · virtual address space DISK · node binary file 0x000000… .text · executable code .data · globals (writable) SNAPSHOT REGION ~1.8 MB virtual in RAM in RAM unfaulted unfaulted unfaulted in RAM 4 KB pages · backed lazily .bss · zero-initialized heap · grows up malloc / V8 spaces shared libraries stack · grows down 0x7fff… PAGE FAULT on first access to a virtual page page → mapped in process page table /usr/bin/node · ELF binary .text section · code other sections .rodata · snapshot inside ~1.8 MB at file offset 0x... page page page page … all 4 KB pages on disk .data ~80 MB total on disk

Why mmap beats read

1
No copy. A read() call copies bytes from the kernel's page cache into a userspace buffer you allocated. mmap doesn't — your virtual address range is the page cache (when faulted).
2
No malloc. No buffer to allocate. mmap just modifies the process page table. Memory accounting shows up as RSS only after you actually touch pages.
3
Lazy. If your code never touches half the snapshot pages (because it doesn't use those builtins), those pages never get loaded. RAM saved.
4
Shared across processes. Two Node processes mapping the same binary share the underlying physical pages of the snapshot. The kernel does this transparently. RAM saved across processes too.
5
Reclaimable. Under memory pressure, mapped pages can be evicted without disk writeback (the file is the source of truth). The system can free RAM aggressively.

Inspecting it yourself

bash · /proc/PID/maps
# start a long-running node process
$ node -e 'setInterval(() => {}, 1000)' &
[1] 12345

# inspect its memory map
$ cat /proc/12345/maps | grep node
55a4...    r--p  00000000   /usr/bin/node   # .rodata mapping (snapshot lives here)
55a4...    r-xp  00228000   /usr/bin/node   # .text mapping
55a4...    rw-p  03ffe000   /usr/bin/node   # .data mapping
                  ↑
                  file offset · these all map directly from the binary

# memory currently resident vs total
$ cat /proc/12345/status | grep -E 'VmRSS|VmSize'
VmSize:    589824 kB    # 589 MB of virtual address space
VmRSS:      45120 kB    # only 45 MB actually in physical RAM
                        # most snapshot pages haven't been faulted in
!
Lock this in
When Node starts, the snapshot doesn't get loaded. It gets mapped. The OS hands V8 a virtual address range that conceptually contains the JS world, but pages only become real when V8 actually reads them. Most startup work isn't "loading" — it's the OS gradually bringing pages into RAM in response to V8's accesses.

Why ASLR breaks raw pointers

Address Space Layout Randomization (ASLR) is a security feature in every modern OS. It deliberately loads each binary at a random virtual address, making memory-corruption exploits harder (the attacker can't predict where things will land).

That means every time you run node, the binary's .rodata section — and the snapshot inside it — lives at a different virtual address. Run twice and you'll see something like:

two runs of the same node binary · different addresses
$ node -e 'console.log(1)'
   .rodata mapped at: 0x55a4_8c12_0000     ; snapshot starts here

$ node -e 'console.log(1)'
   .rodata mapped at: 0x7f23_9b80_3000     ; different address!

$ node -e 'console.log(1)'
   .rodata mapped at: 0x562b_3a45_7000     ; different again

If a pointer inside the snapshot bytes contained the literal value 0x55a4_8c12_8a40 (a build-time guess), it would be valid in run #1 but garbage in runs #2 and #3. Almost all pointers in the snapshot are relative (object IDs resolved through the pointer table — see Step 3), but some can't be:

A
Code embedded references. Compiled machine code (from Step 2) sometimes contains absolute addresses for things like helper function calls or external C function references.
B
Object pointers in code. A compiled builtin might bake the address of a specific Map or String into its instruction stream — e.g., Array.prototype.push may directly reference the Array Map.
C
External references. Pointers from snapshot data to V8 runtime functions (which live in .text, not in the snapshot) need fixup against a side table of known functions.

The relocation table

To handle these, the serializer (Step 3) records every position in the blob where an absolute pointer appears, along with metadata for how to patch it. This is the relocation table. It's a compact list, written into the blob alongside the actual data:

RELOCATION FIXUP · table walks the blob microseconds · just memory writes
RELOCATION TABLE · part of the blob offset · in blob kind target · what to patch with 0x000_1234 FULL_PTR add base + 0x000_5678 0x000_2a40 CODE_OBJECT add base + 0x000_8b20 0x000_4c08 EXT_REF → V8::Runtime::Foo BLOB · being patched in place … bytes … at 0x000_1234 [8 bytes] … at 0x000_2a40 [8 bytes] … at 0x000_4c08 [8 bytes] FIXUP LOOP · the entire pass for each entry in relocation_table: addr = blob_base + entry.offset *addr += blob_base

The fixup loop · step by step

1
Discover the actual base address. After mmap (Step A), V8 knows where the snapshot region lives in this process's virtual memory. Call this blob_base.
2
Walk the relocation table. The table is a sequence of (offset, kind, target) entries. V8 reads it linearly.
3
For each entry, patch. Compute the absolute address of the patch site: addr = blob_base + entry.offset. Read the existing 8 bytes there. Add blob_base to it (relocating a pointer relative to the snapshot's start). Write back.
4
External references use a side table. For pointers to V8 runtime functions (which live elsewhere in the binary), V8 has an external reference table — an array of pointers to known functions. The relocation entry's target is an index into this table; V8 writes the indexed pointer to the patch site.
5
Done. Every absolute pointer in the snapshot now points to the right place in this process's address space. The JS world is wired up correctly for this run.

Why this is so fast

The whole pass is a tight memory-walking loop — no parsing, no allocation, no I/O. With ~tens of thousands of relocation entries, on modern hardware:

A
Cache-friendly. The relocation table is contiguous; reading it is sequential. The patch sites are within the snapshot region (which mmap just mapped). All in L1/L2 cache after a few iterations.
B
Branch-predictable. The loop body has small fixed paths per relocation kind. Modern CPUs nail the branch prediction.
C
No syscalls. Just userspace memory writes to pages V8 already mapped.

Result: a few hundred microseconds at most. Step B is essentially "free" compared to the milliseconds spent elsewhere in startup.

!
Lock this in
Steps 3 (build-time serialize) and B (runtime relocate) are two halves of one mechanism: position-independent serialization. The serializer turns absolute pointers into relative offsets plus a fixup table; the relocator turns them back into absolute pointers in whatever address space the new process happens to have. ASLR security and snapshot portability coexist because of this trick.

What's available the moment startup completes

FULLY-FORMED WORLD · what your first JS line can reach all populated, all linked, all compiled
CENTER · the realm globalThis Native Context all builtins reachable from here Object + prototype + 12 methods Array + prototype + 38 methods Promise + prototype + then/catch/all Function + prototype + call/apply/bind String + prototype + ~40 methods Map · Set + WeakMap + WeakSet Number + Math · Date + BigInt JSON + Reflect + Symbol · Proxy ~30 more constructors Error · TypedArray · Iterator · … all machine code already compiled Torque output baked into the snapshot

The first JS line you run can call any of these

This is the operational meaning of "ready." Try this script:

main.js · runs the moment startup completes
const arr = [1, 2, 3];          // Array constructor: ready
arr.push(4);                   // Array.prototype.push: pre-compiled

const p = Promise.resolve(42);  // Promise: ready
p.then(console.log);          // console: ready (well, host-supplied; same idea)

JSON.stringify({ ok: true }); // JSON: ready

// Every one of these is a direct call into pre-compiled native
// code that was sitting in .rodata until V8 mapped it.

Without the snapshot, that first arr.push(4) couldn't run yet — V8 would still be busy building Array.prototype.push from Torque source. With the snapshot, the function is just there, machine code and all.

The 50ms vs 500ms · where the time goes

STARTUP COST · with vs without snapshot measured on Node 22 · M2 / Linux x86
WITH SNAPSHOT ~50ms total — what we ship mmap reloc your code ~25ms ~µs remaining ~25ms WITHOUT SNAPSHOT ~500ms total — what mksnapshot does build prototypes compile builtins (Torque → asm) init Maps your code ~150ms ~250ms ~75ms ~25ms ~10× speedup — and that's mostly because mmap is essentially free

Why this matters in practice

1
CLI tools. Tools like npm, tsc, eslint, prettier run for short bursts and exit. Without snapshots, the JS world setup would dominate runtime. With snapshots, the tool's own logic is what you wait for.
2
Serverless cold starts. AWS Lambda, Cloudflare Workers, Vercel Edge all care about milliseconds. Cold-start time is largely the cost of bringing up V8. The snapshot shaves hundreds of ms off every cold invocation.
3
Browser tab open. Same V8, same snapshot mechanism. Every Chrome tab gets its own Isolate, but they share the read-only snapshot region — RAM saved across tabs.
4
Embedder-defined snapshots. Node lets you create custom snapshots that include your own code. node --snapshot-blob foo.bin --build-snapshot setup.js bakes setup.js's state into a custom blob. Apps that need to load big config or warm up can ship that as a snapshot too.

Measuring it yourself

bash · benchmark startup
# trivial program · measure startup overhead
$ time node -e '1'
real    0m0.052s        # with snapshot — Node 22

# V8 has a debug build option to disable snapshot loading
# (only available in custom builds — illustrative)
$ time node-no-snapshot -e '1'
real    0m0.487s        # without snapshot

# Node also exposes timing categories
$ node --trace-startup -e '1'
[v8] Snapshot deserialize:   3.2ms
[v8] Heap setup:             1.1ms
[v8] Native context init:    8.4ms
[node] Bootstrap:           14.7ms
[user] First JS:             0.0ms
!
Lock this in
Snapshots are why Node and Chrome feel instant. The entire seven-step lifecycle — bootstrap, compile builtins, serialize, embed, mmap, relocate, ready — exists for one reason: do the expensive work once when V8 is built, then ship the result. Every Node invocation, every browser tab, every serverless cold start benefits. The pattern (build-time setup, runtime mmap, runtime fixup) is general — V8 is just an unusually thorough application of it.
Section 02 · Parse
Source bytes become an Abstract Syntax Tree.
The host fetches bytes. The scanner groups characters into tokens. The parser builds structure. V8 lazily defers most of this work — parsing cost is part of startup latency, and engines work hard to do as little as possible up front.

SECTION 05Stage 0: The host environment loads the source

The host fetches bytes and hands them to V8.

In Node.js: fs.readFile (via libuv) → UTF-8 buffer → V8.

In a browser: HTTP fetch → UTF-8 buffer → V8.

There is no preprocessor. There is no #include. There is no macro expansion. Module imports are handled later, by the engine, after parsing has discovered them.

SECTION 06Stage 1: Scanning (lexing)

The scanner converts characters into tokens.

text → tokenslanguage units, not bytes
Text:
"function add(a, b) {"

        ↓ scanner

Tokens:
[FUNCTION] [IDENT:add] [LPAREN] [IDENT:a] [COMMA] [IDENT:b] [RPAREN] [LBRACE]

V8's scanner handles:

  • UTF-8 / UTF-16 source encoding
  • Unicode identifiers (let π = 3.14 is valid)
  • Automatic semicolon insertion hints
  • Template literal nesting
  • Regex literal disambiguation (/ is division or regex start, depending on parser context)

That last one cooperates with the parser — the scanner can't always decide alone.

SECTION 07Stage 2: Parsing → AST

The parser produces an Abstract Syntax Tree.

JavaScript
function add(a, b) {
  return a + b;
}
AST · function add(a, b) { return a + b; } structured meaning, not characters
NODE FunctionDeclaration name: "add" params · array body · BlockStatement IDENT "a" IDENT "b" ReturnStatement BINARY EXPRESSION operator: + IDENT · left "a" IDENT · right "b" TRY IT paste any code into astexplorer .net

Paste any code into astexplorer.net to see this same kind of tree.

7.1 Lazy parsing

The user is waiting right now for the page to load. Eagerly parsing a 5MB bundle would block the main thread for hundreds of milliseconds.

So V8 does lazy parsing (sometimes called preparsing):

JavaScript
function rarely_called() {
  // 500 lines
}

function called_immediately() {
  // 10 lines
}

called_immediately();

V8's strategy:

PREPARSE vs FULL-PARSE · two parallel tracks based on whether the function was called
TRACK A · PREPARSE function rarely_called defined but never called yet STEP 1 scan tokens just enough to find end brace STEP 2 verify syntax is valid STEP 3 record source range + free variables SKIPPED no AST built · no bytecode RESULT cheap · ~µs · runs at startup TRACK B · FULL-PARSE function called_immediately about to be invoked STEP 1 build full AST · every node STEP 2 generate bytecode via Ignition STEP 3 execute · collect type feedback LATER if it gets hot → JIT compilation RESULT expensive · pays off when used

When rarely_called is eventually invoked, V8 goes back, full-parses it, then generates bytecode.

!
Lock this in
In JavaScript, parsing cost is part of startup latency. Engines defer parsing of code you might not run.

This is why bundlers care about tree-shaking and code-splitting — every byte of unused JS still costs at least scanning + preparsing.

Section 03 · Bytecode
Ignition: real bytes, captured live from V8.
After parsing, V8 hands a function to Ignition, which generates compact register-based bytecode and immediately interprets it. Every byte shown in this section came from a Node 22 process via --print-bytecode. Six bytes for an addition. Nineteen for a console.log call.

SECTION 08Stage 3: Bytecode generation (Ignition)

After parsing, V8 hands a function to Ignition, which generates bytecode.

8.1 What a BytecodeArray actually is

A BytecodeArray is a heap object. Its layout (simplified, post pointer-compression V8):

BYTECODEARRAY · heap object · click any field offsets in bytes
BytecodeArray for function add(a, b) → 6 bytes long Map pointer → BytecodeArray map bytes 0..3 constant_pool pointer → string literals, regex, scope info bytes 4..7 handler_table pointer → try/catch handlers bytes 8..11 source_position_table → for stack traces bytes 12..15 frame_size · param_size · age int32s bytes 16..27 length = 6 (for our add example) bytes 28..31 INLINE · the actual bytecode bytes starts at byte 32, length bytes follow 0b Ldar 04 a1 3b Add 03 a0 00 fb#0 ae Return HEADER 32 bytes of metadata + side-table ptrs PAYLOAD N bytes of bytecode

The constant pool holds string literals, regex literals, scope info, and any value the bytecode needs to reference indirectly.

8.2 Real bytecode for add(a, b)

This is real output from node --print-bytecode on:

JavaScript
function add(a, b) {
  return a + b;
}
V8 · --print-bytecodelive capture
Bytecode length: 6
Parameter count 3
Register count 0
Frame size 0
   23 S> @    0 : 0b 04             Ldar a1
   32 E> @    2 : 3b 03 00          Add a0, [0]
   36 S> @    5 : ae                Return
Constant pool (size = 0)

The actual six bytes V8 stored in the BytecodeArray:

bytes
0b043b0300ae

Decoded:

byte-by-byte decode
0b      = Ldar opcode (load accumulator from register)
04      = register operand (a1, the second parameter — V8 encodes parameters as
          register slots beneath the frame pointer; a0 is the receiver `this`,
          a1 is the first declared param `a`... wait, see below)

3b      = Add opcode (add register to accumulator)
03      = register operand (a0)
00      = feedback vector slot index 0

ae      = Return opcode

Wait — Ldar a1 and Add a0? That looks reversed. It is, sort of. V8 numbers parameter slots starting from the "implicit receiver" side, so for function add(a, b):

  • a0 = the first declared parameter a
  • a1 = the second declared parameter b

The bytecode reads as: load b into accumulator, add a to accumulator, return.

Total: 6 bytes of bytecode for the entire function. Compare to TurboFan's optimized output later, which is 212 bytes of x86-64 machine code for the same function.

!
Lock this in
Bytecode is V8's compact, uniform middle representation. The interpreter is what makes those bytes do something.

8.3 Real bytecode for console.log("Hello")

Real output for:

JavaScript
function greet() {
  console.log("Hello");
}
V8 · --print-bytecodelive capture
Bytecode length: 19
Parameter count 1
Register count 3
Frame size 24
   21 S> @    0 : 21 00 00          LdaGlobal [0], [0]
         @    3 : c8                Star1
   29 E> @    4 : 2f f8 01 02       GetNamedProperty r1, [1], [2]
         @    8 : c9                Star0
         @    9 : 13 02             LdaConstant [2]
         @   11 : c7                Star2
   29 E> @   12 : 61 f9 f8 f7 04    CallProperty1 r0, r1, r2, [4]
         @   17 : 0e                LdaUndefined
   43 S> @   18 : ae                Return

Constant pool (size = 3)
   0: <String[7]: #console>
   1: <String[3]: #log>
   2: <String[5]: #Hello>

The actual 19 bytes:

bytes
210000c82ff80102c91302c761f9f8f7040eae

Walking through:

decoded walk-through
21 00 00       LdaGlobal [name=const#0, fb=slot#0]
                 → load global "console" into accumulator
                 → uses feedback slot 0 to remember where console lives

c8             Star1
                 → store accumulator into register r1

2f f8 01 02    GetNamedProperty [obj=r1, name=const#1, fb=slot#2]
                 → r1.log → accumulator
                 → feedback slot 2 records the shape of `console`
                 → 0xf8 is the encoding of register r1 (negative offsets)

c9             Star0
                 → store accumulator (the function) into r0

13 02          LdaConstant [const#2]
                 → load string "Hello" into accumulator

c7             Star2
                 → store into r2

61 f9 f8 f7 04 CallProperty1 [func=r0, recv=r1, arg1=r2, fb=slot#4]
                 → call r0 with `this`=r1, args=[r2]
                 → equivalent to: r0.call(r1, r2)
                 → feedback slot 4 records what function r0 is

0e             LdaUndefined
                 → load undefined into accumulator (implicit return)

ae             Return

Notice how many feedback slots there are: 4 in 19 bytes. Roughly every property access, global lookup, and call gets one. That's the type-feedback machinery the optimizer will consume later.

The constant pool holds three strings: "console", "log", "Hello". Those strings are heap objects. The bytecode references them by integer index — much cheaper than embedding pointers inline.

8.4 Real bytecode for property access

JavaScript
function getX(p) {
  return p.x;
}
V8 · --print-bytecodelive capture
Bytecode length: 5
Parameter count 2
Register count 0
   30 S> @    0 : 2f 03 00 00       GetNamedProperty a0, [0], [0]
   32 S> @    4 : ae                Return

Constant pool (size = 1)
   0: <String[1]: #x>

Five bytes: 2f 03 00 00 ae. This is the entire compiled body of a property accessor.

Note GetNamedProperty a0, [0], [0]:

  • a0 is the parameter p
  • [0] is constant pool index 0 (the string "x")
  • [0] is feedback slot 0 (where the IC state lives)

That last [0] is everything we care about for performance.

SECTION 09Stage 4: The interpreter executes bytecode

Ignition is both the bytecode generator and the interpreter that executes it.

9.1 The naive interpreter you might imagine

A textbook interpreter looks like:

C++ · pseudocode
while (true) {
  uint8_t opcode = bytecode[pc];
  switch (opcode) {
    case Ldar: { /* load */                pc += 2; break; }
    case Add:  { /* add */                 pc += 4; break; }
    case Return: { return accumulator; }
    // ~250 cases
  }
}

This works. It is also slow, for one CPU-architecture reason: the central switch is one indirect branch. The CPU's branch predictor only has so much state to predict where a single shared branch goes next. With ~250 possible targets, prediction accuracy collapses, and the CPU stalls on every dispatch.

9.2 V8's threaded interpreter

V8 doesn't write the interpreter as a switch. It writes each opcode handler as a separate piece of machine code (generated by TurboFan at V8 build time), and ends each handler with a tail-call to the next handler.

A handler conceptually compiles to:

x86-64 · asm sketch
; <handler for Ldar>
;   - perform the load
;   - advance the pc
;   - dispatch to next handler

mov  rax, [r12 + rbx*8]      ; load register value into accumulator
                              ; (r12 = bytecode array, rbx = register operand)
add  r9, 2                    ; advance bytecode offset by Ldar's size
movzx rax, byte [r12 + r9]    ; load NEXT opcode byte
jmp  [r15 + rax*8]            ; tail-call into dispatch table[next_opcode]

Now the dispatch is at the end of every handler, not in one central location. The CPU has many distinct branch sites, each with its own prediction state. After Ldar, the most likely next opcode is some specific subset; after Add, a different subset. The branch predictor can learn each site independently.

Result: the threaded interpreter is roughly 2× faster than the naive switch interpreter, with no algorithmic changes.

9.3 Interpreter calling convention

The interpreter runs JS bytecode using fixed CPU registers on x86-64:

RoleRegHolds
AccumulatorraxResult of most operations
Bytecode arrayr12Pointer to BytecodeArray's bytes
Bytecode offsetr9Current pc (offset, not address)
Dispatch tabler15Pointer to handler table
Frame pointerrbpStandard x64 frame pointer
ContextrsiCurrent Context (for globals)

(Exact register choices vary slightly by V8 version. The principle is stable: dedicated registers for accumulator, bytecode pointer, and dispatch table, kept hot across all handlers.)

The accumulator is what makes V8's bytecode "register-based with an accumulator." Most ops implicitly read or write rax. This keeps the bytecode small — you don't encode source/dest registers for every op.

encoding size comparison
Compare encoding sizes:

V8 (accumulator):       Add a0, [0]       3 bytes
Pure register-based:    Add r1, r2, r3   ~5 bytes
Stack-based (JVM):      iload_1; iload_2; iadd  3 bytes but more work
!
Lock this in
V8's interpreter is generated machine code, not C++. Its dispatch is fast because branches are spread across handlers. Its bytecode is small because the accumulator is implicit.

9.4 What happens during Ldar a1

Concretely, when V8 executes Ldar a1 from our add example:

step-by-step execution
State:
  r12 = pointer to BytecodeArray bytes [0b 04 3b 03 00 ae]
  r9  = 0  (we're at the start)
  rax = whatever (about to be overwritten)
  rbp points to the frame; arguments are at [rbp+16], [rbp+24], ...

Step 1: opcode dispatch already happened — we're inside Ldar's handler
Step 2: read the operand byte (0x04) at [r12 + r9 + 1]
Step 3: compute the source — for parameter a1, that's [rbp + 24]
Step 4: load: rax ← [rbp + 24]    (this is `b`)
Step 5: r9 += 2                    (Ldar is 2 bytes)
Step 6: load next opcode byte: al ← [r12 + r9]   (which is 0x3b for Add)
Step 7: jump to dispatch_table[0x3b]  → Add's handler

Steps 6 and 7 are the "tail-call to next handler." The CPU never returns to a central loop.

Section 04 · Profile
Inline caches and hidden classes: how dynamic gets fast.
JavaScript is dynamic, so the engine has to watch what types actually appear at runtime. Every property access, every call, every arithmetic op gets a feedback slot. The advice "keep object shapes consistent" is literally about this state machine.

SECTION 10Stage 5: Profiling, ICs, hidden classes

JavaScript is dynamic, so the engine has to watch what types actually appear at runtime.

10.1 The feedback vector

Every function gets a FeedbackVector alongside its bytecode. The FeedbackVector has one slot per profileable operation in the bytecode.

For our getX function with 2f 03 00 00 ae:

FeedbackVector layoutside-table to bytecode
FeedbackVector for getX
+----------------------------------+
| Map pointer                      |
+----------------------------------+
| length: 1                        |
+----------------------------------+
| optimization marker / state      |
+----------------------------------+
| closure_feedback_cell_array      |
+----------------------------------+
| Slot 0: IC state for p.x         |  ← used by GetNamedProperty
+----------------------------------+

A single slot for a GetNamedProperty IC progresses through states:

IC STATE MACHINE · click any state cold → warm → hot → too hot
UNINITIALIZED ∅ never called slot is empty 1st call MONOMORPHIC 1 one shape seen FAST PATH 2-4 shapes POLYMORPHIC 2-4 linear scan SLOWER 5+ shapes MEGAMORPHIC ∞ give up SLOW PATH Performance: keep your code in MONOMORPHIC. "keep object shapes consistent" = "keep ICs MONOMORPHIC" no path back: once an IC has gone megamorphic, it generally stays megamorphic for that call site.
!
Lock this in
Performance advice like "keep object shapes consistent" is literally about keeping ICs in MONOMORPHIC state.

10.2 Hidden classes (Maps)

{ x: 1, y: 2 } has no class declaration in source, but V8 creates one anyway. In V8 source it's called a Map (no relation to Map the JS class). In SpiderMonkey it's called a Shape. In JSC it's a Structure.

A Map for {x, y} looks roughly like:

V8 MAP · hidden class for {x, y} heap object · 32 bytes
MAP for { x, y } · all instances of this shape share this Map meta-map pointer 0..3 instance_type = JS_OBJECT_TYPE 4..5 bit_field · flags extensible · sealed · frozen · … 6 instance_size_in_words = 5 (header + x + y + 2 reserved) 7 inobject_properties_count = 2 8 unused / visitor_id / bit_fields small bookkeeping fields 9..15 prototype pointer → Object.prototype (typically) 16..19 constructor_or_back_pointer 20..23 descriptor_array pointer → [(name="x", offset=0, attrs), (name="y", offset=1, attrs)] 24..27 transitions pointer → which Maps you reach by adding another property 28..31 DESCRIPTOR ARRAY "x" offset 0 · attrs "y" offset 1 · attrs describes layout of in-object property slots PROTOTYPE Object.prototype THE MAP SAYS instances are 5 tagged-words long · property x lives at slot 0 · property y at slot 1

The Map says: instances are 5 tagged-words long; properties x and y are at offsets 0 and 1 of the in-object slot region.

10.3 The JSObject itself

For const p = { x: 1, y: 2 }:

JSObject + HIDDEN CLASS · click any field how V8 lays out { x: 1, y: 2 }
JSObject p · 20 bytes Map pointer → hidden class 0..3 properties pointer empty fixed array 4..7 elements pointer for indexed props 8..11 in-object slot 0 x = Smi(1) 12..15 in-object slot 1 y = Smi(2) 16..19 points to Map · hidden class for {x, y} meta-map · type · bit_field · sizes 0..15 prototype pointer · constructor 16..23 descriptor_array pointer [(name="x", offset=0), (name="y", offset=1)] 24..27 transitions · which Map next 28..31 describes layout of → hot path: read p.x in 4 instructions ; load Map pointer from p mov rdx, [p + 0] ; check shape against cached cmp rdx, <cached_map> ; mismatch → handler jne <miss handler> ; load slot 0 → x mov rax, [p + 12] 5 instructions to read a property — 4 if you're lucky.

That's it. 20 bytes for the object. Reading p.x after the IC is hot is just five instructions, four if you're lucky.

10.4 Smi tagging

The Smi(1) above is not the integer 1 stored as 4 bytes. JavaScript's tagged-pointer scheme distinguishes pointers from small integers in the same word.

V8 on 64-bit (with pointer compression, 32-bit values inside the heap):

SMI TAGGING · 32-bit tagged value low bit = type discriminator
CASE 1 · low bit is 0 → SMI · the integer 1 value = 1 << 1 = 0x00000002 31 27 23 19 15 11 7 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 31-bit signed integer payload to recover the integer: value >> 1 TAG value 0x2 recovers int 1 CASE 2 · low bit is 1 → HEAPOBJECT POINTER value = (aligned_address) | 1 31 0 x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x 1 31-bit pointer (compressed offset / 8) to dereference: (value − 1) + heap_base TAG value …|1 recovers heap ptr EXAMPLES · what common values look like in memory int 0 0x00000000 tag bit 0 → Smi · payload 0 int 1 0x00000002 payload 1, shifted left by one int 2 0x00000004 payload 2, shifted left by one { x: 1 } · object 0x00abcde9 tag bit 1 → HeapObject ptr CONSEQUENCE every integer add must check tags · every property read must check the result isn't a Smi before dereferencing

So the integer 1 is stored as bit pattern 0x00000002 (1 << 1). The integer 2 is stored as 0x00000004. A pointer to a heap object is <aligned address> | 1.

This single design decision affects every operation in V8. Every integer add must check that both operands are Smis. Every property read must check that the result isn't a Smi before dereferencing it. We'll see those checks become explicit machine code in section 12.

10.5 Why property order matters

JavaScript
const a = { x: 1, y: 2 };
const b = { y: 2, x: 1 };

a and b have different Maps. V8 builds Maps by following a transition tree:

MAP TRANSITION TREE · property order matters {x, y} ≠ {y, x} at the engine level
empty Map no properties yet add "x" add "y" Map_X props: [x] Map_Y props: [y] add "y" add "x" Map_XY props: [x, y] · in this order a's Map const a = { x: 1, y: 2 } Map_YX props: [y, x] · in this order b's Map const b = { y: 2, x: 1 } DIFFERENT MAPS same shape different identity code reading .x from BOTH a and b → POLYMORPHIC IC

Code that reads .x from objects with both Maps is polymorphic. Code that reads .x from objects all built {x, y, z} is monomorphic.

This is why React advice "always include all keys, in the same order, every render" matters — it keeps Maps stable.

10.6 What ICs cache

For p.x after the first call with p = {x: 1, y: 2}:

IC slot contents · MONOMORPHIC
Slot 0 contents (MONOMORPHIC):
  Map pointer:   <Map_XY address>
  Handler:       <handler bytecode/code>
  Smi/data:      offset = 0

The handler can be:

  • "load in-object property at offset N"
  • "load from properties backing array at offset N"
  • "call a getter function"
  • "check prototype chain, then load"
  • ...etc.

When TurboFan optimizes the calling function, it inlines the handler's logic directly into the optimized machine code, eliminating the indirect through the IC.

Section 05 · JIT
Sparkplug, Maglev, TurboFan: real machine code.
Once a function is hot, V8 promotes it through three JIT tiers. The TurboFan output below is real assembly captured from a Node 22 process via --print-opt-code, with the engine's own deopt reasons annotated: "not a Smi", "overflow".

SECTION 11Stage 6: JIT compilation tiers

Once a function is hot enough, V8 promotes it. There are three JIT tiers above Ignition:

JIT TIER LADDER · click any tier cold ↑ very hot · with deopt back-arrow
TIER 0 · BYTECODE Ignition cold · interpreter · runs everything slow exec / instant compile ~1k execs · warm TIER 1 · BASELINE JIT Sparkplug no IR · 1:1 bytecode → machine code +5-15% speed / µs compile hot · use feedback TIER 2 · MID-TIER OPTIMIZER Maglev SSA · lightweight · feedback-driven 2-3× / ms compile very hot TIER 3 · TOP OPTIMIZER TurboFan Sea of Nodes · speculative · deopt DEOPT when an assumption breaks… "not a Smi" "overflow" SPEED → fastest slowest deopt is expensive (microseconds), but V8 will not make the same mistake twice.

11.1 Sparkplug

Sparkplug doesn't even build an IR. It walks bytecode and emits machine code instruction-by-instruction. Each Ldar becomes a mov. Each Add becomes the same logic the threaded interpreter handler would do, but inlined directly with no dispatch.

Compilation takes microseconds. The benefit is removing the ~2-3 ns per opcode that interpreter dispatch costs.

11.2 Maglev

Builds SSA form. Uses feedback to specialize: if the IC says Add always sees Smis, Maglev emits Smi-only code with a deopt fallback. Doesn't do heavy optimization passes (no big inlining, no escape analysis).

Takes milliseconds to compile. Produces code 2-3× faster than Sparkplug.

11.3 TurboFan

Full optimizing compiler. Inlines aggressively. Eliminates redundant type checks. Hoists loads out of loops. Allocates objects on the stack when escape analysis allows. Specializes math operations.

And — speculatively optimizes. We'll see this in actual machine code below.

11.4 Real TurboFan output for add(a, b)

This is real machine code emitted by TurboFan (with --print-opt-code) for our add function, after it's been called with Smi args enough times for the optimizer to specialize on Smis.

The interesting part of the body:

x86-64 · TurboFanlive capture
; Smi check on a (parameter at [rbp+0x18])
mov   rdx, [rbp+0x18]
test  dl, 0x1                    ; bit 0 set means HeapObject pointer
jnz   deopt_not_a_smi_a          ; → deopt! a wasn't a Smi after all

; Untag a: signed shift right by 32 to extract the integer
mov   rcx, rdx
sar   rcx, 32

; Smi check on b (parameter at [rbp+0x20])
mov   rdi, [rbp+0x20]
test  dil, 0x1
jnz   deopt_not_a_smi_b          ; → deopt! b wasn't a Smi

; Untag b
mov   r8, rdi
sar   r8, 32

; The actual addition (32-bit)
add   r8d, ecx
jo    deopt_overflow             ; → deopt! result overflowed Smi range

; Re-tag the result back to a Smi
movsxd rdx, r8d
shl    rdx, 32
mov    rax, rdx

; Standard return
mov   rcx, [rbp-0x18]
mov   rsp, rbp
pop   rbp
ret   0x18

Three deopt sites, each labeled with an actual reason (V8's metadata records "not a Smi" and "overflow"):

deopt metadata
deopt_not_a_smi_a:   reason = 'not a Smi'   index = 0
deopt_not_a_smi_b:   reason = 'not a Smi'   index = 1
deopt_overflow:      reason = 'overflow'    index = 2
!
Lock this in
In C, int + int is one add instruction. In optimized JavaScript, int + int is roughly: type check, untag, add, overflow check, re-tag. That's the cost of dynamic typing — but it's bounded and small.

11.5 Deoptimization

If add("foo", "bar") is called after the function was specialized for Smis:

  1. Hits the test dl, 0x1 check
  2. dl's low bit is 1 (it's a string pointer, a HeapObject)
  3. jnz fires → jumps to deopt site
  4. V8's deoptimizer runs:
    • Reconstructs the interpreter frame from the optimized frame
    • Finds the right bytecode offset to resume at
    • Discards the optimized code
    • Marks the function as "do not re-optimize with Smi assumption"
  5. Execution continues in Ignition
  6. Type feedback updates: now sees [String, String] too
  7. Eventually re-optimizes with both Smi and String specializations, or gives up and falls back to a generic Add

Deopt is expensive (microseconds), but it's an investment — V8 will not make the same mistake twice.

SECTION 12JS calling conventions

When TurboFan/Maglev/Sparkplug emit code, they must agree on how functions pass arguments. V8 has its own ABI, distinct from the C calling convention.

12.1 The JS calling convention on x86-64

For a normal JS function call:

ItemRegNotes
Target functionrdiThe JSFunction being called
New targetrdxundefined unless new was used
Arg countraxHow many args were actually given
ContextrsiThe Context (for globals etc.)
stack layoutabove the return address
Stack (above the return address):
  [rsp + 0]:  return address
  [rsp + 8]:  receiver (`this`)
  [rsp + 16]: arg 0
  [rsp + 24]: arg 1
  ...

For our add(2, 3) call from optimized code:

x86-64 · call site
; preparation
mov  rdi, <add JSFunction>
mov  rsi, <current context>
mov  rdx, undefined
mov  rax, 2                  ; 2 args
mov  rcx, undefined           ; receiver (`this`)
push rcx                      ; push receiver
mov  rcx, Smi(3)
push rcx                      ; push arg 1
mov  rcx, Smi(2)
push rcx                      ; push arg 0
call <add's optimized code>

Inside add, the parameters are read from [rbp+0x18] (a) and [rbp+0x20] (b), as we saw above.

12.2 Comparing to C

On Linux x86-64 (System V), C uses:

System V · x86-64
First int args: rdi, rsi, rdx, rcx, r8, r9, then stack
Return value:   rax

These conventions overlap (V8 uses some of the same registers), but the meanings differ: V8's rsi is the Context, not the second argument. This is why crossing between JS and C++ requires "trampolines" — small bits of code that translate one ABI to the other.

12.3 Builtins and runtime calls

When optimized JS code needs to call into V8's C++ runtime (e.g., to allocate a large object, or to do a megamorphic property lookup), it goes through a different convention again. Builtins compiled from Torque have their own ABI tuned for being called from JS.

This is part of why JS engine engineering is so complex: there are 3-4 different ABIs in use simultaneously inside one process, and the engine has to bridge them efficiently.

Section 06 · Modules
TypeScript, source maps, and the two-phase ESM dance.
The engine never sees your TypeScript — it's gone before V8 reads a byte. ESM is fundamentally different from CJS: a two-phase process where the engine builds a module graph during parsing, then evaluates in dependency order with live bindings.

SECTION 13Source maps and TypeScript

When you write TypeScript, JSX, or use any bundler, the engine never sees your original code:

TYPESCRIPT ERASURE · what V8 actually sees types are a build-time concept only
YOUR SOURCE main.tsx function add( a: number, b: number ) : number {…} BUILD STEP tsc · esbuild · swc · babel type-check strip annotations downlevel JSX/syntax → emit .js + .map EMITTED main.js function add( a, b ) {…} types gone · often minified RUNTIME V8 sees only plain JS no idea it was TS SIDE OUTPUT · main.js.map DevTools-only side-table mapping (line, col) → original source the engine itself ignores it · only the debugger reads it

The engine has no idea your code was TypeScript. Type annotations are erased. There is no runtime type information from TS. Type tags inside V8 (Smi, HeapNumber, String) are entirely V8's invention; they have nothing to do with your TS types.

Source maps (.js.map) are a side-table for DevTools to map a stack trace position back to your original file. The engine itself ignores them.

!
Lock this in
TypeScript helps you write code. It does not help V8 run your code faster. Shape stability does.

SECTION 14Modules: a full worked ESM example

JavaScript has two module systems. CommonJS (require) is a synchronous function call. ES Modules (import) is a two-phase process the engine handles directly.

14.1 The example

util.js
export const PREFIX = "[log] ";

export function format(msg) {
  return PREFIX + msg;
}
math.js
import { format } from './util.js';

export function add(a, b) {
  console.log(format("adding"));
  return a + b;
}
main.js
import { add } from './math.js';
console.log(add(2, 3));

14.2 Phase 1: Construction & Linking

When you run node main.js, the engine does this:

ESM MODULE GRAPH · click any node parse → discover imports → link → evaluate
MODULE 1 · entry main.js imports: add ← ./math.js exports: (none) status: unlinked → linked → evaluated depends MODULE 2 · middle math.js imports: format ← ./util.js exports: add → local slot status: linked, then evaluated depends MODULE 3 · leaf util.js imports: (none) exports: PREFIX, format → local status: evaluated FIRST Phase 1: parse all → build graph → link bindings Phase 2: evaluate deepest first → util.js → math.js → main.js EVALUATION ORDER · deepest first imports are indirect bindings — POINTERS to the source module's slot, not copies. live binding semantics follow.
CONSTRUCTION PHASE · parse depth-first, build the graph no module body has run yet
STEP 1 · PARSE main.js discovers: import { add } from './math.js' ModuleRecord("main.js") imports: [{name: "add", from: "./math.js"}] exports: [] · status: unlinked STEP 2 · RESOLVE + PARSE math.js discovers: import { format } · export function add ModuleRecord("math.js") imports: [format ← ./util.js] exports: [add → local slot] STEP 3 · RESOLVE + PARSE util.js · LEAF discovers: export const PREFIX · export function format ModuleRecord("util.js") imports: [] · no deps exports: [PREFIX, format] STEP 4 · BUILD THE MODULE GRAPH main.js entry · root math.js middle · imports + exports util.js leaf · no deps STEP 5 · LINKING · wire imports to exports each import becomes an indirect binding · a pointer to the source module's local slot these are NOT copies — they are pointers · which is why mutation of an export is visible everywhere

After linking, the binding table is:

binding table after link
util.js's environment:
  PREFIX → local slot in util.js
  format → local slot in util.js

math.js's environment:
  format → indirect binding → util.js's `format` slot
  add    → local slot in math.js

main.js's environment:
  add → indirect binding → math.js's `add` slot

Critically: none of the module bodies have run yet. All slots are still in their initial uninitialized state.

14.3 Phase 2: Evaluation

evaluation phasedeepest module first
Evaluate util.js:
  - Initialize PREFIX slot = "[log] "
  - Initialize format slot = <function format>
  - Status: "evaluated"

Evaluate math.js:
  - The `format` slot in math.js's env was already an indirect binding
    pointing to util.js. Since util.js is evaluated, that pointer
    resolves to a real value.
  - Initialize add slot = <function add>
  - Status: "evaluated"

Evaluate main.js:
  - The `add` slot is now resolvable.
  - Execute: console.log(add(2, 3))
    - Call add(2, 3)
      - Inside add: console.log(format("adding"))
        - Look up format → indirect binding → util.js's format
        - Call format("adding") → returns "[log] adding"
        - console.log("[log] adding")
      - return 2 + 3 = 5
    - console.log(5)
  - Status: "evaluated"

14.4 Live bindings

The "indirect binding" detail matters. Consider:

counter.js
export let count = 0;
export function increment() { count++; }
main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1

In CJS, count would be a copy at require time, and the second log would print 0. In ESM, count is an indirect binding to counter.js's slot. When counter.js mutates its local count, main.js sees the new value because it's the same slot.

14.5 Comparison with CJS

Same code in CJS:

util.js (CJS)
const PREFIX = "[log] ";
function format(msg) { return PREFIX + msg; }
module.exports = { PREFIX, format };
main.js (CJS)
const { format } = require('./util.js');

What require('./util.js') does:

require() · synchronous flow
1. Resolve path
2. Check require cache. If hit, return cached module.exports.
3. Read file synchronously (blocks)
4. Wrap source: (function(exports, require, module, __filename, __dirname) {
                    /* file contents */
                  })
5. Hand wrapped source to V8 → parse → bytecode → execute
6. After execution, cache module.exports
7. Return module.exports

Each require is a function call that may block on disk I/O and trigger an entire parse-compile-execute pipeline run. Each ESM import is a declaration the engine collects ahead of time, parses in parallel, and links before any module body runs.

!
Lock this in
require is dynamic — runs at execution time, can be conditional, copies values. import is static — resolved before any code runs, can't be conditional at top level, creates live bindings. This is why dynamic import('./foo') is a separate primitive: it triggers a fresh ESM parse phase.
Section 07 · Memory & Loop
Generational GC, write barriers, and the event loop.
C makes you manage memory. JavaScript does not — and the cost is a sophisticated runtime that allocates with a pointer bump, sweeps with a scavenger, marks tri-color with write barriers, and yields to a host-driven event loop.

SECTION 15Memory: the heap, the stack, GC

C makes you manage memory. JavaScript does not. The cost of "does not" is a sophisticated runtime that constantly allocates, tracks, and reclaims memory.

15.1 The V8 heap layout

young gen
Young generation (~few MB)
from-space + to-space · semi-spaces for scavenger · new objects
old gen
Old generation
long-lived objects · mark-compact GC · incremental + concurrent marking
code · R-X
Code space
JIT-compiled machine code · Sparkplug/Maglev/TurboFan output
read-only
Read-only space
builtins, immortal objects · shared across Isolates in same process
bytecode
Bytecode space
BytecodeArrays from Ignition · compact instructions for every function
large object
Large object space
objects too big for young gen · large strings, big arrays
V8 ISOLATE HEAP · click any region memory regions and their lifetimes
V8 Isolate · one heap, one GC, one thread NATIVE + JS STACK per-thread, grows down · holds JS frames in optimized code, interpreter frames inside heap ↓ YOUNG GENERATION from-space (allocations here) to-space (filled during scavenge) OLD GENERATION long-lived objects · mark-compact GC tri-color marking · write barriers · incremental + concurrent CODE SPACE JIT machine code (R-X) R-X BYTECODE SPACE BytecodeArrays from Ignition READ-ONLY SPACE builtins, snapshot, immortal RO LARGE OBJECT SPACE objects too big for young gen C++ HEAP (V8 itself + libuv buffers + sockets etc.) native — outside V8's GC

15.2 Allocation: the bump allocator

For:

JavaScript
const obj = { x: 1, y: 2 };

Allocation in young gen is just:

bump allocator
free_top = young_gen.top
new_obj  = free_top
young_gen.top += sizeof(JSObject)   ; bump
if (young_gen.top > young_gen.limit)
  trigger_scavenger()

A single increment plus a limit check. Allocating millions of short-lived objects per second is ordinary.

15.3 Scavenger: collecting young gen

The young generation is split in half: from-space and to-space. Allocations only happen in from-space. When from-space fills up:

CHENEY'S ALGORITHM · scavenger GC copy live, free dead
BEFORE · from-space full · live and dead intermixed FROM-SPACE where allocations happen A B x x C x x live · reachable from roots dead · garbage A, B, C are reachable from stack/globals TO-SPACE empty · waiting unused half of young gen SCAVENGE · walk roots, copy survivors AFTER · live objects copied compactly · dead simply abandoned FROM-SPACE · ABANDONED →A'fwd →B'fwd →C'fwd forwarding pointers left for any in-progress refs whole region freed by resetting bump pointer TO-SPACE · COMPACT · NOW THE NEW FROM-SPACE A' B' C' survivors copied head-to-tail · no fragmentation cost ∝ live data · garbage never touched FINAL STEP · swap names to-space → new from-space · old from-space freed (just reset its pointer)

Cost is proportional to live data, not total allocations. Garbage doesn't get touched.

Objects that survive two scavenges get promoted to the old generation (different copy destination, same algorithm during that one cycle).

15.4 The remembered set: tracking old → young pointers

Problem: the scavenger only walks young gen. But what if an old-gen object holds a pointer to a young-gen object? We can't miss that root.

Solution: a remembered set, a side-table of "locations in old gen that point into young gen."

When JS code writes a pointer into an old-gen object's field, the engine inserts a check:

C++ · pseudocode
void WritePointerField(HeapObject* obj, int offset, HeapObject* value) {
  obj->RawWriteField(offset, value);

  if (InOldGen(obj) && InYoungGen(value)) {
    AddToRememberedSet(obj, offset);
  }
}

This write barrier runs on every pointer write. The implementation is heavily optimized — typically a few instructions checking memory page bits — but it's never zero cost.

During scavenger GC, the remembered set is walked as additional roots. We don't have to scan all of old gen. That's the whole reason generational GC works.

15.5 Mark-compact: collecting old gen

Old gen objects tend to live a long time. Copying them all every cycle would be wasteful. So old gen uses mark-compact instead.

MARK-COMPACT · tri-color invariant white → grey → black
TRI-COLOR INVARIANT · every object has one of three colors WHITE not yet visited GREY visited · children pending BLACK fully marked PHASE 1 · INITIAL · all objects WHITE, roots colored GREY and queued ROOT R A C D X Y MARK QUEUE [ R ] PHASE 2 · MID-MARK · pop GREY, color BLACK, queue WHITE children as GREY R A C D X Y MARK QUEUE · in-progress [ A, A2 ] PHASE 3 · DONE · queue empty · all reachable BLACK · remaining WHITE = garbage R A C D X Y ← garbage ← garbage NEXT · SWEEP + COMPACT free WHITE memory move BLACK objects together update all pointers to moved

15.6 Incremental and concurrent marking

A naive mark phase pauses JS execution for the entire walk. For a 1GB heap that can be hundreds of milliseconds — a janky disaster.

V8 marks incrementally: do a few milliseconds of marking, run JS for a while, mark more. And concurrently: do marking on a background thread.

This sounds simple but introduces a problem: while marking is paused, JS code might mutate the heap. Specifically, JS might:

incremental marking · the danger
1. Read pointer from BLACK object (already marked)
2. Write that pointer into a WHITE object (not yet marked)
3. Remove the pointer from the BLACK object
4. Now the WHITE object is unreachable from anywhere we'll visit
   → GC will incorrectly think it's garbage

This violates the tri-color invariant: a BLACK object now points to a WHITE object.

Solution: another write barrier. Every pointer write during marking:

C++ · pseudocode
void WritePointerField(HeapObject* obj, int offset, HeapObject* value) {
  obj->RawWriteField(offset, value);

  // Generational barrier (as before)
  if (InOldGen(obj) && InYoungGen(value)) {
    AddToRememberedSet(obj, offset);
  }

  // Incremental marking barrier
  if (marking_active && IsBlack(obj) && IsWhite(value)) {
    MakeGrey(value);
    PushToMarkingQueue(value);
  }
}

Now incremental/concurrent marking is safe. JS pays a tiny cost on every pointer write, in exchange for not having multi-hundred-millisecond GC pauses.

!
Lock this in
A JavaScript app that stutters is often hitting GC. GC cost is approximately proportional to allocation rate of long-lived objects + old-gen size. Reducing allocations in hot paths reduces GC pressure directly.

This is the deep reason React advice like "use stable references with useMemo/useCallback" matters. Every fresh closure or array allocated on every render is GC pressure.

15.7 The call stack

When optimized JS calls a function, it uses the native CPU stack with standard call/ret. The stack frame contains:

  • The return address
  • Saved frame pointer (rbp)
  • Saved registers (caller-saved by convention)
  • Local variables that didn't fit in registers
  • A "frame type marker" so the GC knows whether this is a JS frame, an interpreter frame, or a C++ frame

The GC walks frames during collection to find roots. It needs to know which slots in each frame hold tagged pointers (so they can be visited and updated) versus raw values (so they're left alone). For optimized code, V8 stores this metadata as a side-table per code object.

For the interpreter, V8 maintains a parallel "interpreter stack" within the heap. Each bytecode invocation has an InterpreterFrame holding the registers, accumulator, bytecode array pointer, etc. This is what powers the call stack you see in DevTools when you're not optimized.

SECTION 16The event loop

The engine doesn't drive the program. The host environment does.

EVENT LOOP · click any region macrotask → drain microtasks → render → repeat
SINGLE-THREADED Call stack your JS runs here runs to completion no preemption from host MACROTASK QUEUE one popped per loop turn • timers (setTimeout, setInterval) • I/O completion (fetch, fs) • DOM events (click, scroll) MICROTASK QUEUE drain ALL before next macrotask • .then() callbacks • await continuations • queueMicrotask RENDER · IDLE · LOOP browser only • rAF callbacks · paint frame • then back to macrotask pop HOST · OTHER THREADS parallel I/O lives here • libuv thread pool • timers · network · disk → pushes to queues pop & run drain loop forever
event loop · pseudocode
while (alive) {
  task = macrotask_queue.pop()
  if (task) execute_to_completion(task)

  while (microtask_queue.not_empty()) {
    mt = microtask_queue.pop()
    execute_to_completion(mt)
  }

  // browser only: render frame, idle work
}
!
Lock this in
JavaScript is single-threaded for your code. The host does I/O and timers in other threads, then queues callbacks back to the main thread. "Async" doesn't mean "parallel" — it means "deferred to a later turn of the loop."

await is a microtask scheduling mechanism. setTimeout(fn, 0) is a macrotask. They are different queues with different priority — and that's why await continuations run before setTimeout(0) callbacks even though both are "as soon as possible."

Section 08 · Recap
End-to-end: console.log("Hello"), the comparison, the shortest version.
One line of JS, traced from text on disk through every stage to the syscall that produces output. Then a side-by-side with C, what the engine doesn't do, and the shortest accurate version of the whole story.

SECTION 17End-to-end: console.log("Hello") from text to terminal

Putting everything together for one line.

17.1 The text on disk

JavaScript
console.log("Hello");

UTF-8 bytes:

bytes
636F6E736F6C652E6C6F67282248656C6C6F22293B
chars
console.log("Hello");

17.2 V8 starts up (already done before your file is read)

  • Isolate created
  • Snapshot deserialized: console, Object, Array, all builtins ready
  • Default Context exists with global object
  • Builtin code (including console.log) sits in code space, ready to call

17.3 Host loads source

Node calls libuv to read main.js synchronously, gets a Buffer, hands the UTF-8 bytes to V8.

17.4 Scanning + parsing

tokens → AST
Tokens: [IDENT:console] [DOT] [IDENT:log] [LPAREN] [STRING:"Hello"] [RPAREN] [SEMI]

AST: ExpressionStatement
       └── CallExpression
             ├── callee: MemberExpression(console.log)
             └── args: [StringLiteral("Hello")]

The string "Hello" becomes a heap-allocated String object. Its contents in memory:

STRING OBJECT · "Hello" in the heap header + bytes
STRING object on the heap FIELD 0 · 4 bytes Map pointer → String Map FIELD 1 · 4 bytes length: 5 FIELD 2 · 4 bytes hash code · cached, used by Map keys etc. PAYLOAD · 5 bytes (one-byte string) bytes: 48 65 6C 6C 6F H e l l o SHARED · String Map in Read-Only space all strings of this form share it VARIANTS SeqOneByteString · ASCII SeqTwoByteString · UTF-16 ConsString · "a"+"b" SlicedString · substring SUMMARY 3 header words (Map ptr · length · hash) + payload bytes · sized in advance from Map.instance_size strings are immutable; "+" produces a new String (often a ConsString that lazily flattens)

17.5 Bytecode

For the wrapping function (modules become functions internally):

bytes
210000c82ff80102c91302c761f9f8f7040eae
decoded
21 00 00       LdaGlobal "console", fb=0
c8             Star1
2f f8 01 02    GetNamedProperty r1, "log", fb=2
c9             Star0
13 02          LdaConstant "Hello"
c7             Star2
61 f9 f8 f7 04 CallProperty1 r0, r1, r2, fb=4
0e             LdaUndefined
ae             Return

Constant pool: ["console", "log", "Hello"].

17.6 First execution (Ignition)

Ignition's threaded interpreter executes:

interpretation walk-through
LdaGlobal:
  - Look up "console" in the global object
  - Found: a JSObject (the console builtin)
  - rax ← console
  - feedback slot 0 records: "found at this property cell"

Star1:
  - r1 ← rax (console)

GetNamedProperty:
  - r1 = console (a JSObject with a hidden class)
  - Look up "log" on console
  - Found: a JSFunction whose code points to V8's builtin console.log
  - rax ← log function
  - feedback slot 2 records: shape of console + offset of log

Star0:
  - r0 ← rax (log function)

LdaConstant:
  - rax ← constant_pool[2] = "Hello" String object pointer

Star2:
  - r2 ← rax

CallProperty1:
  - Set up JS call:
    rdi = r0 (the function)
    rsi = current Context
    rdx = undefined (not new.target)
    rax = 1 (one argument)
    push receiver (r1, console)
    push arg (r2, "Hello")
  - call <log function's code>
  - feedback slot 4 records: function = the console.log builtin

Inside console.log (compiled C++/Torque code):
  - Validate arguments
  - Format "Hello" for output (via Node's Console implementation)
  - Eventually calls process.stdout.write("Hello\n")
  - Which calls libuv's uv_write
  - Which calls the write(2) syscall

write(1, "Hello\n", 6)

The OS:
  - Receives the syscall
  - Routes 6 bytes to file descriptor 1 (stdout)
  - Bytes appear on terminal

LdaUndefined:
  - rax ← undefined

Return:
  - Standard function return

17.7 If this is in a hot loop

After Ignition has executed it a few thousand times with stable types:

  1. Sparkplug compiles the bytecode 1:1 to machine code, removing dispatch overhead
  2. Maglev may inline the property accesses based on feedback
  3. TurboFan can inline console.log itself if its identity is stable, fold "Hello" as a constant, and emit:
x86-64 · TurboFan inlined
; Speculative checks (one per assumption):
cmp [global_console_cell], <cached_console>     ; console hasn't been replaced
jne deopt
cmp [console_map], <cached_console_map>         ; shape unchanged
jne deopt

; Direct call to builtin
mov rdi, <cached_log_function>
mov rsi, <cached_context>
mov rax, 1
push <cached_console>             ; receiver
push <cached_hello_string>        ; argument
call <log_builtin_address>

Now we're at C-like speed: a few register loads, a call, and we're in the builtin. Three or four CPU cycles of overhead instead of dozens.

17.8 The bytes finally hit the OS

Either way — interpreter or optimized — the path bottoms out at write(1, "Hello\n", 6):

arg 1
1
→
stdout file descriptor
arg 2
"Hello\n"
→
bytes to write (in V8's heap, then copied)
arg 3
6
→
number of bytes

Below that syscall, it's the same OS-level path the C document covered.

!
Lock this in
C is fast because the compiler had time to think. JavaScript is fast because the engine watched the program run. Both end at the same syscall, with the same bytes.

SECTION 18Memory layout vs C: side-by-side

PROCESS MEMORY · C vs V8-hosted JS flat vs generational + JIT
C PROCESS · simple, flat STACK function frames · grows downward esp/rsp · automatic, very fast HEAP · malloc one flat region grows up via brk / mmap you allocate, you free no GC no write barriers no hidden classes you own object lifetime .bss zero-init globals .data init-ed globals .rodata read-only constants .text · YOUR CODE fixed at link time · never changes V8-HOSTED JS · generational + JIT NATIVE + JS STACK interleaved C++ and JIT frames YOUNG GEN · semi-spaces scavenger · short-lived objects OLD GEN mark-compact · long-lived CODE SPACE · JIT OUTPUT generated at runtime · discardable READ-ONLY SPACE builtins · snapshot data BYTECODE SPACE BytecodeArrays from Ignition LARGE OBJECT SPACE objects bigger than a page C++ HEAP V8 itself · host runtime · libuv where the engine code lives C: 4 sections + 1 heap · JS: 3 GC spaces + JIT code + bytecode + RO + large + native
CJavaScript
Code is in .text, fixed at link timeCode is generated at runtime, lives in code space, can be discarded
Heap is one flat regionHeap is generational with multiple spaces
You free memoryGC reclaims memory, with write barriers tracking pointers
Strings are byte arrays you manageStrings are heap objects with hidden classes
Globals have fixed addressesGlobals are properties of a global object, looked up via ICs
Calls use one ABI3-4 ABIs in use (JS, builtin, runtime, C++)
Process startup just loads sectionsProcess startup deserializes a snapshot of pre-built JS world
!
Lock this in
A C process is "your code, plus a tiny runtime." A JS process is "the engine, with your code as data inside it."

SECTION 19What the engine does NOT do

The engine does not:

  • Type-check your code in any language sense (no int x = "hello" rejection)
  • Tell you about logic bugs
  • Care what file your code came from
  • Distinguish TypeScript from JavaScript (TS is gone before the engine sees it)
  • Know about your bundler, your framework, your build chain

The engine does:

  • Parse, interpret, compile, optimize, deoptimize, GC
  • Watch your runtime behavior and adapt
  • Throw runtime errors when operations fail (undefined is not a function)

The host does:

  • Fetch sources (HTTP, fs)
  • Provide APIs (DOM, fetch, fs, setTimeout, console)
  • Drive the event loop
  • Manage threads (web workers, worker_threads)

SECTION 20The shortest accurate version

end-to-endat runtime · adaptive · continuous
JS source text bytes
  → tokens
  → AST
  → BytecodeArray + FeedbackVector
  → interpreted by Ignition's threaded interpreter (collecting type feedback)
  → Sparkplug machine code (warm)
  → Maglev machine code (hot)
  → TurboFan machine code (very hot)
  → CPU executes
  → possibly deoptimized back to bytecode
  → repeat

Throughout, the GC manages memory generationally with write barriers,
the event loop schedules tasks and microtasks,
and modules link statically (ESM) or via require calls (CJS).
!
Lock this in
C is translated once, on the developer's machine. JavaScript is translated continuously, on the user's machine, and gets faster as it runs.

The engine is a compiler, an interpreter, a profiler, an optimizer, a deoptimizer, a memory manager, an event-loop participant, and a host of pre-built built-ins — all working together so that this:

JavaScript
console.log("Hello");

eventually becomes the same six bytes hitting write() that a C program would produce.

Just by a much, much longer road.

Back to JIT-Atlas