Ignition: Bytecode and the Interpreter

Here is something most people never realize they can do. Your JavaScript gets rewritten into a real, inspectable language before it runs, and you can print that language with a single flag. Save this as add.js:

function add(a, b) {
  return a + b;
}
add(2, 3);

Then run it through Node with a V8 flag turned on:

node --print-bytecode --print-bytecode-filter=add add.js

Out comes something like this. I have trimmed the addresses and hex opcodes and added the comments, so treat it as a cleaned-up view rather than literal V8 output:

[generated bytecode for function: add]
Parameter count 3
Register count 0
   0 : Ldar  a1        ; load the 2nd argument (b) into the accumulator
   2 : Add   a0, [0]   ; add the 1st argument (a); [0] is a feedback slot
   5 : Return          ; hand back the accumulator

That is not your source, and it is not machine code either. It is bytecode, and between the moment you hit save and the moment add runs, V8 turned your two-line function into that little list of instructions. A component called Ignition then executes the list. The pipeline overview named these pieces and the parsing lesson covered how source becomes a tree. This lesson is about the box in the middle that almost nobody looks at: the instruction set your code actually becomes, and the small machine that runs it.

Bytecode is a smaller language

Bytecode is a compact, linear list of simple instructions for a virtual machine. Each instruction does one small thing: load a value, add two values, store a result, jump, return. It sits between two worlds. It is lower level than your source, because a + b has been broken into explicit load-and-add steps. It is higher level and more portable than machine code, because it is not tied to any CPU. The same bytecode runs on your laptop’s chip and on a phone.

That in-between position is the whole point. Two properties fall out of it, and they are the reason bytecode exists at all.

It is cheap to generate. Walking a syntax tree and emitting a flat list of ops is fast, far faster than producing good machine code. So the engine can get from parsed source to something runnable almost immediately, which is what makes startup feel instant.

It is small. A tree of objects with pointers everywhere is bulky. A tight list of one-byte-ish opcodes with a few operands is not. When Ignition first shipped, its bytecode measured roughly a quarter to a half the size of the equivalent baseline machine code, and it trimmed the memory of a typical browser tab by a few percent. Those exact figures are old and specific to that launch, so do not quote them as law. The direction is the durable part: bytecode is a lot smaller than compiled code, and smaller than the tree it came from.

Because it is small and it is enough to run your program, the engine keeps the bytecode and throws the tree away.

AST: nested, bulkyAssign =Id aBinary +Id bBinary *Id cNum 2freed once bytecode existscompilebytecode: linear, compact, keptLdar bStar r0Ldar cMulSmi 2Add r0Star athis is what Ignition runs
The syntax tree is a bulky 2D structure. Bytecode is a compact 1D list. Once the list exists, the tree is freed and the bytecode is what runs.

Why not just run the tree?

The engine already built a syntax tree while parsing. It could, in principle, walk that tree and execute it directly: visit the + node, evaluate its left child, evaluate its right child, add them. That is called a tree-walking interpreter, and plenty of small languages use one. V8 does not, and the reasons are worth understanding because they explain what bytecode buys.

A tree is scattered across memory. Each node is a separate heap object holding pointers to its children, so walking it means chasing pointers all over the place, which the CPU hates. A bytecode stream is one contiguous run of bytes you read straight through, top to bottom. Modern processors are extremely good at that and terrible at pointer-chasing, so the same logic runs far faster as a flat list than as a tree walk.

A tree is also big and stays big. If you executed the AST directly, you would have to keep the whole tree alive for as long as the function might run, which is basically forever. Compiling to bytecode lets the engine drop the tree entirely, as the garbage collector reclaims it, and hold only the compact bytecode. Less memory, and the memory you keep is the cheap kind.

And a tree carries baggage the runtime does not need. It records structure that mattered for parsing but is dead weight for execution. Lowering to bytecode is a chance to strip that down to just the operations, resolve where each variable lives, and hand every later stage one clean, uniform thing to read.

That last point matters more than it sounds. The bytecode is not just Ignition’s input. It is the shared source of truth for the whole engine. When a hot function tiers up to a compiler, that compiler reads the same bytecode, not your original text. Parsing and scope resolution were done once and nobody repeats them.

The register machine

So what actually runs the bytecode? A tiny virtual machine, and its design shows up in every line of that instruction list. Ignition is a register machine. It has a set of numbered slots called registers (r0, r1, and so on) that hold a function’s local values and temporaries, plus the arguments (a0, a1). Those registers are not CPU registers. They live in the function’s own stack frame, so each call gets its own set.

Then there is one special register that does most of the work: the accumulator. Most bytecodes take the accumulator as an implicit input and write their result back into it. Look again at the dump from the opening. Ldar a1 means “load a1 into the accumulator”. Add a0 means “add a0 to the accumulator, leave the result in the accumulator”. The accumulator is never named as an operand because it is always there, assumed.

Why build it this way? Because it makes the bytecode smaller. If every instruction had to name both a source and a destination, every instruction would be wider. By letting one register be implicit, the common case (do a thing to the running value) needs to name only the other operand. And since JavaScript expressions are mostly chains evaluated left to right, the intermediate result tends to sit in the accumulator exactly where the next instruction wants it, so you rarely have to shuffle it in and out.

register file (stack frame)r0 = 5r1 = …accumulator (a CPU register)acc = 6the running resultAdd r0acc = r0 + accreads r0reads accaccumulator= 11writessame register, reused by the next instruction
Registers live in the stack frame. The accumulator is the implicit hub: most ops read it, operate, and write the result straight back into it.

A line of code, one op at a time

Take a real expression and watch it become bytecode. Nothing exotic:

a = b + c * 2;

Precedence says c * 2 happens first, then b + the result, then store into a. The compiler walks the tree and emits ops that carry that order out on the register machine. Here is the mapping, colored so you can see which piece of source produced which instructions. Again, this is a readable illustration, not a literal V8 dump:

sourcea = b + c * 2bytecodeLdar baccumulator ← bStar r0r0 ← accumulator (save b)Ldar caccumulator ← cMulSmi 2accumulator ← accumulator * 2Add r0accumulator ← r0 + accumulatorStar aa ← accumulator (store result)
Each part of the expression lowers to a couple of register-machine ops. Load a value, park it, compute the other side, combine, store the result.

Six instructions, no cleverness, each one obvious on its own. That is the texture of bytecode: not a magic recipe, just your expression spelled out as small steps a machine can chew through one at a time.

You can step through those exact instructions below. The register file and accumulator are drawn as boxes, and each press runs the next op and updates the values, so you can watch the machine evaluate a = b + c * 2 with b = 5 and c = 3. The bytecode is hand-authored and the values are canned; this is a simulation of the register machine, not a live V8 session:

interactiveStep a bytecode program through the register machine

The interpreter loop

Now the part that gives interpreting both its speed and its cost. Ignition runs a loop, and it is the same loop every interpreter runs: fetch the next bytecode, decode it to figure out which operation it is, dispatch to the small chunk of code that performs it (called a handler), then advance to the next bytecode and go again. In V8 those handlers are themselves generated ahead of time, one per opcode, so dispatch is fast. But the loop still turns once per instruction.

fetchread next bytecodedecodewhich op is itdispatchrun its handleradvancemove to nextonce perbytecode
For every bytecode, Ignition fetches, decodes, dispatches to a handler, and advances. Cheap per turn, but the turn happens once per instruction, over and over.

Run a function a handful of times and that per-instruction overhead is nothing. Run the body of a loop ten million times and you pay the fetch-decode-dispatch tax ten million times, and it starts to dominate. That is exactly the situation compiled machine code fixes: there is no loop and no decode, the CPU just runs your operations directly. Which is why V8 does not stop at the interpreter for hot code. The pipeline lesson walks the climb from Ignition up through the compilers; the takeaway here is that the interpreter’s simplicity is both its strength (instant, tiny, cheap) and its ceiling (a decode tax on every op).

Why interpret first at all

If compiled code is faster, why run an interpreter at all instead of compiling everything up front? The overview lesson answered this in full, so just the short version as it relates to bytecode: generating bytecode is cheap, so you start fast; most functions run too rarely to be worth compiling, so you would waste the effort; and running the bytecode is how the engine watches your code and learns the types it needs before it can compile anything good. The interpreter is not a consolation prize. It is where the information the optimizer depends on comes from.

Which is why the bytecode does not just run and vanish.

The bytecode is the thing that lasts

Two more jobs make the bytecode the most important artifact in the engine, not a throwaway step.

First, it is where feedback lives. Remember the [0] in the opening dump, sitting on the Add instruction. That is an index into a side table called the feedback vector, one per function. As Ignition runs, instructions that could benefit from specialization (property loads, calls, arithmetic) record what they actually saw into their slot: this add only ever got numbers, that property load always saw the same kind of object. The feedback is physically attached to the bytecode, slot by slot. When a function gets hot, the optimizer reads the bytecode and its feedback together, and that is what lets it gamble on the common case. The details of how those slots fill and what happens when they see too many shapes are their own lessons, on inline caches and type feedback and speculation.

Second, it is the floor everything stands on. Every faster tier is built from the bytecode and can fall back to it. When the optimizer’s bet turns out wrong (someone finally calls your number-crunching function with a string), the engine throws away the fast machine code and resumes in Ignition, running the same bytecode, which handles every case correctly. That is deoptimization, and it is only safe because the bytecode never went anywhere. The interpreter is always sitting there, ready, as the correct baseline.

optimized machine codereads the bytecode and its feedbackbytecodeLdaNamedProperty x [0]Add r0 [1]Returnfeedback vector (the slots)[0] shape: Point[1] types: number, numberrecorded while interpretingIgnition interpreterthe always-available baselineruns itbuilt fromdeopt: broken guess falls back down
The bytecode plus its feedback slots is the durable center. Faster tiers are built from it, and a broken optimization deopts straight back down to it.

There is one more payoff, and it touches every page you load. Because bytecode is the expensive-to-produce result of parsing and compiling, V8 can cache it. The first time a script runs, the engine parses and compiles it. It can then stash the resulting bytecode, both in memory and, through the browser, on disk. Next time the same script loads, V8 restores the bytecode and skips straight past the parser. A warm page load does far less work than a cold one for exactly this reason.

cold load (first visit)sourcescan + parsebytecodecode cachestored for laterwarm load (repeat visit)sourcescan + parseparse skippedbytecodefrom cachecode cache
Cold load: parse the source, then compile to bytecode. Warm load: restore the cached bytecode and skip the parse entirely.

Same idea, other engines

Bytecode and an interpreter to run it are not a V8 invention. Every major engine does the same two things under different names.

What this means for your code

Nearly nothing you type is written for the interpreter, and that is the right outcome. You do not hand-tune bytecode and you should not try. What this layer is good for is a correct mental model, which saves you from a few real mistakes:

  • Startup cost is parse-and-compile, and it is cacheable. The reason a warm reload beats a cold one is the bytecode cache. The reason a big bundle starts slowly is that all of it must be scanned and turned into bytecode before your code runs. That is the mechanical link between bundle size and a slow start, covered in Core Web Vitals and parsing. Ship less, split the rest.
  • The optimizer reads your bytecode plus its feedback, not your source. Fast code comes from giving those feedback slots something consistent to record, which is a story about object shapes and types in the next lessons. You get there by writing plain, predictable functions, not by chasing tricks.
  • You can actually look. node --print-bytecode --print-bytecode-filter=yourFn is a real window into what a function became. You will not read it daily, but seeing your code as a dozen small ops demystifies a lot, and it is genuinely useful when you want to understand why two ways of writing the same thing behave the same.
  • Do not cargo-cult the opcodes. The instruction names, the exact ops, the tier thresholds: all engine-internal and all subject to change. The concepts (compact bytecode, a register machine, feedback attached to it, an interpreter as the floor) are stable. Learn those.

Summary

  • V8 compiles your parsed source into bytecode, a compact linear instruction set for a virtual machine, sitting between your source and machine code. It is cheap to generate (fast startup) and small (the syntax tree gets thrown away once it exists).
  • Ignition is the interpreter that runs the bytecode. It is a register machine: values live in numbered registers in the function’s stack frame, and one implicit accumulator carries the running result through an expression, which keeps the bytecode small.
  • Running bytecode beats walking the syntax tree directly: a flat stream is faster for the CPU to read and far smaller to keep in memory, and it gives every later stage one clean thing to work from.
  • The interpreter runs a fetch-decode-dispatch loop, once per bytecode. That is cheap per op but adds up in hot loops, which is why hot code gets compiled instead.
  • The bytecode is the durable center of the engine: type feedback is attached to it in slots, every faster tier is built from it, and a broken optimization deopts straight back to running it. It can also be cached so a repeat load skips parsing.
  • Names and instruction sets are engine-specific and change. SpiderMonkey and JavaScriptCore have their own bytecode and interpreters built on the same idea.

What this means for your code: you never write bytecode, but knowing your functions become small, cached, feedback-carrying instruction streams explains why startup is a parse bill, why warm loads are faster, and why the optimizer rewards consistent, boring code.