DSA DSA In Depth
0%
case v8arraysproduction-internals

Production Case Study: How V8 Optimizes JS Arrays (SMI, Double & Holey Elements)

7 min read Module 1.1: Asymptotic Analysis, Recurrences & Memory Hierarchy Updated Oct 23, 2026
📄 Cheat Sheet
V8 Internals for JavaScript Developers — Elements Kinds & Hidden Classes
CSSConf Australia (Mathias Bynens) V8 Internals for JavaScript Developers — Elements Kinds & Hidden Classes Watch on YouTube ↗

V8 core engineer Mathias Bynens explains how V8 tracks Elements Kinds on hidden classes and why creating array holes or mixing types triggers irreversible lattice transitions.

0:00 / --:--

Inside V8's Elements Kinds Lattice: SMI, Double & Tagged Pointers

To execute array loops at near-native C++ speeds while preserving full ECMAScript semantics, V8 classifies the backing store of every JSArray along two orthogonal dimensions:

  1. Payload Representation (SMI vs DOUBLE vs Generic ELEMENTS):
    • SMI_ELEMENTS (Small Integers): Stores 31-bit signed integers (on 64-bit V8 with pointer compression) directly inside the backing store as immediate tagged words (least significant bit 0), requiring zero heap box allocations.
    • DOUBLE_ELEMENTS (Unboxed IEEE-754 float64): As soon as you push 3.14 or NaN into an SMI array, V8 reallocates the backing store into a contiguous C++ double[] array (8 bytes per element) so TurboFan can emit raw SSE/AVX floating-point instructions without pointer dereferencing.
    • ELEMENTS (Tagged Object Pointers): Pushing a string, object, or function forces V8 to box all numbers and store an array of tagged heap pointers—reintroducing pointer chasing!
  2. Density Invariant (PACKED vs HOLEY):
    • PACKED_*: Guarantees that every index $i \in [0, \text{length} - 1]$ contains an initialized value. Reading a[i] requires only a single bounds check i < a.length and a direct memory load.
    • HOLEY_*: Created when you allocate new Array(1000), write past the end a[10] = 1, or execute delete a[2]. Uninitialized slots contain a special sentinel (the_hole). Because JavaScript mandates that reading a missing property must walk up Array.prototype and Object.prototype, reading the_hole in a HOLEY array forces V8 to check prototype guards or bail out of JIT fast paths!
V8 Elements Kind Backing Store Memory Layout CPU Cache Locality Bounds & Hole Checks on a[i] Triggering JS Code
PACKED_SMI_ELEMENTS Contiguous immediate 31-bit ints 16 ints / 64B line 1 bounds check (i < len) const a = [1, 2, 3];
PACKED_DOUBLE_ELEMENTS Contiguous unboxed 64-bit IEEE-754 8 doubles / 64B line 1 bounds check (i < len) a.push(3.14);
PACKED_ELEMENTS Contiguous tagged pointers to Heap Pointer chase per read 1 bounds check + type tag check a.push("str");
HOLEY_DOUBLE_ELEMENTS IEEE-754 + the_hole NaN bit-pattern 8 doubles / 64B line Bounds + the_hole NaN check + prototype guard const a = [1.5, , 3.5];
DICTIONARY_ELEMENTS Hash table (NumberDictionary) Hash probe + scattered Full hash table lookup per index const a = []; a[1000000] = 1;

Formal Specification: The Irreversible Lattice Monotonicity Invariant

Transitions in V8's Elements Kind lattice are strictly one-way (monotonic toward more general types):

$$\texttt{PACKED\_SMI} \longrightarrow \texttt{PACKED\_DOUBLE} \longrightarrow \texttt{PACKED\_ELEMENTS}$$

$$\downarrow \hspace{9.5em} \downarrow \hspace{9.5em} \downarrow$$

$$\texttt{HOLEY\_SMI} \;\longrightarrow\; \texttt{HOLEY\_DOUBLE} \;\longrightarrow\; \texttt{HOLEY\_ELEMENTS} \longrightarrow \texttt{DICTIONARY}$$

Why does V8 never transition backwards (e.g., from HOLEY_SMI back to PACKED_SMI after all holes are filled, or from PACKED_DOUBLE back to PACKED_SMI after popping the float)? Because checking whether all $N$ elements are integers or hole-free on every mutation would turn an $O(1)$ array write into an $O(N)$ scan, and oscillating states would cause infinite JIT deoptimization loops in TurboFan!

Visualizing V8 Backing Store Layouts & The Irreversible Lattice

The SVG diagram below traces the exact transition lattice and shows why PACKED_DOUBLE_ELEMENTS stores raw 64-bit IEEE-754 floats inline whereas HOLEY_ELEMENTS scatters boxed HeapNumber objects across the V8 heap.

PACKED_SMI_ELEMENTS [1, 2, 3] (Unboxed 31b Ints) PACKED_DOUBLE_ELEMENTS + push(3.14) (Raw IEEE-754) PACKED_ELEMENTS + push("x") (Boxed Pointers) HOLEY_SMI_ELEMENTS [1, , 3] or new Array(n) HOLEY_DOUBLE_ELEMENTS Hole = Special Signaling NaN HOLEY_ELEMENTS Pointer Chase + Hole Checks DICTIONARY_ELEMENTS (Slow Hash Table Mode: a[100000] = 1)
flowchart LR subgraph FastPath["PACKED_DOUBLE / TypedArray Fast Path"] F1["Check i < a.length (Hoisted by BCE)"] --> F2["Single 64-bit XMM Load from Base + 8*i"] end subgraph HoleyPath["HOLEY_ELEMENTS Slow Path"] H1["Check i < a.length"] --> H2["Load Tagged Ptr"] H2 --> H3{"Ptr == the_hole ?"} H3 -- "No" --> H4["Unbox HeapNumber (2nd Memory Read!)"] H3 -- "Yes" --> H5["Walk Array.prototype → Object.prototype"] end
CPU 64-Byte Cache Line & Stride Locality Simulator
64B Cache Line (8 × 8B)

Compare contiguous stride-1 traversal (1 cache miss per 8 elements, ~4 cycles L1 hit) against stride-8 and heap pointer-chasing (100% cache misses, ~200 cycles RAM stall).

Step 1 / 12
Custom Input:
Timeline

Invariant & State

Access index [0] (Cache Line #0): Cold Cache Line Miss (+DRAM stall)
📊 Live Magnitude & Pointer Stage — CPU 64-Byte Cache Line & Stride Locality Simulator Active Pivot Match/Sorted
MISS (D 12 [0x1000] In L1 45 [0x1008] In L1 78 [0x1010] In L1 23 [0x1018] In L1 56 [0x1020] In L1 89 [0x1028] In L1 34 [0x1030] In L1 67 [0x1038] Cold 91 [0x1040] Cold 14 [0x1048] Cold 29 [0x1050] Cold 63 [0x1058] Cold 82 [0x1060] Cold 37 [0x1068] Cold 50 [0x1070] Cold 74 [0x1078]
Cache Line #0 (64 Bytes = 8 × int64) — RESIDENT IN L1 CACHE
0x1000 12 MISS (DRAM)
0x1008 45 In L1
0x1010 78 In L1
0x1018 23 In L1
0x1020 56 In L1
0x1028 89 In L1
0x1030 34 In L1
0x1038 67 In L1
Cache Line #1 (64 Bytes = 8 × int64) — NOT IN L1
0x1040 91 Cold
0x1048 14 Cold
0x1050 29 Cold
0x1058 63 Cold
0x1060 82 Cold
0x1068 37 Cold
0x1070 50 Cold
0x1078 74 Cold
Accesses
1 / 12
L1 Cache Hits
0
Cache Misses
1
Cumulative Cycles
60 cy (~60.0 cy/op)
[Step 1] Read A[0] = 12 at 0x1000 -> CACHE LINE FILL (fetched 64B block)

Hardware Reality: Why PACKED_DOUBLE to PACKED_ELEMENTS Copies the Entire Array

Unlike transitioning from PACKED_SMI to HOLEY_SMI (which only updates the Hidden Class Map metadata pointer because both store 32-bit/64-bit tagged words), transitioning from PACKED_DOUBLE_ELEMENTS to PACKED_ELEMENTS (e.g., pushing "hello" or undefined into a float array) changes the physical bit-pattern of every slot from raw 64-bit IEEE-754 floats to tagged heap pointers! V8 must immediately allocate $N$ separate HeapNumber objects on the V8 heap and copy all $N$ floats into them—turning a single .push() call into an unexpected $O(N)$ allocation and GC pressure spike.

Writing V8-Friendly High-Performance JavaScript & TypedArrays

The JavaScript patterns below demonstrate how to preserve PACKED elements kinds and when to bypass V8's lattice entirely using Float64Array / Int32Array.

// V8 Performance Engineering: Avoiding Irreversible Lattice Degradations

// 1. ANTI-PATTERN: Pre-allocating with new Array(N) permanently brands the array
// as HOLEY_SMI_ELEMENTS, even after you fill every single slot!
function sumHoleyAntiPattern(n) {
  const arr = new Array(n); // Immediate transition to HOLEY_SMI_ELEMENTS!
  for (let i = 0; i < n; i++) arr[i] = i;
  let sum = 0;
  for (let i = 0; i < arr.length; i++) sum += arr[i];
  return sum;
}

// 2. PRODUCTION PATTERN A: Start with [] (PACKED_SMI_ELEMENTS) and push sequentially,
// OR for fixed-size numeric buffers, use TypedArrays (Int32Array / Float64Array)
// which CANNOT become holey and CANNOT box elements!
function sumPackedOrTyped(n) {
  const vec = new Int32Array(n); // Guaranteed contiguous unboxed 32-bit ints (16 per cache line)
  for (let i = 0; i < n; i++) vec[i] = i;
  let sum = 0;
  for (let i = 0; i < vec.length; i++) sum += vec[i];
  return sum;
}

// 3. ANTI-PATTERN: Storing -0 or NaN or undefined in an integer counter array
// Note: In V8, -0 cannot be represented as an SMI (because 0 is already an SMI),
// so arr.push(-0) silently transitions PACKED_SMI_ELEMENTS -> PACKED_DOUBLE_ELEMENTS!

Failure Modes & Adversarial Pitfalls

  • Out-of-Bounds Reads (i <= arr.length) Trigger Prototype Chain Walks: Accidentally writing for (let i = 0; i <= arr.length; i++) reads arr[arr.length] on the final iteration, which returns undefined only after V8 checks the NoElementsProtector cell or walks Array.prototype and Object.prototype. Never read past arr.length - 1 to terminate a loop!
  • Mutating Array.prototype Deoptimizes Every HOLEY Array in the Entire Process: V8 uses a global speculation guard called the NoElementsProtector to skip prototype walks on HOLEY array reads, assuming Array.prototype has no indexed properties. If any third-party library executes Array.prototype[0] = "poison", V8 permanently invalidates the global protector for the lifetime of the V8 Isolate, slowing down every HOLEY array access across the entire application!
Check yourself Question 1 of 4
Concept Check V8 Elements Kind Monotonic Lattice Invariant recall

In Chrome V8's JavaScript engine, what happens to an array's internal `Elements Kind` when you execute `const a = [1, 2, 3]; a.push(3.14); a.pop();`?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Why does reading an element from a `HOLEY` array in V8 normally avoid walking `Array.prototype` and `Object.prototype`, and what happens across the entire V8 Isolate if any script executes `Array.prototype[0] = 'fallback'`?

Key Takeaways

V8 tracks each JSArray's layout via an Elements Kind lattice along two axes: Payload (SMI, DOUBLE, Generic Tagged Elements) and Density (PACKED vs HOLEY).
Lattice transitions are strictly one-way (irreversible): new Array(N) starts as HOLEY_SMI_ELEMENTS and can never become PACKED_SMI_ELEMENTS even after every slot is filled.
Pushing -0 or a float into a PACKED_SMI array reallocates it as PACKED_DOUBLE; pushing an object into PACKED_DOUBLE boxes all N numbers on the heap.
For numeric DSA workloads in JavaScript/TypeScript, Int32Array and Float64Array bypass the Elements Kind lattice completely, guaranteeing contiguous unboxed cache-line storage.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue