AlgoInsight

Free lesson · JavaScript Basic to Advance

JIT Compilation and Hidden Classes

Easy5 min read

How JavaScript Gets Fast

The reason JavaScript performance is competitive with statically-compiled languages — despite all its dynamic features — comes down to JIT compilation. Just-in-time compilation watches code as it runs, figures out what types and shapes the code actually uses, and produces machine code optimized for those specific patterns.

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

// Called millions of times with integers
for (let i = 0; i < 10_000_000; i++) {
  add(i, i + 1);
}

In this code, add is called millions of times with the same types — both arguments are integers, the return is an integer. V8 notices this. It compiles add to machine code that assumes integer inputs and integer addition. The optimized version is dramatically faster than the bytecode-interpreted version.

But there's a catch: V8's optimized code depends on its assumptions holding. If a future call passes a string instead of an integer, the assumptions break and the optimized code becomes useless. V8 has to detect this and fall back.

This Topic covers how V8's optimizer works, the concept of hidden classes that makes object property access fast, and the patterns that help or hurt optimization.

Interactive animation: Warmed up on numbers, then called with strings: the compiled assumption breaks and V8 falls back to bytecode.

Inline Caches: Making Property Access Fast

JavaScript property access — obj.x — is conceptually slow. The engine has to look up x in the object's properties, possibly walk the prototype chain, possibly call a getter. Doing this naively, every time, would be very slow.

V8's solution is inline caches. The first time a piece of code does obj.x, V8 records what kind of object obj was and where x was found. The second time, if obj is the same kind, V8 uses the cached lookup directly instead of going through the full property-lookup machinery.

function getName(user) {
  return user.name;   // inline cache attached to this access
}

// First call — cold IC
getName({ name: "Olena", age: 35 });

// Second call with same-shaped object — cache hit, fast
getName({ name: "Nikhil", age: 32 });

// Different shape — cache miss, slower
getName({ age: 25, name: "Different order" });

The first call sets up the inline cache. Subsequent calls with the same object shape hit the cache. Calls with different shapes miss the cache and either fall back to slower lookup or invalidate the cache and start collecting new information.

The implication: code that works with consistently-shaped objects gets fast caches. Code that handles wildly varying object shapes never gets stable caches.

Hidden Classes: The Object Layout Trick

To make inline caches work, V8 needs a way to identify "objects with the same shape." It does this through hidden classes (also called Maps or Shapes in some implementations).

When you create an object, V8 builds a hidden class describing its structure. Objects with the same properties added in the same order share the same hidden class.

const user1 = {};
user1.name = "Olena";    // hidden class: { name }
user1.age = 35;           // hidden class transitions to: { name, age }

const user2 = {};
user2.name = "Nikhil";   // hidden class: { name } — same as user1's intermediate
user2.age = 32;           // hidden class: { name, age } — same as user1's final

// user1 and user2 share the same hidden class

const user3 = {};
user3.age = 25;           // hidden class: { age } — different
user3.name = "Different"; // hidden class: { age, name } — different from user1/user2

user1 and user2 share a hidden class because they added the same properties in the same order. user3 has a different hidden class because the order differs, even though the final set of properties is the same.

This sharing matters because V8 uses the hidden class as the lookup key for inline caches. Code that sees only user1 and user2 will have stable caches. Code that also sees user3 will have to handle multiple hidden classes.

graph LR
    A[Empty object] --> B[Add name<br/>HC: name]
    B --> C[Add age<br/>HC: name, age]

    D[Empty object] --> E[Add age<br/>HC: age]
    E --> F[Add name<br/>HC: age, name]

    style C fill:#10b981,color:#fff
    style F fill:#f59e0b,color:#fff
This diagram shows how hidden classes form. Starting from an empty object, adding a name property transitions the object's hidden class to one describing "has name." Adding an age property next transitions to "has name and age." A different object that starts empty but adds age first, then name, ends up with a different hidden class even though both objects ultimately have the same properties — because the order of property addition produced different transition paths.

Interactive animation: Same properties in the same order share one hidden class: one shape descriptor, and each object a slot array.

Patterns That Help Optimization

Several patterns make V8's job easier:

Initialize all properties in the constructor. When you create objects, set every property you'll ever use right in the constructor (or class fields). Don't add properties later. This produces stable hidden classes.

// ✓ Good — all properties set in constructor
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.z = 0;          // even unused properties, set them
  }
}

// ✗ Bad — adds property conditionally
class FlexiblePoint {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  enable3D() {
    this.z = 0;          // adding property later → new hidden class
  }
}

Always set properties in the same order. If your code creates objects in different ways, set the properties in a consistent order across all paths. Different orders produce different hidden classes.

Use monomorphic functions. A function is monomorphic at a call site if it only ever sees one type. Polymorphic if it sees a few (up to 4 in V8). Megamorphic if it sees many. Performance follows: monomorphic > polymorphic > megamorphic.

// Monomorphic — only ever receives objects of one shape
function getX(point) { return point.x; }
getX({ x: 1, y: 2 });
getX({ x: 3, y: 4 });

// Megamorphic — receives many different shapes
function getProperty(obj, key) { return obj[key]; }
getProperty({ a: 1 }, "a");
getProperty({ b: 2, c: 3 }, "c");
getProperty(["array"], 0);
// V8 can't optimize this well

Use consistent types. A function called with integers should keep being called with integers. Mixing integers and floats produces deoptimization. Mixing numbers and strings produces more deoptimization.

These aren't rules you should apply blindly to every line of code. They're patterns to be aware of for hot paths. Most code runs cold and doesn't benefit from optimization-friendly patterns. Hot paths — render loops, parsers, data processing — benefit dramatically.

Interactive animation: The morphism ladder: monomorphic, polymorphic, megamorphic - the speed a call site gets as it sees more shapes.

What Causes Deoptimization

When V8's optimized code makes an assumption that turns out to be wrong, the optimized version is invalidated. The engine has to fall back to interpreted bytecode, possibly recompile, and lose all the work the optimizer did.

Common causes of deoptimization:

Type changes. A function that always saw integers now sees a string. The integer-specialized machine code can't handle that.

Hidden class transitions on hot objects. Adding a property to an object that was being read in a hot path forces V8 to update its inline caches.

Holes in arrays. const arr = [1, 2, 3] is a packed array. const arr = [1, , 3] has a hole at index 1. V8 has special-case fast handling for packed arrays; holes force slower paths.

Out-of-range array access. arr[1000000] on a small array. V8 has to fall back from optimized integer-indexed access to slower property-lookup access.

Using delete on object properties. Deleting a property changes the object's shape and invalidates caches.

You can see deoptimization happening in real time using V8's tracing flags:

node --trace-deopt script.js
# Prints information whenever a function deoptimizes

For performance-sensitive code, watching for deoptimizations is the key diagnostic technique. A function that runs slowly might be slow because it's constantly being deoptimized.

Interactive animation: Packed vs holey: b[10] = 4 on a three-element array leaves holes that slow every later read.

Numeric Representations

A specific optimization V8 does that's worth knowing about: small integers (called Smi for "Small Integer") are stored differently from other numbers.

A Smi fits in 31 bits on 32-bit platforms or 32 bits on 64-bit platforms. V8 uses tag bits to distinguish Smis from object pointers, allowing them to be stored inline without heap allocation.

const a = 5;            // Smi — fast, no heap allocation
const b = 1.5;          // HeapNumber — allocated on the heap
const c = 2**30;         // Smi (just barely fits)
const d = 2**32;         // Too big for Smi — HeapNumber

// Arithmetic on Smis is very fast
// Arithmetic that produces a non-Smi result (overflow, decimals) involves boxing

This is why integer-heavy code is faster than float-heavy code in V8, and why "use integers when you can" is reasonable advice for hot inner loops.

The same boxing concern applies to other types. Strings, objects, and arrays are heap-allocated. Property access on them involves indirection that integers don't have. For very performance-sensitive code, packing data into typed arrays (Int32Array, Float64Array) can produce significant speedups because typed arrays are stored as raw native arrays without per-element boxing.

When Optimization Doesn't Matter

A balancing perspective: most application code doesn't benefit from engine-level optimization concerns. The Pareto principle applies — 80% of execution time is in 20% of code (often less). The vast majority of code runs rarely and never gets optimized at all.

For application code, the optimization-friendly patterns are still good defaults — consistent object shapes, monomorphic functions, no surprising property additions — because they're also good engineering practices. But spending time optimizing cold code is wasted effort.

Profile first. Find the hot paths. Apply optimization-friendly patterns there. Topic 13.6 covers profiling techniques.

Common Pitfalls

Warning: The JIT optimization traps.

Treating optimization patterns as rules. Optimization-friendly patterns matter for hot code. They don't matter for most code. Don't compromise code clarity for optimization in cold paths.

Optimizing for V8 specifically. Patterns that help V8 might hurt other engines. If you're shipping to multiple environments, test in all of them.

Assuming "modern JavaScript is fast." It's fast when optimizable. Code that defeats optimizations — varying types, varying shapes, frequent property changes — can be 10-100x slower than optimized code doing the same logical work.

Adding properties conditionally to performance-critical objects. Each conditional path creates a different hidden class. The function reading those objects sees multiple shapes and becomes polymorphic or megamorphic.

Why This Matters in Interviews

Interview: Hidden classes and inline caches are staff-level interview territory. Questions like "why is this function slow?" or "explain how V8 optimizes property access" expect detailed answers covering these concepts. Topic 13.7 covers the interview-prep treatment in depth.

Try It

Exercise: Run this performance comparison:

function monomorphicTest() {
  const point = { x: 1, y: 2 };
  let sum = 0;
  for (let i = 0; i < 100_000_000; i++) {
    sum += point.x + point.y;
  }
  return sum;
}

function polymorphicTest() {
  const points = [
    { x: 1, y: 2 },
    { y: 4, x: 3 },           // different property order — different hidden class
    { x: 5, y: 6, z: 7 },     // extra property — different hidden class
    { x: 9 },                  // missing property — different hidden class
  ];
  let sum = 0;
  for (let i = 0; i < 100_000_000; i++) {
    const p = points[i % 4];
    sum += (p.x || 0) + (p.y || 0);
  }
  return sum;
}

console.time("monomorphic");
monomorphicTest();
console.timeEnd("monomorphic");

console.time("polymorphic");
polymorphicTest();
console.timeEnd("polymorphic");

The polymorphic version will be noticeably slower despite doing similar logical work. V8 can't optimize the property access as aggressively when objects have varying shapes. Once you see the performance difference yourself, hidden classes become concrete.

Back to JavaScript Basic to Advance