How JavaScript engines work: parsing, JIT and hidden classes

What V8 and other engines do with your source code, why code gets faster as it runs, and the few habits that help the optimiser.

  • Beginner
  • 8 min read
  • Updated 17 Oct 2026

What you will learn

  • Describe JIT compilation
  • Avoid patterns that defeat optimisation
  • Explain hidden classes and inline caches
  • Explain why code is deoptimised

Before you start

A JavaScript engine turns your source text into something the processor can run. Chrome, Edge and Node.js use V8. Firefox uses SpiderMonkey and Safari uses JavaScriptCore. They differ in detail but follow the same broad design. Knowing that design explains why JavaScript gets faster as it runs, why some code is unexpectedly slow, and which "optimisation tips" matter and which are folklore.

This lesson describes V8, because its team documents its design publicly on v8.dev. The concepts carry over to the other engines.

From source to running code

At a high level, an engine does four things:

  1. Parse the source text into an abstract syntax tree (AST), a data structure describing the program.
  2. Compile the AST to bytecode, a compact set of instructions for a virtual machine.
  3. Interpret the bytecode, while collecting feedback about what the code actually does.
  4. Optimise frequently run code by compiling it to machine code, using that feedback to make assumptions.

Steps 3 and 4 make up just-in-time (JIT) compilation: compilation happens while the program runs, and only for code that has proved worth the effort.

Parsing, lazily

Pages ship far more JavaScript than they run during start-up. Fully parsing every function before running anything would waste time and memory, so engines parse lazily. A quick pre-parse of each function checks for syntax errors and notes which variables it uses, but does not build a full AST. The full parse happens only when the function is first called.

This is one reason sending less JavaScript is the most effective performance improvement for page load: every byte still has to be downloaded, at least pre-parsed and kept in memory. Engines also cache compiled code between visits, which helps repeat loads.

Bytecode and the interpreter

V8's interpreter is called Ignition. It compiles the AST to bytecode and runs it. Bytecode starts quickly and is much smaller than machine code, but interpreting it is relatively slow, because each instruction is handled one at a time by general-purpose code.

While it runs, the interpreter records type feedback: which kinds of objects reach each property access, which functions a call site calls, whether arithmetic used small integers or floating-point numbers. This feedback is the raw material for optimisation.

Tiers of compilers

Compiling to optimised machine code is expensive, so engines do not do it for everything. They use several tiers, each spending more compile time to produce faster code, and move a function up a tier when it has run often enough to justify it.

V8's tiers, as described in the Sparkplug and Maglev posts on v8.dev:

TierWhat it does
IgnitionInterpreter: compiles to bytecode and runs it, collecting feedback
SparkplugBaseline compiler: turns bytecode into machine code very quickly, without optimising
MaglevMid-tier optimising compiler: uses feedback to produce good code quickly
TurboFanTop-tier optimising compiler: spends the most time to produce the fastest code

Most code never leaves the first tiers, which is fine: it does not run often enough to matter. Hot loops and frequently called functions climb to the optimising compilers. SpiderMonkey and JavaScriptCore have their own tiered designs with different names, built on the same idea.

Speculation and deoptimisation

An optimising compiler is fast because it speculates. If the feedback says a function has only ever been called with small integers, the compiler generates machine code that assumes small integers, with cheap checks to confirm the assumption.

JavaScript
function addScores(a, b) {
  return a + b;
}

for (let i = 0; i < 100_000; i++) {
  addScores(i, 1); // Always small integers so far.
}

addScores('10', 1); // → '101'. An assumption fails.

When a check fails, the optimised code cannot handle the new case. The engine deoptimises: it discards that machine code, returns to a lower tier, and continues correctly. Your program never produces a wrong answer; it just runs more slowly until the engine re-optimises with the new, wider feedback.

Occasional deoptimisation is normal and cheap. It matters when hot code keeps flipping between shapes and types, so it is repeatedly optimised and thrown away, or settles on slow generic code.

Hidden classes

JavaScript objects can gain and lose properties at any time, so in principle every property lookup would be a dictionary search. Engines avoid that with hidden classes. V8 calls them maps; SpiderMonkey and JavaScriptCore call them shapes or structures.

The v8.dev article Fast properties in V8 explains the details. A hidden class records an object's layout: which properties it has, in which order, and where each value is stored. Objects created the same way share a hidden class:

JavaScript
function makePoint(x, y) {
  return { x, y };
}

const p1 = makePoint(1, 2);
const p2 = makePoint(3, 4); // Same properties, same order: same hidden class.

Adding a property creates a transition to a new hidden class. Objects that receive the same properties in a different order end up with different hidden classes, even though they look identical to you:

JavaScript
const a = {};
a.x = 1;
a.y = 2; // Transitions: {} → {x} → {x, y}

const b = {};
b.y = 2;
b.x = 1; // Transitions: {} → {y} → {y, x}. A different hidden class from a.

Inline caches

Hidden classes pay off through inline caches. The first time a property access such as point.x runs, the engine looks up where x lives for that hidden class and remembers the answer at that place in the code. Next time, if the object has the same hidden class, it reads the value straight from the remembered position.

How many hidden classes reach one access site determines how fast it is:

StateMeaningSpeed
Monomorphicone hidden class seenfastest
Polymorphica few hidden classes seenstill fast, checks each
Megamorphicmany hidden classes seenfalls back to a slower generic lookup

So consistency is what helps the engine: the same functions receiving objects of the same shape, with values of the same types.

What this means for everyday code

Most advice reduces to "write predictable code", which is also easier to read.

  • Initialise every property in the constructor, in the same order. Class fields do this naturally:
JavaScript
class Course {
  title;
  lessons = 0;
  completed = 0;

  constructor(title, lessons) {
    this.title = title;
    this.lessons = lessons;
  }
}

Every Course gets the same hidden class. Adding properties later, only to some instances, splits them into several.

  • Prefer setting a property to null or undefined over delete. Deleting can move an object into a slower dictionary mode. If properties are genuinely added and removed at run time, use a Map, which is designed for it.
  • Keep arrays consistent. Engines track what kind of elements an array holds, such as small integers, floating-point numbers or anything at all, and whether it has holes. Arrays of one type without holes use the fastest representation. Create arrays by pushing or with Array.from, rather than new Array(n) followed by scattered writes, and avoid mixing numbers, strings and objects in a hot array.
  • Keep hot functions type-stable. A function that sometimes receives a number and sometimes a string works, but its call sites lose the benefit of specialised code.
  • Do not chase micro-optimisations. Engines change; tricks that helped one version can hurt the next. Write clear code, measure with the DevTools Performance panel or Node's profiler, and only then change what the profile shows is slow.

Be wary of micro-benchmarks that run a tiny snippet in a loop millions of times. The optimiser can remove work whose result is never used, or specialise for the benchmark's unrealistic inputs. Measure realistic workloads.

Seeing it for yourself

V8 exposes diagnostic flags you can pass to Node.js. --trace-opt and --trace-deopt print messages when functions are optimised and deoptimised, with a reason:

Shell
node --trace-opt --trace-deopt scores.js

The output is verbose and its format changes between versions, so treat it as an exploration tool, not something to depend on. For real investigations, the CPU profile in DevTools (or node --cpu-prof) shows where time is actually spent, which is usually your algorithm, the DOM or the network, not the JIT.

Summary

  • Engines parse source into an AST, compile it to bytecode, interpret it while collecting type feedback, and compile hot code to machine code.
  • V8 uses the Ignition interpreter, the Sparkplug baseline compiler and the Maglev and TurboFan optimising compilers.
  • Optimising compilers speculate based on feedback; when an assumption fails, the code is deoptimised and still runs correctly.
  • Hidden classes and inline caches make property access fast when objects have consistent shapes.
  • Initialise properties consistently, avoid delete on hot objects, keep arrays and functions type-stable, and measure before optimising.