completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Bytecode & interpreters

Learn why engines run bytecode first, how V8 Ignition reads it with an accumulator interpreter, and how other engines start executing.

By the end, you can
  • 01
    Explain why engines make bytecodeCompare walking the AST, running bytecode, and making machine code at once: how fast each starts and how much memory it uses.
  • 02
    Read a V8 listingUse --print-bytecode output to find offsets, registers, feedback slots, constants, and jumps.
  • 03
    Trace an interpreterStep through a small model with an accumulator and registers, and see how handlers run each instruction.

What bytecode is

Before a JavaScript engine runs your code, it reads it. First it splits the text into words. Then it builds a tree that shows how the code fits together. This tree is called the AST, short for abstract syntax tree. Then it works out which variables belong to which function. That step is scope analysis.

A tree is good for describing code. It is slow to run directly. So the engine first turns the tree into a simpler form. That form is bytecode.

Definition

Bytecode is a list of short instructions that the engine makes from your code. Each instruction does one small job, like "load the number 1" or "add these two values". An interpreter is the part of the engine that reads the bytecode one instruction at a time and does what each one says. The result is the same as the JavaScript rules say it must be.

Real-life analogyRecipe card and one mixing bowl

A cook does not reread the whole cookbook for every stir. She follows a short recipe card. She keeps the mixture she is working on in one bowl. She moves things in and out of labeled jars. You will meet the accumulator and registers properly in a moment.

In real life: Recipe card
In JavaScript: The bytecode: short instructions, in order
In real life: Cook
In JavaScript: The interpreter, which follows the instructions
In real life: One mixing bowl
In JavaScript: The accumulator: one place for the value being worked on
In real life: Labeled jars
In JavaScript: Registers: numbered slots for saved values, like a0, r0, and r1

This lesson uses V8 as its main example. V8 is the engine inside Chrome and Node.js. Its interpreter is called Ignition. Near the end, you will compare it with the engines in Firefox and Safari.

Here is where this lesson fits among its neighbors:

Why engines make bytecode

STARTUP + MEMORY

Machine code is the CPU's own language. The CPU is the computer's processor. An engine has three ways to run the tree:

  • Walk the tree itself. This is simple to build. But the tree is made of many small objects that point at each other. That uses a lot of memory, and moving through it again and again is slow.
  • Turn every function into machine code at once. Machine code runs fast. But making it takes time and memory. Much of the code that runs while a page loads runs only once, so that work is often wasted.
  • Turn the tree into bytecode and run that. Bytecode is small and quick to make. Later, the engine can turn only the busy code into machine code.

V8 picked the third way. Its 2016 post, Firing up the Ignition interpreter, says Ignition bytecode is about a quarter to a half the size of the simple machine code V8 made before. V8 first turned Ignition on for Android phones with little memory.

Three forms of the same code, left to right. The parser makes an AST. The engine turns the AST into bytecode, and the interpreter runs the bytecode first. Later, code that runs often can become machine code.ASTbytecodemachine codemade bythe parserthe interpreterruns it firstmade later,for busy code
The engine turns the parser's tree into bytecode and runs that first. Only busy code, which runs often, is turned into machine code later.

The table compares four ways to run code. Each row says what actually runs, and what is good and bad about it.

Four ways an engine can run code
Way to run codeWhat runsGood and bad
Walk the ASTThe interpreter moves through the tree itself.Simple to build. But the tree uses a lot of memory, and moving through it again and again is slow.
Register bytecodeShort instructions that keep values in numbered slots, called registers.Small and quick to make. V8's Ignition and Safari's JavaScriptCore work this way.
Stack bytecodeShort instructions that put values on a pile, called a stack, and take them off.Small and quick to make. Firefox's SpiderMonkey works this way.
Machine codeThe CPU's own instructions, made for one kind of CPU.Fastest to run. But it is bigger, and slow to make while a page is starting.
Faster tiers still matter

Bytecode is the first step, not the last. A tier is one level of running code, from simple to fast. V8 has four tiers: Ignition, Sparkplug, Maglev, and TurboFan. Firefox and Safari have their own. The bytecode tier starts small. While it runs, it notes what kinds of values each operation sees. These notes are called feedback. The faster tiers start from the bytecode and the feedback.

Ignition's accumulator and registers

a0, r0, acc

While Ignition runs a function, it keeps values in two kinds of places:

  • Registers are numbered slots that belong to one call of the function. They are not the CPU's own registers. They are slots in memory that Ignition sets aside. Parameters print as a0, a1, and so on. Local variables and in-between values print as r0, r1, and so on.
  • The accumulator is one special slot for the value being worked on right now. Most instructions read from it, write to it, or both.

So many instructions use the accumulator that the listing does not name it. That keeps each instruction short.

Use this table to decode a V8 listing. The left column is what you see. The right column says what it means.

The names you see in an Ignition listing
What you seeWhat it means
a0, a1The function's parameters. V8 also counts this, so a function with one parameter shows Parameter count 2.
r0 to rNRegisters: numbered slots for local variables and in-between values.
The accumulatorOne slot for the value being worked on. Most instructions use it without naming it, which keeps them short.
Constant poolA list of fixed values at the bottom of the listing, such as property names. Instructions point to an entry by its number.
[n] after an opcodeOften a feedback slot: where V8 notes what kinds of values an add, a property read, a call, or a comparison sees.

Here are three instructions, read in plain words:

  • Ldar a0 means "copy parameter a0 into the accumulator".
  • Star0 means "copy the accumulator into r0".
  • Add r0, [1] means "add r0 to the accumulator". The [1] is not a number being added. It names feedback slot 1: the place where V8 notes what kinds of values this add sees.

The name of each instruction, like Ldar or Add, is called its opcode. The values after it are its operands. The table groups the opcodes in this lesson by the kind of job they do.

V8 12.4 opcodes used in this lesson
Kind of jobOpcodes
Copy values in and outLdaZero, LdaSmi, Ldar, Star0, Star1, LdaImmutableCurrentContextSlot
Do math and compareAdd, AddSmi, MulSmi, TestLessThan. Each has a feedback slot where V8 notes what kinds of values it sees.
Read properties and callGetNamedProperty, SetNamedProperty, CallProperty1, CallUndefinedReceiver2. Older blog posts may show older names, such as LdaNamedProperty.
Jump and returnJumpIfFalse, JumpLoop, Return. Jumps go to an offset in the bytecode, not to a source line.
Make closuresCreateFunctionContext, StaCurrentContextSlot, CreateClosure, and the context reads. These keep variables that inner functions need.
Version note

The listings in this lesson come from Node 22.23.1, which uses V8 12.4.254.21-node.56. Opcode names are not part of the JavaScript standard. Older blog posts may use older names. Newer V8 versions may print different instructions.

Read --print-bytecode

REAL NODE OUTPUT

Node can print the bytecode V8 makes. The flag --print-bytecode turns printing on. The flag --print-bytecode-filter names one function, so only that function is printed.

This lesson's tests run the command below on the demo file. They check that every listing in this lesson still matches what Node prints. Memory addresses change on every run, so the listings show <addr> in their place.

Print the bytecode for one functionShell
node --print-bytecode --print-bytecode-filter=loopSum bytecode-demo.js
The demo file, bytecode-demo.jsJavaScript
function arithmetic(a, b) {  const total = a + b;  return total * 2;} function propertyCall(user) {  user.count = user.count + 1;  return user.label(user.count);} function loopSum(n) {  let total = 0;  let i = 1;  while (i < n) {    total = total + i;    i = i + 1;  }  return total;} function makeAdder(x) {  return function add(y) {    return x + y;  };} function callFree(fn) {  return fn(1, 2);} const user = { count: 1, label(value) { return "#" + value; } };console.log(arithmetic(2, 3));console.log(propertyCall(user));console.log(loopSum(5));console.log(makeAdder(4)(6));console.log(callFree((a, b) => a + b));

Line by line: a loop

Here is the bytecode for loopSum, from lines 11 to 19 of the demo file. The first lines are facts about the function, not instructions:

  • Parameter count 2: the function's this value plus its one parameter, n. V8 counts this as a parameter.
  • Register count 2: two registers, r0 for total and r1 for i.

On each instruction line, the number after @ is the offset: how many bytes from the start of the bytecode the instruction sits. Jumps go to an offset, not to a source line.

loopSum's bytecode (Node 22.23.1, V8 12.4)V8 bytecode
[generated bytecode for function: loopSum (<addr> <SharedFunctionInfo loopSum>)]Bytecode length: 31Parameter count 2Register count 2Frame size 16  207 S> <addr> @    0 : 0c                LdaZero         <addr> @    1 : c9                Star0  220 S> <addr> @    2 : 0d 01             LdaSmi [1]         <addr> @    4 : c8                Star1  234 S> <addr> @    5 : 0b 03             Ldar a0  234 E> <addr> @    7 : 71 f8 00          TestLessThan r1, [0]         <addr> @   10 : 9e 12             JumpIfFalse [18] (<addr> @ 28)  245 S> <addr> @   12 : 0b f8             Ldar r1  259 E> <addr> @   14 : 3b f9 01          Add r0, [1]         <addr> @   17 : c9                Star0  268 S> <addr> @   18 : 0b f8             Ldar r1  274 E> <addr> @   20 : 47 01 02          AddSmi [1], [2]         <addr> @   23 : c8                Star1  225 E> <addr> @   24 : 8e 13 00 03       JumpLoop [19], [0], [3] (<addr> @ 5)  285 S> <addr> @   28 : 0b f9             Ldar r0  298 S> <addr> @   30 : ae                ReturnConstant pool (size = 0)Handler Table (size = 0)Source Position Table (size = 27)<addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>

Read the loop part in order:

  1. @ 5: Ldar a0 copies n into the accumulator.
  2. @ 7: TestLessThan r1 checks whether i < n. The answer, true or false, goes into the accumulator.
  3. @ 10: JumpIfFalse jumps out to offset 28 if the answer is false.
  4. @ 12 to @ 17: copy i into the accumulator, add total, and store the sum back in r0.
  5. @ 18 to @ 23: add 1 to i. In AddSmi [1], [2], the [1] is the number to add, and [2] is a feedback slot. Smi is short for "small integer".
  6. @ 24: JumpLoop goes back to offset 5 to test again.
Four instructions from the loopSum listing, top to bottom by offset: 5 Ldar a0, 10 JumpIfFalse, 24 JumpLoop, and 28 Ldar r0. On the left, an arrow from JumpLoop goes back up to offset 5. On the right, an arrow from JumpIfFalse goes down to offset 28, out of the loop.@5 Ldar a0@10 JumpIfFalse…@24 JumpLoop@28 Ldar r0back to @5if false:out to @28
A jump names a place in the bytecode by its offset, not a source line. JumpLoop goes back to offset 5 to test again, and JumpIfFalse leaves the loop by jumping to offset 28.

Properties, calls, and closures

Reading and writing a property each have their own opcode in V8 12.4:

  • GetNamedProperty reads a property, like user.count.
  • SetNamedProperty writes a property, like user.count = 2.
  • CallProperty1 calls a method with one argument, like user.label(user.count). It names the function, the object before the dot, the argument, and a feedback slot.
propertyCall's bytecode: read, write, and call a methodV8 bytecode
[generated bytecode for function: propertyCall (<addr> <SharedFunctionInfo propertyCall>)]Bytecode length: 27Parameter count 2Register count 3Frame size 24  124 S> <addr> @    0 : 2f 03 00 01       GetNamedProperty a0, [0], [1]  130 E> <addr> @    4 : 47 01 00          AddSmi [1], [0]  117 E> <addr> @    7 : 35 03 00 03       SetNamedProperty a0, [0], [3]  149 S> <addr> @   11 : 2f 03 01 05       GetNamedProperty a0, [1], [5]         <addr> @   15 : c9                Star0  160 E> <addr> @   16 : 2f 03 00 01       GetNamedProperty a0, [0], [1]         <addr> @   20 : c7                Star2  149 E> <addr> @   21 : 61 f9 03 f7 07    CallProperty1 r0, a0, r2, [7]  167 S> <addr> @   26 : ae                ReturnConstant pool (size = 2)<addr>: [TrustedFixedArray] - map: <addr> <Map(TRUSTED_FIXED_ARRAY_TYPE)> - length: 2           0: <addr> <String[5]: #count>           1: <addr> <String[5]: #label>Handler Table (size = 0)Source Position Table (size = 18)<addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>

Line 6 reads user.count. In GetNamedProperty a0, [0], [1], the [0] points to entry 0 of the constant pool. That is the list of fixed values at the bottom of the listing, and entry 0 is the name count. Line 8 writes the new count. Line 13 calls label as a method.

Closures link back to scope analysis. A closure is a function that keeps using variables from the function around it. Here, the inner add uses x from makeAdder. So makeAdder keeps x in a context: a small object for variables that inner functions need.

makeAdder's bytecode: keep x for the inner functionV8 bytecode
[generated bytecode for function: makeAdder (<addr> <SharedFunctionInfo makeAdder>)]Bytecode length: 14Parameter count 2Register count 1Frame size 8  320 E> <addr> @    0 : 88 00 01          CreateFunctionContext [0], [1]         <addr> @    3 : 1a f9             PushContext r0         <addr> @    5 : 0b 03             Ldar a0         <addr> @    7 : 25 02             StaCurrentContextSlot [2]  328 S> <addr> @    9 : 85 01 00 02       CreateClosure [1], [0], #2  375 S> <addr> @   13 : ae                ReturnConstant pool (size = 2)<addr>: [TrustedFixedArray] - map: <addr> <Map(TRUSTED_FIXED_ARRAY_TYPE)> - length: 2           0: <addr> <ScopeInfo FUNCTION_SCOPE>           1: <addr> <SharedFunctionInfo add>Handler Table (size = 0)Source Position Table (size = 10)<addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>

Line 6 makes the context. Line 9 stores x in slot 2 of that context. Line 10, CreateClosure, makes the inner add function.

The inner add's bytecode: read x, then add yV8 bytecode
[generated bytecode for function: add (<addr> <SharedFunctionInfo add>)]Bytecode length: 9Parameter count 2Register count 1Frame size 8  357 S> <addr> @    0 : 17 02             LdaImmutableCurrentContextSlot [2]         <addr> @    2 : c9                Star0         <addr> @    3 : 0b 03             Ldar a0  366 E> <addr> @    5 : 3b f9 00          Add r0, [0]  370 S> <addr> @    8 : ae                ReturnConstant pool (size = 0)Handler Table (size = 0)Source Position Table (size = 9)<addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>

Line 6 reads x back from the context, and line 7 stores it in r0. Line 8 copies y into the accumulator. Line 9 adds them.

Running add for makeAdder(4)(6). Register r0 holds x, which is 4. The accumulator holds y, which is 6. The instruction Add r0, [0] adds them and leaves 10 in the accumulator.makeAdder(4)(6)4r0x = 46accy = 6Add r0, [0]10acc4 + 6
Lines 6 to 8 put x (4) in r0 and y (6) in the accumulator. Line 9 adds r0 to the accumulator, so it now holds 10.

A plain call such as fn(1, 2) has no object before the dot. V8 calls the this value of a call its receiver. So for this call it prints CallUndefinedReceiver2: a call with no receiver and two arguments.

callFree's bytecode: a call with no object before the dotV8 bytecode
[generated bytecode for function: callFree (<addr> <SharedFunctionInfo callFree>)]Bytecode length: 12Parameter count 2Register count 3Frame size 24   26 S> <addr> @    0 : 0d 01             LdaSmi [1]         <addr> @    2 : c8                Star1         <addr> @    3 : 0d 02             LdaSmi [2]         <addr> @    5 : c7                Star2   33 E> <addr> @    6 : 66 03 f8 f7 00    CallUndefinedReceiver2 a0, r1, r2, [0]   42 S> <addr> @   11 : ae                ReturnConstant pool (size = 0)Handler Table (size = 0)Source Position Table (size = 8)<addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>

The opcode only says how the call is written. Inside the called function, the usual rules for this still apply. The code below shows both rules.

A plain call still follows the rules for thisPop out in the code editor (opens in a new tab)JavaScript
function sloppyReceiver() {  return this === globalThis;} function strictReceiver() {  "use strict";  return this === undefined;} console.log(sloppyReceiver());console.log(strictReceiver());

Both lines print true. Outside strict mode, a plain call gets the global object, globalThis, as this. In strict mode, this stays undefined.

Build a small teaching interpreter

MODEL, NOT V8

The experiments below use a small model, not V8. Here is what the model does:

  1. It reads a tiny function with acorn, a library that builds the AST.
  2. It turns the AST into toy bytecode that uses an accumulator and registers, like Ignition.
  3. It runs the toy bytecode with a table of small functions, one for each opcode.

The model knows only a few things. It has no objects, no errors, and none of V8's speed tricks. Its opcodes have names like V8's, but they are not real V8 bytecode.

Each toy instruction has a number, starting at 0. A counter called pc, short for program counter, holds the number of the next instruction to run. Most instructions add 1 to pc. Jumps set pc to another number.

Toy bytecode, step by step: a sum loop
Step 0 of 90Ready
Your turn: follow the blue line

This is a small model that works like Ignition. It is not Ignition itself. Step through the loop and watch pc, the accumulator, and the registers change.

Running in
  1. script
Next: line 2
const program = [  ["Star","r0"],  ["LdaSmi",1],  ["Star","r1"],  ["Ldar","r1"],  ["Star","r2"],  ["Ldar","a0"],  ["Star","r3"],  ["LdaSmi",1],  ["Add","r3"],  ["TestLessThan","r2"],  ["JumpIfFalse",23],  ["Ldar","r0"],  ["Star","r4"],  ["Ldar","r1"],  ["Add","r4"],  ["Star","r0"],  ["Ldar","r1"],  ["Star","r5"],  ["LdaSmi",1],  ["Add","r5"],  ["Star","r1"],  ["Jump",4],  ["Ldar","r0"],  ["Return"],]; function run(bytecode, a0) {  const registers = { a0: a0, r0: undefined, r1: undefined };  let pc = 0;  let acc;  const dispatch = {    LdaSmi(value) { acc = value; pc += 1; },    Ldar(register) { acc = registers[register]; pc += 1; },    Star(register) { registers[register] = acc; pc += 1; },    Add(register) { acc = registers[register] + acc; pc += 1; },    Mul(register) { acc = registers[register] * acc; pc += 1; },    TestLessThan(register) { acc = registers[register] < acc; pc += 1; },    JumpIfFalse(target) { pc = acc ? pc + 1 : target; },    Jump(target) { pc = target; },    Return() { pc = bytecode.length; },  };  while (pc < bytecode.length) {    const [op, operand] = bytecode[pc];    dispatch[op](operand);  }  return acc;} console.log(run(program, 4));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Watch the loop. JumpIfFalse leaves the loop when the accumulator holds false. Jump sets pc back to 4, where the loop test starts. Back and Reset replay steps saved from a real run of the model.

Toy bytecode, step by step: multiply, then add
Step 0 of 10Ready
Your turn: follow the blue line

A shorter run. Watch each value pass through the accumulator before the function returns.

Running in
  1. script
Next: line 2
const program = [  ["Star","r1"],  ["LdaSmi",2],  ["Mul","r1"],  ["Star","r0"],  ["Ldar","r0"],  ["Star","r2"],  ["LdaSmi",1],  ["Add","r2"],  ["Return"],]; function run(bytecode, a0) {  const registers = { a0: a0, r0: undefined };  let pc = 0;  let acc;  const dispatch = {    LdaSmi(value) { acc = value; pc += 1; },    Ldar(register) { acc = registers[register]; pc += 1; },    Star(register) { registers[register] = acc; pc += 1; },    Add(register) { acc = registers[register] + acc; pc += 1; },    Mul(register) { acc = registers[register] * acc; pc += 1; },    TestLessThan(register) { acc = registers[register] < acc; pc += 1; },    JumpIfFalse(target) { pc = acc ? pc + 1 : target; },    Jump(target) { pc = target; },    Return() { pc = bytecode.length; },  };  while (pc < bytecode.length) {    const [op, operand] = bytecode[pc];    dispatch[op](operand);  }  return acc;} console.log(run(program, 5));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

This shorter example makes the accumulator easy to follow. Load a value. Store it in a register. Load the next value. Combine the two. Return the result. Real V8 has more kinds of opcodes, but the idea is the same: one value being worked on, plus numbered slots for saved values.

Now try your own. Pick an example, change the argument, or edit the function. Then compare the toy bytecode with a real V8 listing for a similar function.

Turn a tiny function into toy bytecode
Toy bytecodeToy bytecode
0000 LdaSmi 0
0001 Star r0
0002 LdaSmi 1
0003 Star r1
0004 Ldar r1
0005 Star r2
0006 Ldar a0
0007 Star r3
0008 LdaSmi 1
0009 Add r3
0010 TestLessThan r2
0011 JumpIfFalse 23
0012 Ldar r0
0013 Star r4
0014 Ldar r1
0015 Add r4
0016 Star r0
0017 Ldar r1
0018 Star r5
0019 LdaSmi 1
0020 Add r5
0021 Star r1
0022 Jump 4
0023 Ldar r0
0024 Return
Toy result and a real V8 listingNode 22.23.1 / V8 12.4
toy result10
V8 listing forloopSum(n)

A real V8 listing for a similar loop. It is not a compilation of the toy source you edit.

Real V8 listing: loopSum(n)V8 bytecode
[generated bytecode for function: loopSum (<addr> <SharedFunctionInfo loopSum>)]
Bytecode length: 31
Parameter count 2
Register count 2
Frame size 16
  207 S> <addr> @    0 : 0c                LdaZero
         <addr> @    1 : c9                Star0
  220 S> <addr> @    2 : 0d 01             LdaSmi [1]
         <addr> @    4 : c8                Star1
  234 S> <addr> @    5 : 0b 03             Ldar a0
  234 E> <addr> @    7 : 71 f8 00          TestLessThan r1, [0]
         <addr> @   10 : 9e 12             JumpIfFalse [18] (<addr> @ 28)
  245 S> <addr> @   12 : 0b f8             Ldar r1
  259 E> <addr> @   14 : 3b f9 01          Add r0, [1]
         <addr> @   17 : c9                Star0
  268 S> <addr> @   18 : 0b f8             Ldar r1
  274 E> <addr> @   20 : 47 01 02          AddSmi [1], [2]
         <addr> @   23 : c8                Star1
  225 E> <addr> @   24 : 8e 13 00 03       JumpLoop [19], [0], [3] (<addr> @ 5)
  285 S> <addr> @   28 : 0b f9             Ldar r0
  298 S> <addr> @   30 : ae                Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 27)
<addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>
Try it yourself
Example and argument

The model turned the function into toy bytecode and ran it in 90 steps. It returned 10. The registers ended as a0=4, r0=10, r1=5, r2=5, r3=4, r4=6, r5=4.

This is a small teaching model with an accumulator and registers, like Ignition. It is not V8. The V8 listing was saved from a real run of a similar function, so it does not change when you edit the code.

Handlers and dispatch

OPCODE TO HANDLER

The interpreter does not understand opcode names by itself. Each opcode has its own small piece of code that does that opcode's job. That piece of code is called a handler. Running bytecode is a short cycle:

  1. Read the opcode at the current offset.
  2. Go to that opcode's handler. This step is called dispatch.
  3. The handler does its job. It changes the accumulator, a register, or the program counter.
  4. Repeat with the next opcode.
A tiny table of handlersPop out in the code editor (opens in a new tab)JavaScript
const handlers = {  LdaSmi(state, value) {    state.acc = value;    state.pc += 1;  },  Add(state, register) {    state.acc = state.registers[register] + state.acc;    state.pc += 1;  },  JumpIfFalse(state, target) {    state.pc = state.acc ? state.pc + 1 : target;  },}; function dispatch(bytecode, state) {  const [op, operand] = bytecode[state.pc];  return handlers[op](state, operand);}

Lines 1 to 13 build handlers, an object with one function for each opcode. Line 6 starts the Add handler. Line 15 starts dispatch. Line 16 reads the instruction at pc: its opcode name and its operand. Line 17 picks the handler by that name and runs it.

Dispatch. The instruction at pc is Add with operand r0. Its name, Add, picks the Add function from the handlers object, which also holds LdaSmi. Add runs and moves pc on by one.bytecode[pc]["Add", "r0"]op is AddLdaSmiAddhandlersAdd runs, pc + 1
Line 16 reads the instruction at pc. Line 17 uses its name, Add, to pick the matching function from handlers and run it.

Ignition does the same thing in machine code. V8's docs call it threaded dispatch:

  • Each handler is its own small block of machine code.
  • At its end, a handler reads the next opcode. It looks up that opcode's handler in a table and jumps straight there.

V8 did not write these handlers by hand for every kind of CPU. Its first Ignition post says they were made with the back end of TurboFan, the compiler V8 uses for its fastest machine code. Later V8 tools, such as the CodeStubAssembler, use the same back end to build other small pieces of machine code.

The bytecode itself is kept small too. Copying the accumulator into a low-numbered register has its own one-byte opcode: Star0, Star1, Star2, and so on. You can see them in the Node 22 listings above. Like the unnamed accumulator, these short opcodes make the bytecode smaller.

Saving memory: flushing and lazy feedback

V8 LITE

Bytecode is small, but it still uses memory. V8's V8 Lite project measured real web pages. Bytecode and feedback were two of the big users of memory. Several V8 Lite ideas are now part of normal V8.

Two words first:

  • A feedback vector is one function's list of feedback slots: its notes about what kinds of values each operation saw.
  • Garbage collection (GC) is when the engine frees memory that nothing uses any more.

Each row of the table is one way V8 saves memory around bytecode.

How V8 saves memory around bytecode
What V8 doesHow it works
Flush old bytecodeEach time garbage collection runs, a function's bytecode gets older. Running the function makes it young again. Old bytecode can be thrown away. --flush-bytecode is on by default.
Make it again when neededIf a function whose bytecode was thrown away runs again, V8 makes its bytecode again before running it.
Make feedback lateV8 waits to make a function's feedback vector until the function has run enough. Node 22 has --lazy-feedback-allocation and a flag for one of the limits.
Find source positions lateV8 can wait to work out exact source positions until a stack trace or the debugger needs them.

You can see these settings in Node today:

  • --flush-bytecode is on by default.
  • --lazy-feedback-allocation is on by default.
  • --invocation-count-for-feedback-allocation sets one of the limits that decide when a function gets its feedback vector.

These are details inside the engine. Your app code should not depend on them.

SpiderMonkey and JavaScriptCore

SAME IDEA, DIFFERENT VM

The JavaScript standard does not say what bytecode looks like. Each engine makes its own. Firefox's engine, SpiderMonkey, and Safari's engine, JavaScriptCore (JSC), also run bytecode first. Their bytecode looks different, and they decide in their own ways when to move code to a faster tier.

Each row is one engine: the name of its first tier, and what its bytecode looks like.

The first tier in three big engines
EngineFirst tierWhat its bytecode looks like
V8 (Chrome, Node.js)IgnitionRegisters plus one accumulator that most instructions use without naming it. Faster tiers start from this bytecode and its feedback.
SpiderMonkey (Firefox)Baseline InterpreterA stack: each opcode takes values off the pile and puts results back. The interpreter runs one opcode at a time and adds inline caches.
JavaScriptCore (Safari)LLIntEach instruction names its slots, such as loc7 and arg1. The LLInt is written in offlineasm and runs before any faster tier.

A little more about each one:

  • SpiderMonkey's first tier is the Baseline Interpreter. Firefox's docs call it a mix of an interpreter and a JIT. A JIT, or just-in-time compiler, makes machine code while the program runs. The Baseline Interpreter runs bytecode one opcode at a time. It also attaches inline caches: small saved shortcuts that make repeated operations faster.
  • SpiderMonkey's bytecode uses a stack, a pile of values. Each opcode's description says how many values it takes off the pile and how many it puts back.
  • JSC's bytecode names its slots inside each instruction, such as loc7 for a local variable and arg1 for an argument. Its first tier is the LLInt, short for Low Level Interpreter. WebKit wrote it in offlineasm, a small assembly-like language of its own.

When this helps you

REAL WORK

You don't need bytecode for most speed problems. Start with these tools first:

  • the browser's Performance panel
  • speed numbers from real users
  • a look at how big your JavaScript files are
  • the call stack

Bytecode helps in a few cases. Each row of the table is a question you might have, and when bytecode can answer it.

When reading bytecode helps
Your questionWhen bytecode can help
StartupYou want to see what runs first, before any faster tier has had time to start.
MemoryYou want to learn why engines throw away old bytecode or wait to make feedback vectors.
Calls and propertiesYou want to see the exact steps: a property read, a method call, a plain call, or a closure reading its context.
LearningYou want a clear step from source code to what the interpreter does, before you study faster tiers.
Which job does each opcode do?
  • `Ldar a0`
  • `Star0`
  • `Add r0, [1]`
  • `TestLessThan r1, [0]`
  • `JumpLoop [19], [0], [3]`
  • `Return`
  • `CallProperty1 r0, a0, r2, [7]`
  • `CreateClosure [1], [0], #2`
Try it yourself
0 of 8 correct

Sort each V8 opcode by its main job. Then read why each one goes there.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common misconceptions

  • “Bytecode is JavaScript with shorter names.” It is a separate list of instructions. Each engine makes its own, after it has parsed your code and worked out its scopes.
  • “Bytecode is machine code.” The CPU cannot run Ignition bytecode. Handlers carry out each instruction, or a compiler turns the bytecode into machine code.
  • “One V8 listing teaches every browser.” SpiderMonkey and JavaScriptCore have their own bytecode, with different opcode names.
  • “A number in brackets is always a value.” In Add r0, [1], the [1] names a feedback slot. It is not the number 1.
  • “Flushing bytecode changes what my program does.” It only saves memory. If the function runs again, V8 makes its bytecode again.

Each row names two ideas that are easy to mix up, then says how they differ.

Ideas that are easy to mix up
Easy to mix upHow they differ
Bytecode and the ASTThe AST shows how the code is written. Bytecode is the list of steps to run it.
Bytecode and machine codeThe engine runs or compiles bytecode. The CPU runs machine code, which is different for each kind of CPU.
Ignition and all enginesIgnition is V8's interpreter. Firefox and Safari use other interpreters and other bytecode.
The toy model and V8The model shows how an accumulator and registers work. It is not how V8 is built.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upTrace one accumulator add

Predict what this code prints for the accumulator.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const registers = { r0: 3 };
let acc = 4;
acc = registers.r0 + acc;
console.log(acc);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFollow a false jump

    What does the program counter, pc, print after the jump?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    let pc = 5;
    let acc = false;
    pc = acc ? pc + 1 : 12;
    console.log(pc);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeName the plain-call opcode

      Which exact call opcode does V8 print for return fn(1, 2)?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeUse today's property opcode name

        Which opcode reads user.count in the stored V8 listing?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeAdd a handler to the toy table

          The table of handlers has LdaSmi, Add, and jumps. You want to multiply too. What name would you give the new handler?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Why did V8 add Ignition bytecode instead of making machine code for every function right away?

              Choose an answer to see the explanation.

            2. Question 2 of 7In an Ignition listing, what does Add r0, [1] mean?

              Choose an answer to see the explanation.

            3. Question 3 of 7What does this tiny accumulator model print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const registers = { r0: 3 };
              let acc = 4;
              acc = registers.r0 + acc;
              console.log(acc);

              Choose an answer to see the explanation.

            4. Question 4 of 7Which line in the loop listing shows that jumps go to bytecode offsets?

              Read the code, then predictJavaScript
              [generated bytecode for function: loopSum (<addr> <SharedFunctionInfo loopSum>)]
              Bytecode length: 31
              Parameter count 2
              Register count 2
              Frame size 16
                207 S> <addr> @    0 : 0c                LdaZero
                       <addr> @    1 : c9                Star0
                220 S> <addr> @    2 : 0d 01             LdaSmi [1]
                       <addr> @    4 : c8                Star1
                234 S> <addr> @    5 : 0b 03             Ldar a0
                234 E> <addr> @    7 : 71 f8 00          TestLessThan r1, [0]
                       <addr> @   10 : 9e 12             JumpIfFalse [18] (<addr> @ 28)
                245 S> <addr> @   12 : 0b f8             Ldar r1
                259 E> <addr> @   14 : 3b f9 01          Add r0, [1]
                       <addr> @   17 : c9                Star0
                268 S> <addr> @   18 : 0b f8             Ldar r1
                274 E> <addr> @   20 : 47 01 02          AddSmi [1], [2]
                       <addr> @   23 : c8                Star1
                225 E> <addr> @   24 : 8e 13 00 03       JumpLoop [19], [0], [3] (<addr> @ 5)
                285 S> <addr> @   28 : 0b f9             Ldar r0
                298 S> <addr> @   30 : ae                Return
              Constant pool (size = 0)
              Handler Table (size = 0)
              Source Position Table (size = 27)
              <addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>

              Choose an answer to see the explanation.

            5. Question 5 of 7Which opcodes does Node 22.23.1 (V8 12.4) print for this property read, write, and method call?

              Read the code, then predictJavaScript
              [generated bytecode for function: propertyCall (<addr> <SharedFunctionInfo propertyCall>)]
              Bytecode length: 27
              Parameter count 2
              Register count 3
              Frame size 24
                124 S> <addr> @    0 : 2f 03 00 01       GetNamedProperty a0, [0], [1]
                130 E> <addr> @    4 : 47 01 00          AddSmi [1], [0]
                117 E> <addr> @    7 : 35 03 00 03       SetNamedProperty a0, [0], [3]
                149 S> <addr> @   11 : 2f 03 01 05       GetNamedProperty a0, [1], [5]
                       <addr> @   15 : c9                Star0
                160 E> <addr> @   16 : 2f 03 00 01       GetNamedProperty a0, [0], [1]
                       <addr> @   20 : c7                Star2
                149 E> <addr> @   21 : 61 f9 03 f7 07    CallProperty1 r0, a0, r2, [7]
                167 S> <addr> @   26 : ae                Return
              Constant pool (size = 2)
              <addr>: [TrustedFixedArray]
               - map: <addr> <Map(TRUSTED_FIXED_ARRAY_TYPE)>
               - length: 2
                         0: <addr> <String[5]: #count>
                         1: <addr> <String[5]: #label>
              Handler Table (size = 0)
              Source Position Table (size = 18)
              <addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>

              Choose an answer to see the explanation.

            6. Question 6 of 7V8 threw away a function's old bytecode. What happens when that function runs again?

              Choose an answer to see the explanation.

            7. Question 7 of 7Which statement about different engines is true?

              Choose an answer to see the explanation.

            Key takeaways

            • Bytecode is a short list of instructions. It sits between the AST and the CPU's machine code.
            • Ignition keeps values in registers and in one accumulator. Most instructions use the accumulator without naming it.
            • --print-bytecode shows offsets, registers, constants, and feedback slots for one V8 version.
            • An interpreter is a cycle: read an opcode, go to its handler, let the handler do its job, repeat.
            • V8 can throw away old bytecode and make feedback vectors late to save memory. Your program behaves the same.
            • SpiderMonkey and JavaScriptCore also run bytecode first, but their bytecode and first tiers are their own.

            Remember the one-liner.
            Bytecode lets an engine start running your code quickly, take notes as it goes, and save the slow work of making machine code for the code that runs most.

            Next: Startup: snapshots & code caching. Later, Tiered compilation shows how busy bytecode moves on to faster tiers.

            CompleteFrontend Clear concepts. Working examples.