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:
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:
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
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
0x660x750x6ESo your source file is not "code" to the engine yet. It is just text data.
66 75 6E 63 74 69 6F 6E 20 61 64 64 28 61 …
function add(a…
The engine reads these text bytes and interprets them according to the ECMAScript grammar.
SECTION 03The full pipeline
.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.node --print-bytecode -e "function add(a,b){return a+b}; add(1,2)"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.
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.
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.
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:
- Looks up
pushonarr.__proto__(Array.prototype) - Finds a function whose code pointer points into V8's pre-compiled builtin code
- Calls it directly — same
callinstruction 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.
V8 in init mode: building the world from scratch
"Init mode" isn't a configuration toggle — it's a separate small executable called mksnapshot that runs once, when V8 itself is being compiled. Its entire job is to build a complete JavaScript world the slow way, capture the result, and exit.
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.
What "the initial JavaScript world" contains
The bootstrap creates everything that exists on globalThis before your code runs. Specifically:
true, false, null, undefined, the empty string, the empty FixedArray, every prototype Map. Roots are how the GC and serializer find everything else.Object, Array, Function, Promise, Map, Set, Error and ~30 more constructors. When you write new Array(), V8 looks the constructor up here.Object.prototype, Array.prototype, Function.prototype, etc. Each with all its method properties already attached.Object.prototype, the Map for an empty plain object, the Map for an empty Array, and so on.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.
// 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.
node invocation — inherits its frozen output. When you start Node, you're not building a JavaScript world. You're loading one.Compile builtins: from Torque to machine code
"Compile builtins" means: take the source code of Array.prototype.push, Object.assign, Promise, JSON.parse, and ~thousands of others, and produce real x86-64 (or ARM64) machine code that V8 can call directly. The source isn't C++ — it's a V8-invented DSL called Torque.
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
A real Torque builtin
This is roughly what Array.prototype.indexOf looks like in Torque (simplified):
// 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++?
SmiUntag a HeapObject in Torque source.add; HeapObject loads compile with the correct tag-clearing..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.
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.Serialize the heap: live objects → flat blob
After steps 1 and 2, V8's heap is fully populated — Maps, JSFunctions, Strings, Code objects, the Roots, the Native Context. Serialization walks this graph and writes it out as a single binary blob with every pointer translated into a portable form.
What's in the heap at this point
By the end of bootstrap + builtin compilation, the heap looks roughly like this:
The serialization walk · step by step
true, false, undefined, every prototype Map, the empty FixedArray. Everything reachable in the JS world is reachable from the Roots.kJSObject, kCode, kString, kMap), then writes its fields. Pointer fields cause referenced objects to get queued.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.
node_modules's native bindings sometimes need recompiling — same constraint applies more loosely.Embed in node binary: blob → executable
After step 3 you have a 1-3 MB binary blob sitting on disk: snapshot_blob.bin. Step 4 takes those bytes and links them directly into the final Node executable as static data. When you install Node, the JS world is bundled inside the binary — there is no separate snapshot file to ship.
From blob bytes to executable bytes
The four sub-stages · in detail
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.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.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.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:
# 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?
.rodata when it loads the binary's segments at process start. No additional open() + read() needed..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.mmap the snapshot: lazy, zero-copy loading
When you run node, V8 doesn't read() the snapshot bytes into a buffer. It uses mmap — a syscall that maps file (or binary segment) bytes directly into the process's virtual address space. No copying, no allocation. The OS lazily loads pages from disk only when V8 actually touches them.
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:
The runtime sequence · what happens when you run node
Why mmap beats read
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).Inspecting it yourself
# 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
Fix up pointers: relocation in microseconds
The snapshot was serialized at build time, but at runtime it gets mapped into a different virtual address space — courtesy of ASLR, every Node process gets a random base address. Any pointer in the snapshot that referred to an absolute address is now wrong. V8 walks the relocation table and patches them. The whole pass takes microseconds.
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:
$ 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:
Array.prototype.push may directly reference the Array Map..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:
The fixup loop · step by step
blob_base.(offset, kind, target) entries. V8 reads it linearly.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.target is an index into this table; V8 writes the indexed pointer to the patch site.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:
Result: a few hundred microseconds at most. Step B is essentially "free" compared to the milliseconds spent elsewhere in startup.
Fully-formed JS world: ~50ms instead of ~500ms
After mmap (Step A) and relocation (Step B), V8 has a complete, working JavaScript universe. Object.prototype exists with all its methods. Array exists with all its compiled machine code. Promise is constructable. The first line of your code runs in a world that was prepared months ago, when V8 was built.
What's available the moment startup completes
The first JS line you run can call any of these
This is the operational meaning of "ready." Try this script:
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
Why this matters in practice
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.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
# 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
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:
"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.14is 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.
function add(a, b) { return a + b; }
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):
function rarely_called() { // 500 lines } function called_immediately() { // 10 lines } called_immediately();
V8's strategy:
When rarely_called is eventually invoked, V8 goes back, full-parses it, then generates bytecode.
This is why bundlers care about tree-shaking and code-splitting — every byte of unused JS still costs at least scanning + preparsing.
--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):
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:
function add(a, b) { return a + b; }
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:
Decoded:
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 opcodeWait — 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 parameteraa1= the second declared parameterb
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.
8.3 Real bytecode for console.log("Hello")
Real output for:
function greet() { console.log("Hello"); }
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:
Walking 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 ReturnNotice 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
function getX(p) { return p.x; }
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]:
a0is the parameterp[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:
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:
; <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:
| Role | Reg | Holds |
|---|---|---|
| Accumulator | rax | Result of most operations |
| Bytecode array | r12 | Pointer to BytecodeArray's bytes |
| Bytecode offset | r9 | Current pc (offset, not address) |
| Dispatch table | r15 | Pointer to handler table |
| Frame pointer | rbp | Standard x64 frame pointer |
| Context | rsi | Current 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.
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
9.4 What happens during Ldar a1
Concretely, when V8 executes Ldar a1 from our add example:
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 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 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:
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:
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 }:
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):
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
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:
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}:
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.
--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:
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:
; 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_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
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:
- Hits the
test dl, 0x1check - dl's low bit is 1 (it's a string pointer, a HeapObject)
- jnz fires → jumps to deopt site
- 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"
- Execution continues in Ignition
- Type feedback updates: now sees [String, String] too
- 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:
| Item | Reg | Notes |
|---|---|---|
| Target function | rdi | The JSFunction being called |
| New target | rdx | undefined unless new was used |
| Arg count | rax | How many args were actually given |
| Context | rsi | The Context (for globals etc.) |
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:
; 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:
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 13Source maps and TypeScript
When you write TypeScript, JSX, or use any bundler, the engine never sees your original code:
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.
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
export const PREFIX = "[log] "; export function format(msg) { return PREFIX + msg; }
import { format } from './util.js'; export function add(a, b) { console.log(format("adding")); return a + b; }
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:
After linking, the binding table is:
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
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:
export let count = 0; export function increment() { count++; }
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:
const PREFIX = "[log] "; function format(msg) { return PREFIX + msg; } module.exports = { PREFIX, format };
const { format } = require('./util.js');
What require('./util.js') does:
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.exportsEach 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.
- Live bindings: imports stay synchronized with exports
- Tree-shakeable by bundlers (statically analyzable)
- Top-level await works naturally
- Cycle handling is well-defined
- No conditional
importat top level (useimport()) - All imports resolved before execution begins
require() blocks on disk I/O, runs the module body, caches and returns the module.exports value.- Conditional
requireworks anywhere - Trivially synchronous mental model
- Established Node ecosystem
- Values are copies, not live bindings
- Hard to tree-shake (analysis is dynamic)
- Cycles cause partially-loaded exports
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 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
15.2 Allocation: the bump allocator
For:
const obj = { x: 1, y: 2 };
Allocation in young gen is just:
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:
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:
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.
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:
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:
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.
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.
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 }
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 17End-to-end: console.log("Hello") from text to terminal
Putting everything together for one line.
17.1 The text on disk
console.log("Hello");
UTF-8 bytes:
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: [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:
17.5 Bytecode
For the wrapping function (modules become functions internally):
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:
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 return17.7 If this is in a hot loop
After Ignition has executed it a few thousand times with stable types:
- Sparkplug compiles the bytecode 1:1 to machine code, removing dispatch overhead
- Maglev may inline the property accesses based on feedback
- TurboFan can inline
console.logitself if its identity is stable, fold "Hello" as a constant, and emit:
; 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):
1"Hello\n"6Below that syscall, it's the same OS-level path the C document covered.
SECTION 18Memory layout vs C: side-by-side
| C | JavaScript |
|---|---|
Code is in .text, fixed at link time | Code is generated at runtime, lives in code space, can be discarded |
| Heap is one flat region | Heap is generational with multiple spaces |
| You free memory | GC reclaims memory, with write barriers tracking pointers |
| Strings are byte arrays you manage | Strings are heap objects with hidden classes |
| Globals have fixed addresses | Globals are properties of a global object, looked up via ICs |
| Calls use one ABI | 3-4 ABIs in use (JS, builtin, runtime, C++) |
| Process startup just loads sections | Process startup deserializes a snapshot of pre-built JS world |
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
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).
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:
console.log("Hello");
eventually becomes the same six bytes hitting write() that a C program would produce.
Just by a much, much longer road.