Siddhant Deval
Siddhant Deval
frontend5 min read

The V8 Abyss: JavaScript Prototypes, ES6 Classes & Hidden Class Transitions

ES6 classes are syntactic sugar over JavaScript's prototype delegation chain, and understanding how V8 optimizes them through Hidden Classes and Inline Caches is critical for writing high-performance, 60fps frontend engines. This article goes to the bottom of the sea — from prototype links to V8 memory layout and shape transitions.

The V8 Abyss: JavaScript Prototypes, ES6 Classes & Hidden Class Transitions

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. But in a browser rendering 10,000 canvas shapes at 60fps, the implementation of those domain objects has measurable performance consequences. A Shape class whose properties are assigned in different orders across construction sites triggers V8's hidden class deoptimization — silently degrading from fast property access (nanoseconds) to dictionary-mode access (microseconds). At 10,000 shapes per frame, this difference is visible as a 16ms frame budget violation.

This article traces the execution path from JavaScript class syntax down to V8's memory representation — hidden classes, inline caches, and the property assignment order rules that determine whether your domain objects run at peak speed or fall into the deoptimized slow path.

Architectural Note

Canvas Studio Domain Connection: Every Shape subclass in the vector studio (RectShape, EllipseShape, TextShape) is affected by the rules in this article. Part 6 (Composition) and Part 12 (Capstone) use the hidden-class-stable construction patterns established here to maintain 60fps with large shape counts.


1. What V8 Does With Your JavaScript Objects

JavaScript objects are dynamically typed — you can add, remove, and reassign properties at any time. V8 accelerates property access despite this dynamism using a hidden layer of static type information called Hidden Classes (also called "shapes" in SpiderMonkey, "structures" in JavaScriptCore).

1.1 Hidden Classes: V8's Static Type System

Every JavaScript object has a pointer to a hidden class — an internal V8 data structure that records the set of property names and their storage offsets. When you access obj.x, V8 does not perform a hash-map lookup; it uses the hidden class to find the byte offset of x in the object's property backing store, and loads it in a single memory read.

JAVASCRIPT
const a = {};
// V8 assigns hidden class HC0: { } (empty)

a.x = 10;
// V8 creates HC1: { x: offset_0 } and transitions a from HC0 → HC1

a.y = 20;
// V8 creates HC2: { x: offset_0, y: offset_1 } and transitions a from HC1 → HC2

Each property addition creates a hidden class transition. If two objects follow the same transition sequence, they share the same hidden class — and V8 can apply the same optimization to both:

JAVASCRIPT
const b = {};
b.x = 10;
b.y = 20;
// b follows HC0 → HC1 → HC2 — same sequence as a
// b and a share HC2 — V8 can use the same Inline Cache for both

1.2 Inline Caches: The Fast Path

When V8 compiles a function that accesses obj.x, it emits a fast path called an Inline Cache (IC). The IC records: "the last time this property access ran, the receiver had hidden class HC2, and x was at offset 0." On subsequent calls with the same hidden class, the IC skips the property lookup entirely.

JAVASCRIPT
function getX(obj) { return obj.x; }

getX(a); // IC: monomorphic — HC2, offset 0 (fastest)
getX(b); // IC: monomorphic — still HC2 (same hidden class) — fast path reused

This is why objects that share a hidden class are faster than objects that do not — the IC for every operation on them is monomorphic (one hidden class, maximum optimization).


2. How Property Assignment Order Breaks Hidden Class Sharing

The hidden class depends on the order in which properties are assigned, not just the set of properties. Two objects with the same properties assigned in different orders have different hidden classes:

JAVASCRIPT
// ❌ Different assignment order → different hidden classes → IC becomes polymorphic
const shapeA = {};
shapeA.x = 0; shapeA.y = 0; shapeA.width = 100; shapeA.height = 50;
// Hidden class: { x, y, width, height } — call it HC_XYWH

const shapeB = {};
shapeB.width = 100; shapeB.height = 50; shapeB.x = 0; shapeB.y = 0;
// Hidden class: { width, height, x, y } — call it HC_WHXY — DIFFERENT from HC_XYWH

function render(shape) {
  return shape.x + shape.y; // IC observed both HC_XYWH and HC_WHXY
  // IC is now POLYMORPHIC — slower
}

render(shapeA); // IC caches HC_XYWH
render(shapeB); // IC sees different HC — becomes polymorphic (up to 4 hidden classes)
// If more than 4 hidden classes are seen, IC goes MEGAMORPHIC — no caching at all

In a canvas studio with 10,000 shapes, if RectShape and EllipseShape both have x, y, width, height but assign them in different orders (because they were written by different developers), every render(shape) call in the render loop runs through a polymorphic IC instead of a monomorphic one. The performance difference at 10,000 shapes: 2ms vs 8ms per frame.


3. ES6 Classes: Guaranteed Hidden Class Stability

ES6 class syntax solves the property order problem by guaranteeing that properties defined in the constructor are always assigned in declaration order:

TYPESCRIPT
// ✅ ES6 class — properties always assigned in the same order, guaranteed
class Shape {
  x: number;
  y: number;
  width: number;
  height: number;

  constructor(x: number, y: number, w: number, h: number) {
    // V8 sees: always x first, then y, then width, then height
    // Every Shape instance gets the same hidden class
    this.x = x;
    this.y = y;
    this.width = w;
    this.height = h;
  }
}

const rect  = new Shape(0, 0, 100, 50);
const ellipse = new Shape(50, 50, 80, 80);
// Both share the same hidden class — all ICs are monomorphic

The TypeScript class declaration (x: number; in the class body) generates a constructor that assigns properties in declaration order. V8 processes the constructor once on first instantiation and creates a fixed hidden class chain. Every subsequent new Shape(...) follows the same chain — guaranteed monomorphic ICs for all property accesses.

3.1 The Prototype Chain: How Class Inheritance Maps to V8

TYPESCRIPT
class BaseShape {
  id: ShapeId;
  x: number;
  y: number;

  constructor(id: ShapeId, x: number, y: number) {
    this.id = id; this.x = x; this.y = y;
  }

  move(dx: number, dy: number): void { this.x += dx; this.y += dy; }
}

class RectShape extends BaseShape {
  width: number;
  height: number;

  constructor(id: ShapeId, x: number, y: number, w: number, h: number) {
    super(id, x, y); // ← Assigns id, x, y first (BaseShape constructor order)
    this.width = w;  // ← Then width, height (RectShape additions)
    this.height = h;
  }
}

V8's hidden class chain for RectShape:

HC0: {}
HC1: { id: offset_0 }
HC2: { id: offset_0, x: offset_1 }
HC3: { id: offset_0, x: offset_1, y: offset_2 }      ← BaseShape complete
HC4: { id: offset_0, x: offset_1, y: offset_2, width: offset_3 }
HC5: { id: offset_0, x: offset_1, y: offset_2, width: offset_3, height: offset_4 }  ← RectShape complete

Every new RectShape(...) traverses exactly HC0 → HC5. All RectShape instances share HC5. The render(shape) function accessing shape.x on any RectShape instance gets a monomorphic IC hitting x at offset_1 every time.


4. The #private Fields Performance Consideration

ECMAScript private fields (#field) use a different storage mechanism than public properties. They are stored in a separate slot that is not part of the property backing store inspected by the regular hidden class chain. This has a subtle performance implication:

TYPESCRIPT
class ShapeWithPrivate {
  #id: ShapeId;    // Private field — stored in WeakMap-like internal slot
  #x: number;
  #y: number;
  width: number;   // Public field — part of hidden class
  height: number;  // Public field — part of hidden class

  constructor(id: ShapeId, x: number, y: number, w: number, h: number) {
    this.#id = id; this.#x = x; this.#y = y;
    this.width = w; this.height = h;
  }

  get x(): number { return this.#x; }
  get y(): number { return this.#y; }
}

In V8 (from Node 18+ / Chrome 94+), #private field access is optimized via a dedicated IC path that is comparable to public property access for monomorphic cases. The overhead of #private vs public is negligible in modern V8 — the private field check (verifying the receiver is a genuine instance of the class) adds ~1ns per access, irrelevant at any scale that is not called billions of times per second.

Pro Tip & Optimization

Use #private fields for domain invariant enforcement (Part 4 covers this fully). Do not use public fields to avoid a theoretical private-field performance overhead — V8's private field optimization has been production-ready since Chrome 94. The encapsulation guarantee is worth the ~1ns overhead.


5. The Factory Function vs. Class Hidden Class Problem

Factory functions are popular in JavaScript for their closure-based encapsulation. But they produce a different hidden class per invocation — no hidden class sharing across instances:

TYPESCRIPT
// ❌ Factory function — each instance gets a unique hidden class
function makeShape(x: number, y: number, w: number, h: number) {
  // A new closure is created per call — a new object literal
  return {
    x,    // Each returned object literal creates a fresh hidden class chain
    y,    // The property assignments here are identical...
    width: w,
    height: h,
    move(dx: number, dy: number) {
      this.x += dx; // 'this' here has the object's HC — but methods defined inline
      this.y += dy; // create new function objects per factory call
    }
  };
}

const r1 = makeShape(0, 0, 100, 50);
const r2 = makeShape(10, 10, 80, 80);

// r1 and r2 actually DO share a hidden class here because the object literal
// has the same property names in the same order — V8 optimizes this specific case.
// But the 'move' method is a NEW function object per instance:
r1.move === r2.move // false — different function objects
// This means the IC for move() observes different function objects
// and cannot inline-cache the method body as efficiently as prototype methods

The critical difference is method sharing. With classes, r1.move and r2.move are the same function object on Shape.prototype. V8 can compile and optimize that function once. With factory functions, each instance has its own move function object — V8 sees it as a potentially different function each time and cannot cache the optimization as aggressively.

For the canvas studio with 10,000 shapes, each with move, resize, rotate, getBounds, toData, and hitTest methods: classes share 6 methods across 10,000 instances (6 function objects total). Factory functions create 60,000 function objects — one per method per instance. Memory difference: ~30KB vs ~1.8MB for method storage alone.

TYPESCRIPT
// ✅ Class — methods shared via prototype (6 function objects total)
class RectShape {
  move(dx: number, dy: number) { /* ... */ }
  resize(dw: number, dh: number) { /* ... */ }
  rotate(angle: number) { /* ... */ }
  getBounds(): Rect { /* ... */ }
  toData(): RectShapeData { /* ... */ }
  hitTest(p: Point): boolean { /* ... */ }
}

const shapes = Array.from({ length: 10_000 }, () => new RectShape(...));
// RectShape.prototype.move shared by all 10,000 instances
// 6 function objects total — one per method, on the prototype

6. Hidden Class Deoptimization Triggers

These are the four most common patterns that trigger hidden class instability in domain-heavy frontend code:

6.1 Conditional Property Addition

TYPESCRIPT
// ❌ Conditional property breaks the hidden class chain
class Shape {
  constructor(x: number, y: number, label?: string) {
    this.x = x;
    this.y = y;
    if (label) this.label = label; // ← Some instances have 'label', some don't
    // Two different hidden classes: { x, y } and { x, y, label }
  }
}

Fix: always assign all properties in the constructor with defaults:

TYPESCRIPT
// ✅ All properties assigned in every constructor call — one hidden class
constructor(x: number, y: number, label: string = '') {
  this.x = x; this.y = y; this.label = label; // Always assigned
}

6.2 Post-Construction Property Addition

TYPESCRIPT
// ❌ Adding property after construction creates a new hidden class
const shape = new RectShape(0, 0, 100, 50);
shape.debugColor = '#ff0000'; // ← Added post-construction — new hidden class
// Now this shape has a different HC from all other RectShapes — polymorphic ICs

Fix: declare debugColor in the class body with a default:

TYPESCRIPT
class RectShape extends BaseShape {
  debugColor: string = '';  // Always present — same HC as all other RectShapes
}

6.3 delete on Object Properties

TYPESCRIPT
// ❌ delete causes V8 to switch the object to dictionary mode (slowest)
delete shape.x; // V8 cannot maintain the hidden class without 'x'
// shape is now a dictionary object — hash-map lookup for all properties

Fix: never delete properties from domain objects. Set them to null, undefined, or a sentinel value instead.

6.4 Mixed Type Assignments (Type Confusion)

TYPESCRIPT
// ❌ Changing a property's type causes IC deoptimization
class Shape {
  value: number | string;  // Two possible types
  constructor(v: number | string) { this.value = v; }
}

function processValue(shape: Shape) {
  return shape.value + 1;
}

processValue(new Shape(10));    // IC: 'value' is a number
processValue(new Shape('abc')); // IC: 'value' is now a string — IC becomes polymorphic

Fix: maintain type stability in properties. If a field can be number or string, model them as separate typed properties.


7. Practical Shapes for the Vector Studio

Applying all rules to the canvas studio's shape hierarchy:

TYPESCRIPT
// src/domain/shapes/BaseShape.ts — hidden-class-stable base
export abstract class BaseShape {
  // All properties declared with explicit types and defaults
  // Assigned in this exact order in every subclass
  readonly id: ShapeId;
  x: number;
  y: number;
  rotation: number;       // Always present — even if 0
  opacity: number;        // Always present — even if 1
  isLocked: boolean;      // Always present — even if false
  isVisible: boolean;     // Always present — even if true

  protected constructor(
    id: ShapeId,
    x: number, y: number,
    rotation = 0,
    opacity = 1,
    isLocked = false,
    isVisible = true,
  ) {
    // Assignment order matches declaration order — guaranteed stable hidden class
    this.id = id;
    this.x = x;
    this.y = y;
    this.rotation = rotation;
    this.opacity = opacity;
    this.isLocked = isLocked;
    this.isVisible = isVisible;
  }

  abstract getBounds(): Rect;
  abstract hitTest(p: Point): boolean;
  abstract toData(): BaseShapeData;

  move(dx: number, dy: number): void { this.x += dx; this.y += dy; }
}

// src/domain/shapes/RectShape.ts
export class RectShape extends BaseShape {
  width: number;   // Subclass-specific properties declared after super's
  height: number;  // Always in this order — every RectShape gets the same HC

  constructor(id: ShapeId, x: number, y: number, w: number, h: number) {
    super(id, x, y); // Assigns: id, x, y, rotation(0), opacity(1), isLocked(false), isVisible(true)
    this.width = w;  // Then: width, height
    this.height = h;
    // Final HC: { id, x, y, rotation, opacity, isLocked, isVisible, width, height }
    // Shared by ALL RectShape instances
  }

  getBounds(): Rect { return rect(this.x, this.y, this.width, this.height); }
  hitTest(p: Point): boolean { return containsPoint(this.getBounds(), p); }
  toData(): RectShapeData { return { type: 'rect', id: this.id, x: this.x, y: this.y, width: this.width, height: this.height }; }
}

Summary

Concept Rule
Hidden Classes V8's internal static type for objects; determines property access speed
Property Order Hidden class depends on assignment order — same set, different order = different HC
Class vs Factory class methods are shared on prototype (N instances, 1 function object); factory methods create N function objects
#private Performance Comparable to public access in V8 ≥ Chrome 94; use for encapsulation without penalty
Conditional Properties Conditional property addition creates HC branches — always assign with defaults
delete Converts object to dictionary mode — never delete from domain objects
Type Stability Changing a property's type deoptimizes ICs — maintain consistent types per property

What's Next

Part 4 examines encapsulation in depth — the difference between TypeScript private (erased at compile time) and ECMAScript #private (enforced by the runtime), and how each affects domain invariant protection, V8 performance, and test design in the canvas studio.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#JavaScript#TypeScript#V8#Prototypes#Performance#OOP Internals#Memory Management
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.