JavaScript - Holey Arrays vs. Packed Arrays
JavaScript arrays are not always arrays. Sometimes they are C-structs, sometimes they are Hash Maps. You control which one you get, often without realizing it.
You see this code every day. You might have written it this morning.
const arr = [1, 2, 3];
// ... some logic happens ...
arr[100] = 4; // The crimeTo a junior developer, this is the beauty of JavaScript, dynamic, flexible arrays that expand on demand. No malloc, no fixed sizes, no segmentation faults.
To a V8 engineer, this is a De-optimization Event.
By writing to index 100 of a 3-item array, you didn’t just add a number. You forced the engine to fundamentally alter the underlying storage structure of that object. You forced V8 to downgrade your high-performance memory block into a slow, lookup-heavy chain.
JavaScript arrays are not always arrays. Sometimes they are C-structs, sometimes they are Hash Maps. You control which one you get, often without realizing it.
Elements Kinds
V8 tracks the shape of arrays using a system called Elements Kinds. There are over 20 distinct kinds, but for performance tuning, we care about the war between PACKED and HOLEY.
The PACKED_SMI_ELEMENTS
When you initialize const arr = [1, 2, 3], V8 loves you. It detects that all elements are Small Integers (SMI) and the array is dense (no gaps).
Under the hood, this looks strikingly similar to a C++ std::vector or a raw C array.
Memory - Contiguous.
Access - Simple pointer arithmetic.
Base Address + (Index * 4).Speed - O(1). Blazing fast.
This is PACKED_SMI_ELEMENTS. This is where you want to stay.
The Downgrade
When you execute arr[100] = 4, you create a “hole”, a massive gap from index 3 to 99 where no data exists. V8 cannot rely on pointer arithmetic anymore because reading memory at offset 50 would return garbage or uninitialized memory.
The engine must reclassify the array. It transitions from PACKED_SMI_ELEMENTS to HOLEY_SMI_ELEMENTS (or HOLEY_ELEMENTS if types are mixed).
This is the most critical concept to internalize: Elements Kinds transitions are generally irreversible.
Once an array becomes HOLEY, it stays HOLEY. Even if you backfill indices 3 through 99 later, V8 will not waste CPU cycles attempting to detect if the array is “healed” to switch it back to PACKED. You have permanently tainted the array’s performance profile for its entire lifecycle.
Why Holey is Slow?
Why does a hole matter? Why can’t the engine just return undefined for the gaps?
Because in JavaScript, undefined is a value. A hole is the absence of a property.
When you access arr[50] in a HOLEY array:
V8 checks if index 50 exists on the array itself. It finds a hole.
V8 cannot simply return
undefinedyet. It must walk up the Prototype Chain (Array.prototype, thenObject.prototype) to ensure that index 50 isn’t defined as a getter or value somewhere higher up.
You turned a single memory fetch into a tree traversal.
DICTIONARY_MODE
The example above (arr[100] = 4) is bad, but manageable. However, if you do this
const arr = [1, 2, 3];
arr[100000] = 4; // Sparse ArrayV8 looks at the memory waste required to allocate 100,000 empty slots for just two integers and decides to bail out entirely. It switches to DICTIONARY_MODE.
The array is no longer an array in memory. It becomes a Hash Map.
Keys are converted to hashes.
Access becomes significantly slower due to hash computation and collision handling.
Iterating over this array is a performance disaster compared to a packed array.
The new Array()
I see this pattern in “optimized” codebases constantly
// I am pre-allocating memory for performance
const arr = new Array(100);
arr[0] = 1;Stop doing this.
new Array(100) creates an array with length: 100, but zero allocated slots. It is born HOLEY_SMI_ELEMENTS (or HOLEY_ELEMENTS). You have pre-allocated nothing but a promise of slowness.
How to fix that?
If you want a packed array, you must ensure it has no holes from birth.
// Use Literal (Best for small sets)
const arr = [1, 2, 3]; // PACKED_SMI_ELEMENTS// Use Push (Best for Dynamic growth)
const arr = [];
for (let i = 0; i < 100; i++) {
arr.push(i); // Remains PACKED as it grows
}// Use Array.from (A cleaner syntax)
// Creates a PACKED array filled with initialized values
const arr = Array.from({ length: 100 }, (_, i) => i); Best Practices
As senior engineers, we optimize for the machine, not just the linter.
Never delete Array Elements
Using the delete operator on an array index creates a hole.
const arr = [1, 2, 3];
delete arr[1]; // Transitions to HOLEY_SMI_ELEMENTS
// arr is now [1, empty, 3]Use .splice() if you need to remove it, or overwrite it with a value if you need to maintain the index.
undefined is better than a Hole
If you must “clear” a slot but keep the index, explicit undefined is superior to a hole.
const arr = [1, 2, 3];
arr[1] = undefined; This transitions the array to PACKED_ELEMENTS (generic packed). While generic packed is slower than PACKED_SMI (because it has to handle tagging/boxing), it is significantly faster than HOLEY because it skips the prototype chain walk.
Initialize Monomorphically
Don’t mix types if you can avoid it.
[1, 2, 3]isPACKED_SMI(Fastest).[1, 2, 3.5]degrades toPACKED_DOUBLE(Fast, but requires conversion overhead).[1, 2, '3']degrades toPACKED_ELEMENTS(Slower).
Treat your arrays like static memory blocks. Keep them contiguous, keep them typed, and never let V8 doubt the integrity of your indices.



Fantastic writeup on element kinds. The part about how holes force prototype chain walks is something I wish more devs understood early on. Back when I was debugging perf issues in a data viz app, tracking down that one `new Array(1000)` call saved us like 40ms per render cycle.