AdvancedEngine behavior and performance engineering~45 min

Engine Behavior and Performance Engineering

Hidden classes and inline caches as engine-dependent optimizations, generational garbage collection, designing a benchmark that isn't lying to you, and when WebAssembly is (and isn't) the right boundary to reach for.

By the end of this lesson, you can

  • Explain what a hidden class and an inline cache are, and why writing consistently-shaped objects helps engine optimization
  • Explain generational garbage collection at a level that informs allocation decisions
  • Identify at least three common benchmark design mistakes and why each produces misleading results
  • Explain what WebAssembly's boundary cost is, and when reaching for it is inappropriate

Why it matters

Performance and Diagnostics covered measuring, algorithmic complexity, and memory leaks at an application level. This lesson goes one level deeper: how an engine actually executes JavaScript fast (and what defeats that), how garbage collection is actually structured, and how to design a benchmark that measures what you think it measures.

Hidden classes and inline caches

Modern engines don’t treat every object as a generic hash map. Internally, they track an object’s hidden class (V8’s term; other engines use similar ideas under different names) — essentially, the shape of an object: which properties it has, in which order:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

const p1 = new Point(1, 2); // hidden class "has x, then y"
const p2 = new Point(3, 4); // SAME hidden class — engine can reuse optimized access code

const p3 = new Point(5, 6);
p3.z = 7; // p3 now has a DIFFERENT hidden class than p1/p2 — "has x, then y, then z"

An inline cache remembers, at a specific point in code, what hidden class was seen last time a property was accessed there — if the same hidden class shows up again, the engine can skip the general property lookup and go straight to a fast, direct memory offset. Objects with inconsistent shapes (some with a z property, some without, arriving at the same code) force the inline cache to fall back to slower, more general lookup logic.

Deoptimization happens when the engine had compiled a fast, optimized version of a function based on assumptions (consistent argument types, consistent object shapes) that later code violates — the engine falls back to slower, unoptimized execution, sometimes with a real, measurable pause while it does so. A function called with wildly varying argument types is a common trigger.

Generational garbage collection

Most modern engines organize the heap by object age, based on the empirical observation that most objects die young:

A simplified generational garbage collector model
GenerationContainsCollected
Young (nursery)Recently allocated objectsFrequently, cheaply — most objects here die immediately
OldObjects that survived several young-generation collectionsLess often, more expensively — a full scan costs more

An object promoted to the old generation because it happened to survive a couple of collections, but is actually short-lived overall, adds real cost — this is one reason a genuinely temporary object retained slightly too long (recall environment retention from Advanced Functions) can have a larger performance impact than its brief lifetime would suggest.

Designing a benchmark that isn’t lying to you

Common benchmark mistakes, and what each one actually produces
MistakeWhat it produces instead of a real measurement
No warm-up periodMeasures the engine's slower, unoptimized initial execution, not steady-state performance
Dead code eliminationThe engine notices a computed result is never used and skips the work entirely — you benchmark nothing
Unrepresentative inputA benchmark on tiny, uniform data can favor an algorithm that loses on real, varied production data
Ignoring varianceA single run's number, without noting spread across many runs, can be noise mistaken for a real difference
// A benchmark vulnerable to dead-code elimination
function benchmark(fn, iterations) {
  const start = performance.now();
  for (let i = 0; i < iterations; i++) {
    fn(); // if fn's result is never used or observed, the engine may legally skip real work
  }
  return performance.now() - start;
}

// Forcing the result to matter defeats that specific optimization
function benchmarkSafer(fn, iterations) {
  let sink = 0;
  const start = performance.now();
  for (let i = 0; i < iterations; i++) {
    sink += fn(); // using the result somehow makes it harder for the engine to elide the call
  }
  console.log(sink); // observing sink further discourages full elimination
  return performance.now() - start;
}

WebAssembly’s real boundary cost

WebAssembly (Wasm) runs compiled code (from Rust, C++, and other languages) at near-native speed — genuinely valuable for CPU-intensive, self-contained work (image/video codecs, physics simulations, cryptography). Crossing the JavaScript/Wasm boundary itself has real cost:

  • Data passed across the boundary generally needs to be copied or carefully shared through linear memory — not a free operation, especially for complex object graphs.
  • DOM access is not directly available from Wasm — any DOM interaction still has to round-trip through JavaScript.
  • The engineering cost (a build toolchain, a different language, harder debugging) is real and ongoing, not a one-time investment.

Check your understanding

function Point(x, y) { this.x = x; this.y = y; } const points = []; for (let i = 0; i < 1000; i++) { const p = new Point(i, i); if (i % 2 === 0) p.tag = 'even'; // only SOME points get this extra property points.push(p); } What's the likely performance consequence of the conditional p.tag assignment?
Or reveal the answer without checking

Answer:The points array now contains objects on at least two different hidden classes, which can make code that processes points uniformly slower due to inline cache misses
Points with a tag property end up on a different hidden class than points without one. Code that later iterates points and reads a common property may see inconsistent shapes at that code location, degrading inline cache effectiveness compared to a uniform population of objects.

A benchmark reports function A is 3x faster than function B, based on a single run of each with no warm-up. What's the strongest reason to distrust this result?
Or reveal the answer without checking

Answer:A single cold run measures the engine's initial, unoptimized execution rather than steady-state performance, and provides no sense of run-to-run variance
No warm-up means the comparison likely reflects JIT warm-up differences rather than genuine steady-state performance, and a single run gives no information about variance — a real difference could be noise, or an even larger real difference could be masked by it.

Practice: warm-up

Explain, in your own words, why calling the same function repeatedly with consistently-shaped arguments tends to run faster over time than calling it with wildly varying argument shapes, connecting your answer to at least one specific concept from this lesson.

Stuck? Reveal one hint at a time.

  1. Hint 1

    Think about what the engine can safely assume, and what it has to keep re-checking, at the point in code where the function's body accesses its arguments' properties.

  2. Hint 2

    Consistent shapes let an inline cache at that access point stay "warm" — the same hidden class every time, versus constantly falling back to general lookup.

Reveal the explanation

Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.

When a function’s arguments consistently have the same hidden class (same properties, same order), the inline cache at each property-access point inside the function can stay on its fast path, recognizing the same shape every call. Wildly varying argument shapes force that inline cache to repeatedly fall back to general, slower property lookup — and in more extreme cases, can trigger deoptimization of a previously-optimized version of the function.

Practice: apply it

Identify the specific mistake in this benchmark, and rewrite it to avoid that mistake.

function benchmarkSort(arr) {
  const start = performance.now();
  arr.slice().sort((a, b) => a - b); // result never used
  return performance.now() - start;
}

console.log(benchmarkSort([5, 3, 1, 4, 2])); // called once, tiny array

Stuck? Reveal one hint at a time.

  1. Hint 1

    Two separate issues are present here: one about the benchmark's own result being usable at all, and one about run count/input realism.

  2. Hint 2

    The unused sort result is vulnerable to the same dead-code-elimination risk described in the lesson.

Reveal an improved version

Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.

function benchmarkSort(arr, iterations = 1000) {
  let sink = 0;
  const start = performance.now();
  for (let i = 0; i < iterations; i++) {
    const sorted = arr.slice().sort((a, b) => a - b);
    sink += sorted[0]; // observing part of the result discourages elimination
  }
  console.log(sink);
  return (performance.now() - start) / iterations; // average per-call time
}

// Also: a 5-element array is not representative of real sorting workloads —
// test against a realistically-sized, realistically-distributed dataset.

Summary

  • Hidden classes track an object’s shape; inline caches remember the last-seen shape at a specific access point to skip general lookup — consistent object shapes across many instances keep this optimization effective, all engine-dependent, not a language guarantee.
  • Deoptimization falls an optimized function back to slower execution when its actual usage violates the assumptions it was optimized under.
  • Generational garbage collection treats young and old objects differently, based on most objects dying young — an object retained slightly too long can be promoted and become more expensive to eventually collect than its real lifetime would suggest.
  • A trustworthy benchmark warms up, runs many iterations, uses representative input, reports variance, and defends against dead-code elimination by ensuring results are actually used.
  • WebAssembly earns its real complexity and boundary cost specifically for self-contained, CPU-intensive work — inappropriate for typical application logic or DOM-heavy code, where a naive port can end up slower than well-written JavaScript.