Skip to content

V8 Array Representations and Element Kinds

advanced9 min read

The Array That Got Permanently Slower

This might be the most counterintuitive thing about V8 arrays:

const fast = [1, 2, 3, 4, 5];
const slow = [1, , 3, 4, 5]; // Note the hole at index 1

Both arrays have the same length. Both contain mostly the same values. But slow is permanently and measurably slower to iterate -- and there's no way to fix it. Not by filling the hole. Not by reassigning the value. Once V8 labels an array as "holey," it stays holey for its entire lifetime. Let that sink in.

const a = [1, , 3]; // HOLEY_SMI_ELEMENTS (forever)
a[1] = 2;           // Still HOLEY_SMI_ELEMENTS
// a is now [1, 2, 3] — no holes — but V8 remembers

This is the element kind lattice, and it only goes in one direction: down.

V8's Internal Array Types

So what's actually going on under the hood?

Mental Model

Think of element kinds as security clearance levels that only get revoked, never restored. A "PACKED_SMI" array has the highest clearance — V8 trusts it completely (all integers, no gaps) and gives it the fastest access path. The moment you introduce a float, it drops to "PACKED_DOUBLE" — still fast, but slightly less trusted. Add a string? Down to "PACKED_ELEMENTS" — generic, slower. Create a hole? Every level gains the "HOLEY" prefix — V8 will never fully trust this array again, and adds extra checks on every access.

V8 classifies every array into one of these element kinds:

Element KindContentsStorageAccess Speed
PACKED_SMI_ELEMENTSSmall integers only (31-bit)Unboxed integersFastest
PACKED_DOUBLE_ELEMENTSAny numbers (ints or floats)Unboxed 64-bit doublesFast
PACKED_ELEMENTSAny values (strings, objects, mixed)Tagged pointersModerate
HOLEY_SMI_ELEMENTSSmall integers with holesUnboxed integers + hole checksSlower
HOLEY_DOUBLE_ELEMENTSNumbers with holesUnboxed doubles + hole checksSlower
HOLEY_ELEMENTSAny values with holesTagged pointers + hole checksSlowest

The Lattice: One-Way Transitions

Here's the thing that catches everyone off guard. Element kind transitions only go downward. Once you cross a threshold, you can never go back:

PACKED_SMI_ELEMENTS
    |           \
    v            v
PACKED_DOUBLE_ELEMENTS    HOLEY_SMI_ELEMENTS
    |           \            |            \
    v            v           v             v
PACKED_ELEMENTS    HOLEY_DOUBLE_ELEMENTS
                        |
                        v
                   HOLEY_ELEMENTS

Every arrow is one-way. Let's trace transitions:

const arr = [1, 2, 3];     // PACKED_SMI_ELEMENTS
arr.push(4.5);              // -> PACKED_DOUBLE_ELEMENTS (float introduced)
arr.push("hello");          // -> PACKED_ELEMENTS (non-number introduced)
// arr is now [1, 2, 3, 4.5, "hello"] — PACKED_ELEMENTS forever
Execution Trace
Create
[1, 2, 3]
PACKED_SMI_ELEMENTS — all small integers
Push 4.5
[1, 2, 3, 4.5]
PACKED_DOUBLE_ELEMENTS — float triggers transition
Push 'hi'
[1, 2, 3, 4.5, 'hi']
PACKED_ELEMENTS — string triggers transition
Remove 'hi'
[1, 2, 3, 4.5]
Still PACKED_ELEMENTS — transitions never reverse
Replace all
[10, 20, 30, 40]
Still PACKED_ELEMENTS — even all-integer content can't restore SMI kind

Why HOLEY Is Permanently Slower

You might be thinking, "how much slower can a hole check really be?" Turns out, it's not just the check itself. When V8 encounters a holey array, every element access requires an extra check:

// PACKED access: just read the slot
function sumPacked(arr) {
  // Generated code: load arr[i] directly — no checks needed
  // V8 knows every slot has a valid value
}

// HOLEY access: check for hole, then read
function sumHoley(arr) {
  // Generated code:
  //   1. Load arr[i]
  //   2. Check if value is "the_hole" sentinel
  //   3. If hole: walk prototype chain (Array.prototype, Object.prototype)
  //   4. If not hole: use the value
}

The hole check itself is fast (one comparison), but it has cascading effects:

  1. Prototype chain walk: A hole means the value might exist on Array.prototype or Object.prototype. V8 must check.
  2. TurboFan pessimism: Holey arrays prevent certain optimizations like bounds-check elimination.
  3. Branch prediction: The extra branch for hole checking can cause CPU pipeline stalls in tight loops.
// You can actually put values on the prototype and holes will find them
Array.prototype[2] = "surprise";
const arr = [1, 2, , 4]; // HOLEY_SMI_ELEMENTS
console.log(arr[2]); // "surprise" — found on prototype!

This is why V8 can't skip the hole check — holes have observable semantics.

Common Trap

new Array(100) creates a holey array. Even if you immediately fill it, it stays holey:

// HOLEY_SMI_ELEMENTS — the damage is done at creation
const arr = new Array(100);
for (let i = 0; i < 100; i++) arr[i] = i;
// arr is full, zero holes, but V8 still treats it as HOLEY

// PACKED_SMI_ELEMENTS — no holes ever existed
const arr2 = [];
for (let i = 0; i < 100; i++) arr2.push(i);
// arr2 is packed because push never creates holes

Use Array.from() or push() to build arrays. Avoid pre-sizing with new Array(n).

SMI vs Double vs Elements

PACKED_SMI_ELEMENTS: The Speed King

This is the best you can get. V8 stores Small Integers (Smis) unboxed -- no heap allocation, no pointer indirection. On 64-bit systems, a Smi is a 31-bit signed integer (V8 uses 1 bit as a tag to distinguish Smis from pointers).

// PACKED_SMI_ELEMENTS — stored as raw integers
const ages = [25, 30, 22, 28, 35]; // No boxing, no heap allocation per element

Range: -2^30 to 2^30 - 1 (approximately -1 billion to +1 billion). Numbers outside this range become HeapNumbers and push the array to PACKED_DOUBLE_ELEMENTS.

const arr = [1, 2, 3]; // PACKED_SMI_ELEMENTS
arr.push(2 ** 31);     // -> PACKED_DOUBLE_ELEMENTS (too large for Smi)

PACKED_DOUBLE_ELEMENTS: Unboxed Floats

When an array contains any float (or integers too large for Smi), V8 stores all elements as unboxed 64-bit doubles. No heap allocation per element — just a flat buffer of doubles.

// PACKED_DOUBLE_ELEMENTS — flat buffer of 64-bit floats
const coords = [1.5, 2.7, 3.14, 0.0, -1.0];

This is still very fast — modern CPUs handle double arrays efficiently. The main cost vs. SMI is that doubles take 8 bytes per element instead of 4 (on 64-bit, after pointer compression).

PACKED_ELEMENTS: Generic Tagged Values

When an array contains non-numeric values (strings, objects, mixed types), V8 stores tagged pointers. Each element is a pointer to a heap object.

// PACKED_ELEMENTS — array of tagged pointers
const names = ["Alice", "Bob", "Charlie"];
const mixed = [1, "two", { three: 3 }, null]; // Also PACKED_ELEMENTS

This is the slowest packed variant because V8 must dereference pointers and can't make type assumptions about elements.

Production Scenario: The Accidental Holey Array

This one shows up constantly in production code. A team builds a data processing pipeline:

function processChunk(size) {
  // Pre-allocate for "performance" — creates HOLEY array
  const results = new Array(size);

  for (let i = 0; i < size; i++) {
    results[i] = computeResult(i);
  }

  // This loop is slower than necessary because results is HOLEY
  let total = 0;
  for (let i = 0; i < results.length; i++) {
    total += results[i]; // Hole check on every access
  }
  return total;
}

They assumed new Array(size) was faster because it "pre-allocates." Sounds reasonable, right? In reality, it creates a holey array that's slower to iterate.

The fix:

function processChunk(size) {
  const results = [];

  for (let i = 0; i < size; i++) {
    results.push(computeResult(i));
  }

  // Now results is PACKED — no hole checks
  let total = 0;
  for (let i = 0; i < results.length; i++) {
    total += results[i]; // Direct access, no hole check
  }
  return total;
}

The push() approach creates a PACKED array. The team measured a 15% improvement in their processing loop.

How to check an array's element kind

In Node.js, you can use V8's internal %DebugPrint to inspect element kinds:

node --allow-natives-syntax -e "
const a = [1, 2, 3];
%DebugPrint(a);
"

Look for the elements field in the output:

- elements: 0x... <FixedArray[3]> [PACKED_SMI_ELEMENTS]

In Chrome DevTools, you can see element kinds in the Memory panel's heap snapshot by inspecting array objects.

You can also use %HasSmiElements(arr), %HasDoubleElements(arr), %HasObjectElements(arr), %HasHoleyElements(arr) with --allow-natives-syntax in tests.

Array Method Considerations

One more thing worth knowing: some array methods preserve element kinds, others don't:

const smis = [1, 2, 3, 4, 5]; // PACKED_SMI_ELEMENTS

// Preserves kind: map with integer result
smis.map(x => x * 2); // PACKED_SMI_ELEMENTS

// Transitions: map with float result
smis.map(x => x * 1.1); // PACKED_DOUBLE_ELEMENTS

// Transitions: filter may create sparse result internally
// (V8 is smart about this — filter typically preserves packed)

// Dangerous: Array.from with mapFn returning different types
Array.from(smis, (x, i) => i === 0 ? "first" : x); // PACKED_ELEMENTS

Common Mistakes

What developers doWhat they should do
Using new Array(n) to pre-allocate arrays for performance
new Array(n) creates a HOLEY array. Even filling every slot doesn't remove the holey flag
Use [] with push() or Array.from() — both create packed arrays
Creating sparse arrays with literal holes: [1, , 3]
Literal holes create HOLEY element kinds. undefined is a value — it doesn't create a hole
Always provide values for every index: [1, undefined, 3]
Mixing types in arrays that will be iterated in hot loops
Mixed types push to PACKED_ELEMENTS (slowest packed kind). Homogeneous arrays get specialized fast paths
Keep arrays homogeneous: all integers, all floats, or all objects — never mixed
Adding a single non-number element to a number array
One string or object element transitions the entire array to PACKED_ELEMENTS permanently
If an array starts as numbers, keep it numbers. Use a separate array for metadata

Quiz: Element Kind Transitions

Quiz
What is the element kind of arr after this code?
Quiz
Which array creation pattern results in PACKED_DOUBLE_ELEMENTS?

Key Rules

Key Rules
  1. 1V8 assigns every array an element kind: PACKED_SMI (fastest) > PACKED_DOUBLE > PACKED_ELEMENTS > HOLEY variants (slowest).
  2. 2Element kind transitions are one-way. Once an array goes from PACKED_SMI to PACKED_DOUBLE, or from PACKED to HOLEY, it never goes back.
  3. 3Avoid new Array(n) — it creates HOLEY arrays. Use [], push(), or Array.from() instead.
  4. 4Keep arrays homogeneous: all integers, all floats, or all objects. Never mix types.
  5. 5HOLEY arrays require an extra check on every element access (hole -> prototype chain walk), even if all holes have been filled.
  6. 6For maximum array performance: use literal creation [1,2,3], keep element types consistent, never create holes.