loke.dev
Header image for Stop Pre-Sizing Your JavaScript Arrays: How 'Holey' Elements are Sabotaging Your V8 Performance

Stop Pre-Sizing Your JavaScript Arrays: How 'Holey' Elements are Sabotaging Your V8 Performance

Pre-sizing your arrays might seem like a professional optimization, but in the world of V8 'element kinds,' you’re likely opting into a slower tier of execution.

· 4 min read

You’ve probably been told that pre-allocating your JavaScript arrays is a "pro-level" performance win. It’s one of those classic optimizations passed down by developers who grew up in the world of C++ or Java: "Tell the engine exactly how much memory you need upfront, and you'll save on re-allocations."

It sounds logical, but in the context of the modern V8 engine (which powers Chrome and Node.js), this "optimization" is actually a trap. By pre-sizing an array, you’re often forcing V8 into a slower execution tier before you’ve even put a single piece of data into it.

The Hidden Complexity of "Element Kinds"

V8 doesn't treat all arrays equally. Under the hood, it assigns an Element Kind to your array based on what’s inside it. This allows the engine to skip expensive checks.

The "Gold Standard" is PACKED_SMI_ELEMENTS. This is an array filled with Small Integers (Smis) with no gaps. If V8 knows your array is packed, it doesn't have to check the prototype chain for missing values every time you access an index.

But the moment you do this:

const arr = new Array(100); 

You haven't created a "pre-allocated" array of integers. You’ve created a Holey array. Specifically, HOLEY_SMI_ELEMENTS.

Why "Holes" are Performance Poison

A "hole" is a gap in the array. When you use new Array(100), you have 100 slots that are technically empty (not undefined, but truly empty).

Because the array is holey, V8 can no longer make simple assumptions. Every time you try to read a value, the engine has to perform a series of checks:
1. Is the index within bounds?
2. Is the element a "hole"?
3. If it's a hole, search the prototype chain to see if someone defined a value for that index on Array.prototype.

That third step is the killer. Even if you never modify the prototype, V8 still has to perform that check because the spec says it must. In a "Packed" array, V8 knows there are no holes, so it skips that lookup entirely.

Seeing the Transition in Action

V8 tracks these transitions, and they only go one way: from Fast to Slow. You can never go back from Holey to Packed.

// This starts as PACKED_SMI_ELEMENTS
const fastArray = [1, 2, 3];

// This starts as HOLEY_SMI_ELEMENTS
const slowArray = new Array(3);
slowArray[0] = 1;
slowArray[1] = 2;
slowArray[2] = 3;

// Even though slowArray now looks exactly like fastArray, 
// it remains HOLEY forever. It's stuck in the slow lane.

If you’re iterating over a large array, the difference between "Packed" and "Holey" can result in a significant performance delta. The engine has to do more work for every single arr[i] access.

The "Double" Trouble

It gets worse when you mix types. V8 prefers Smis, but it can handle doubles (floats) efficiently too, as long as it knows they are all doubles (PACKED_DOUBLE_ELEMENTS).

If you pre-size an array and then start shoving decimals into it, you're layering "Holey" on top of "Double" transitions.

const numbers = new Array(3); // HOLEY_SMI_ELEMENTS
numbers[0] = 1.1;             // Transition to HOLEY_DOUBLE_ELEMENTS
numbers[1] = 2.2;
numbers[2] = 3.3;

If you had just used a literal or push, V8 could have optimized for PACKED_DOUBLE_ELEMENTS from the start.

When Should You Actually Pre-size?

Is there ever a time to use new Array(n)? Generally, only if you are absolutely sure the array size is massive (thousands of elements) and you are willing to accept the "Holey" penalty to avoid the overhead of the engine re-growing the backing store multiple times.

But for the vast majority of web development? Don't do it.

If you really need the performance of pre-allocation without the "Holey" baggage, use TypedArrays.

// This is actually pre-allocated in memory and has 
// zero "hole" overhead because it defaults to 0.
const buffer = new Int32Array(100); 

Int32Array or Float64Array are much closer to the "C-style" arrays you're probably thinking of. They have a fixed size, they don't have holes, and V8 loves them.

The Better Pattern

Instead of trying to be smarter than the engine, let the engine do its job. For most collections, starting with an empty array literal and using push() is the way to go.

// Good: V8 keeps this PACKED_SMI_ELEMENTS
const result = [];
for (let i = 0; i < 100; i++) {
  result.push(i);
}

// Better: If you already have the data
const result = data.map(x => x * 2); 

Array#map, Array#filter, and Array.from() are highly optimized by V8. They often handle the internal allocation logic much better than we can manually.

Summary for your next Code Review

* `[]` is your friend: Array literals create "Packed" elements.
* `new Array(n)` is a red flag: It creates "Holey" elements, which adds overhead to every single index access.
* Transitions are one-way: Once an array becomes Holey, it stays Holey.
* Use TypedArrays for heavy lifting: If you genuinely need a large, fixed-size block of numbers, Int32Array or Float64Array is what you actually want.

Stop worrying about the "cost" of growing an array. V8 is incredibly good at resizing backing stores. It’s much worse at trying to guess if you’ve messed with Array.prototype because of a hole you created on line 5.