How V8 Ignition Bytecode Actually Works
JSC Decompiler Team
Engineering
Before V8 runs a single line of your JavaScript, it compiles that source into bytecode — a compact instruction set executed by an interpreter called Ignition. This is the layer that actually runs most of the code in Chrome, Node.js, and Electron. If you've ever looked at a .jsc file or run node --print-bytecode and wondered what Ldar and Star mean, this article explains the instruction set itself: the register machine, the accumulator, the opcode families, and how a line of JavaScript lowers into bytecode.
This is the companion piece to our writeup on how a V8 bytecode decompiler works. That post covers the reverse direction — parsing a serialized .jsc back into JavaScript. This one is about the forward direction: what the bytecode is as an instruction set, and why it's shaped the way it is.
Where Ignition Sits in V8
V8 is a tiered engine. Source text is parsed into an AST, and from that AST the BytecodeGenerator emits Ignition bytecode. Ignition is a bytecode interpreter: it walks the instruction stream one opcode at a time and executes handlers for each. There is no machine code at this stage — execution is a dispatch loop.
Ignition shipped as V8's default interpreter around V8 5.9 (Chrome 59, 2017). Before it, V8 compiled straight to machine code with the full-codegen and Crankshaft pipeline, which used a lot of memory to hold native code for functions that ran only a few times. Bytecode is much denser than machine code, so Ignition cut memory pressure and gave V8 a clean intermediate representation to build optimization on top of.
Above the interpreter is TurboFan, the optimizing JIT. When a function runs often enough to be considered hot, V8 feeds its bytecode and the runtime type feedback it has collected into TurboFan, which produces specialized machine code. Newer V8 versions add intermediate tiers between the two: Sparkplug (a fast, non-optimizing baseline compiler that turns bytecode into straight-line machine code, added in V8 9.1) and Maglev (a mid-tier optimizing compiler, roughly V8 11.x). Every one of these tiers consumes the same Ignition bytecode as its input. The bytecode is the source of truth; the compilers are accelerators sitting on top of it.
Key point: Bytecode is generated lazily and per function. V8 does not compile your whole file up front — a function's BytecodeArray is produced the first time the function is about to run (or eagerly for the top-level code). This is exactly why a serialized .jsc is a collection of per-function bytecode arrays plus a shared constant pool.
A Register Machine, Not a Stack Machine
Most bytecode VMs people have seen — the JVM, CPython, WebAssembly — are stack machines. Operands are pushed onto an evaluation stack, an operation pops its inputs and pushes its result. a + b becomes something like push a; push b; add. Simple to generate, but it means a lot of instructions, and every one of them implicitly shuffles the stack.
Ignition is a register machine. Instead of an evaluation stack, each function has a flat register file — r0, r1, r2, and so on — mapped directly onto slots in the interpreter's stack frame. These aren't hardware registers; they're fixed offsets into the frame. Local variables and temporaries live here. The register count for a function is fixed at compile time and stored in the BytecodeArray header (you see it printed as Register count under --print-bytecode).
Function parameters get their own register range, conventionally shown as a0, a1, and so on in disassembly, living below the frame pointer along with the receiver (this) and the closure. So in a function f(a, b), a is a0 and b is a1. The decompiler's job of recovering variable names starts here: the register-to-variable mapping is what gets turned back into a and b.
Register-based encoding pays off in density. Because an operation can name its source and destination registers directly, you skip the push/pop bookkeeping a stack machine needs, so you emit fewer instructions for the same work. Fewer instructions means fewer trips through the interpreter's dispatch loop, and dispatch is the dominant cost in any interpreter. That was one of the explicit motivations behind Ignition's design.
The Accumulator: An Implicit Operand
Ignition isn't a pure register machine. It has one special register, the accumulator, that acts as an implicit source and destination for most instructions. Rather than every opcode encoding both a source and a destination register, most operations read from the accumulator, write to the accumulator, or both. This shrinks the encoding further — a great many instructions need to name only one explicit register because the other operand is understood to be the accumulator.
The two workhorse instructions for moving values in and out of it are Ldar (“load accumulator from register”) and Star (“store accumulator to register”). Ldar r3 copies r3 into the accumulator; Star r3 copies the accumulator into r3. The rest of the “Lda” family loads other kinds of values into the accumulator: LdaSmi loads a small integer immediate, LdaConstant loads an entry from the constant pool, and dedicated opcodes load common singletons: LdaZero, LdaUndefined, LdaNull, LdaTrue, LdaFalse.
Arithmetic follows the same convention. Add r2, [slot] means “add register r2 to the accumulator and leave the result in the accumulator.” The accumulator is the left operand, the named register is the right operand, and the result overwrites the accumulator. Same for Sub, Mul, Div, Mod, the bitwise ops, and the comparisons (TestEqual, TestEqualStrict, TestLessThan, and friends), which leave a boolean in the accumulator.
Why an accumulator at all? It's a middle ground between a pure stack and a pure register machine. Expression evaluation naturally threads a single “current value” from one operation to the next; dedicating a register to that value means most opcodes carry one fewer operand. You pay for it with explicit Ldar/Star moves when a value has to be parked in a named register, but on balance the encoding comes out smaller.
Anatomy of an Opcode
Every Ignition instruction is a single-byte opcode followed by zero or more operands. The opcode byte selects a handler; the operands are laid out in the bytes that follow, with sizes fixed per operand type. Common operand types include:
- Register — an index into the frame's register file, encoded as a (usually negative) offset. Shown as
r0,a1, etc. - Immediate — a signed integer baked into the instruction, as in
LdaSmi [42]. - Index — an unsigned index into the constant pool (
LdaConstant [3]) or into the feedback vector (a “slot”). - Register list — a base register plus a count, used for passing a run of consecutive registers as call arguments.
By default each operand is one byte, which caps a register index or constant index at 256 values. Most functions never exceed that. When they do, Ignition uses prefix opcodes: Wide promotes the following instruction's operands to 16-bit, and ExtraWide promotes them to 32-bit. The prefix is itself a one-byte opcode that precedes the real instruction, so Wide LdaSmi [40000] is the wide-operand form of LdaSmi. This keeps the common case tight (one-byte operands) while still handling large frames and constant pools.
The exact opcode set, and the numeric value each mnemonic maps to, is not stable across V8 versions. Opcodes get added, removed, and renumbered between releases. V8 12.x defines roughly 200 opcodes, V8 13.x around 208, V8 14.x around 211–212. That instability is precisely why decompiling a .jsc requires knowing the producing version — more on that below and in our note on the invalid bytecode header error.
The Constant Pool and Feedback Slots
Bytecode instructions are small and fixed-width, so they can't carry arbitrary literals inline. Anything that isn't a small integer or a well-known singleton lives in the constant pool — a per-function array of heap objects attached to the BytecodeArray. String literals, heap numbers (doubles), regex literals, object and array boilerplate descriptions, property name references, and nested function templates (SharedFunctionInfos) all sit in the pool. Instructions reference them by index: LdaConstant [0] loads pool entry 0 into the accumulator.
The second array of indices you'll see in disassembly points into the feedback vector. Instructions that can benefit from runtime type information — property loads and stores, calls, arithmetic, comparisons — carry a feedback slot index, printed as the trailing [n] operand. That slot is where V8 records what it has actually seen at runtime: the shapes (hidden classes / maps) of objects flowing through a property access, whether an Add only ever saw small integers, which function a call site actually invoked. This is the inline-cache mechanism, and the slot index is how a given bytecode finds its cache.
A named property load like obj.x compiles to something in the GetNamedProperty family (older versions call it LdaNamedProperty): it takes the object register, a constant-pool index for the property name, and a feedback slot. On the first execution the slot is empty and V8 does a full generic lookup; it then records the object's map and the property offset, so subsequent runs through that slot hit the cache. When the interpreter later hands the function to TurboFan, this accumulated feedback is what lets the optimizer speculate — “this access always saw a map with x at offset 12, compile a direct load and guard the map.”
Why this matters for reversing: the feedback slots are runtime scaffolding, not program logic. A decompiler reads the constant-pool references (real string and number literals, real property names) and discards the slot indices, which carry no source-level meaning.
The Main Opcode Families
You don't need to memorize all ~200 opcodes to read bytecode. They cluster into a handful of families, and once you recognize the families the disassembly reads like a slightly verbose assembly.
Loads and stores
Ldar/Star move between the accumulator and registers. Mov copies register to register without touching the accumulator. Global variable access uses LdaGlobal/StaGlobal. Closure variables captured from an outer scope go through context slots with LdaContextSlot/StaContextSlot.
Property access
Named properties (obj.x) use the GetNamedProperty/SetNamedProperty family; keyed properties (obj[k]) use GetKeyedProperty/SetKeyedProperty. Older V8 spells these LdaNamedProperty and LdaKeyedProperty. Object and array literals are built by CreateObjectLiteral and CreateArrayLiteral, which reference boilerplate in the constant pool.
Calls and construction
Calls come in specialized shapes to keep the common cases tight. There are fixed-arity forms — CallUndefinedReceiver0, CallUndefinedReceiver1, and so on — plus general forms like CallProperty, CallAnyReceiver, and CallWithSpread. They take a callee register, a register list for arguments, and a feedback slot. Construct handles new. Runtime and V8-internal helpers are reached via CallRuntime.
Control flow
There are no structured blocks in bytecode — only jumps. Unconditional Jump, conditional JumpIfFalse/JumpIfTrue (which test the accumulator), null/undefined-specific variants, and JumpLoop, which is a backward jump that also serves as the interpreter's hot-loop / on-stack-replacement trigger. Reconstructing if, for, and while from these jump targets and back-edges is a core job of the decompiler. Return ends the function with the accumulator as the return value.
A Function, Lowered
Here is a small function and its approximate Ignition bytecode, annotated. This is illustrative — exact mnemonics, operand encodings, and slot numbers shift between V8 versions, so treat it as the shape of the output rather than a byte-exact transcript. Reproduce the real thing for your Node version with node --print-bytecode (or d8 --print-bytecode).
// Source
function clamp(x, max) {
if (x > max) {
return max;
}
return x;
}
// Approximate Ignition bytecode (illustrative)
// Parameters: a0 = x, a1 = max
Ldar a1 // acc <- max
TestGreaterThan a0, [0] // acc <- (x > max)? using feedback slot 0
JumpIfFalse [8] // if acc is false, skip ahead to the tail
Ldar a1 // acc <- max
Return // return acc (max)
Ldar a0 // acc <- x (jump target)
Return // return acc (x)Read it top to bottom. The comparison loads one operand into the accumulator, then TestGreaterThan a0, [0] compares it against the other, leaving a boolean in the accumulator and recording feedback in slot 0. JumpIfFalse consumes that boolean: if the condition was false, control skips the return max arm and falls through to the return x tail. Notice there is no else in the bytecode — the source if became a forward conditional jump, and that jump target is exactly what a decompiler pattern-matches back into an if statement.
Two things survived compilation cleanly: the control-flow structure (recoverable from the jump) and the parameter references (a0, a1). What did not survive: the names x and max as such (they're just register positions now) and, if there had been any, comments and formatting. That's the fundamental asymmetry of bytecode: logic and literals are preserved, cosmetic identifiers are not.
From Live Bytecode to a .jsc File
Everything so far lives in memory while V8 runs. A .jsc file appears when that in-memory representation is serialized to disk through V8's code cache. bytenode is the common tool for this: it compiles a source file, then calls V8's serializer (v8::ScriptCompiler::CachedData) to write out the BytecodeArrays, the constant pools, scope metadata, and handler tables as one binary blob. The bytecode you read under --print-bytecode is the same bytecode that ends up in the file.
This is why .jsc files are version-locked. Two things move under you between V8 releases: the instruction set itself (opcodes added, removed, renumbered) and the serialization format that wraps it (header layout, flag hashes, the serializer's own opcodes). A file written by Node 20's V8 will not load in Node 22's V8, and a decompiler must select the right parser for the producing version before it can decode a single instruction. You can identify that version from the header with our V8 version analyzer, and the full parse-to-JavaScript pipeline is covered in how a V8 bytecode decompiler works. If you just have a file and want the source back, the step-by-step is in how to decompile .jsc files.
Reproducing This Yourself
The fastest way to build intuition is to dump bytecode for small snippets and watch how the disassembly changes as you edit the source. Node exposes V8's flag directly:
# Dump bytecode for an inline snippet
$ node --print-bytecode -e "function clamp(x, m){ return x > m ? m : x; } clamp(1,2)"
# Filter to a single function to cut the noise
$ node --print-bytecode --print-bytecode-filter=clamp app.js
# d8 (the V8 shell) takes the same flag
$ d8 --print-bytecode script.jsThe header block before each listing tells you the frame shape: Parameter count, Register count, and Frame size. Each instruction line shows a bytecode offset, the raw operand bytes, the mnemonic, and its operands. The leading S> markers are source position annotations — they map a bytecode offset back to a byte offset in the original source, which is how stack traces recover line numbers.
Reading Bytecode vs. Recovering Source
Knowing the instruction set is enough to read a short function by hand. It does not scale. A real .jsc holds dozens or hundreds of functions, nested closures, boilerplate-driven literals, and jump tables that only make sense once you reconstruct the control-flow graph — and you have to speak the exact V8 version that produced it. That's where reading stops and decompilation starts.
JSC Decompiler takes a .jsc, detects the V8 version from its header, decodes every BytecodeArray, rebuilds control flow, and emits readable JavaScript — across Node 8 through 25 and Electron 17 through 38. Upload a file at jscdecompiler.com to go from bytecode straight to source, or read the decompiler internals writeup if you want to understand how the reverse direction is built.
Try JSC Decompiler Free
Upload a .jsc file and get readable JavaScript back in seconds. No signup required for the free tier.