V8 Array Representations and Element Kinds
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?
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 Kind | Contents | Storage | Access Speed |
|---|---|---|---|
PACKED_SMI_ELEMENTS | Small integers only (31-bit) | Unboxed integers | Fastest |
PACKED_DOUBLE_ELEMENTS | Any numbers (ints or floats) | Unboxed 64-bit doubles | Fast |
PACKED_ELEMENTS | Any values (strings, objects, mixed) | Tagged pointers | Moderate |
HOLEY_SMI_ELEMENTS | Small integers with holes | Unboxed integers + hole checks | Slower |
HOLEY_DOUBLE_ELEMENTS | Numbers with holes | Unboxed doubles + hole checks | Slower |
HOLEY_ELEMENTS | Any values with holes | Tagged pointers + hole checks | Slowest |
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
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:
- Prototype chain walk: A hole means the value might exist on
Array.prototypeorObject.prototype. V8 must check. - TurboFan pessimism: Holey arrays prevent certain optimizations like bounds-check elimination.
- 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.
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 holesUse 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 do | What 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
Key Rules
- 1V8 assigns every array an element kind: PACKED_SMI (fastest) > PACKED_DOUBLE > PACKED_ELEMENTS > HOLEY variants (slowest).
- 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.
- 3Avoid new Array(n) — it creates HOLEY arrays. Use [], push(), or Array.from() instead.
- 4Keep arrays homogeneous: all integers, all floats, or all objects. Never mix types.
- 5HOLEY arrays require an extra check on every element access (hole -> prototype chain walk), even if all holes have been filled.
- 6For maximum array performance: use literal creation [1,2,3], keep element types consistent, never create holes.