You're writing clean code, filling arrays efficiently, but there's a hidden performance trap that catches even experienced developers: filling arrays in reverse order. Let me show you why this innocent-looking pattern can tank your app's performance and how to avoid it.
The Surprising Culprit
Consider these two seemingly equivalent approaches:
// Approach A: Forward filling
const forwardArray = [];
for (let i = 0; i < 10000; i++) {
forwardArray[i] = i * 2;
}
// Approach B: Reverse filling
const reverseArray = [];
for (let i = 9999; i >= 0; i--) {
reverseArray[i] = i * 2;
}
Both create identical arrays, right? Wrong. The second approach can be significantly slower and use more memory. Here's why.
What's Really Happening Under The Hood
JavaScript engines like V8 (Chrome, Node.js) and SpiderMonkey (Firefox) are incredibly smart. They optimize arrays based on how you build them. When you fill arrays sequentially, engines use a dense array representation:
- Contiguous memory allocation
- Direct indexed access (like C arrays)
- Minimal metadata overhead
- Lightning-fast iteration
But when you fill backwards, something sinister happens during construction:
const arr = [];
arr[999] = 'last'; // Whoa! We jumped to index 999
arr[998] = 'second'; // Working backwards...
// The engine sees: indices 0-997 are undefined (holes!)
The engine detects holes (gaps in the array) and may switch to a sparse array representation:
- Dictionary-like hash map structure
- Slower property lookups
- Extra memory for bookkeeping
- Deoptimized iteration
Real-World Performance Impact
Let's benchmark this with a practical example:
// Test setup
const SIZE = 100000;
// Test 1: Forward filling
console.time('Forward Fill');
const forward = [];
for (let i = 0; i < SIZE; i++) {
forward[i] = Math.random();
}
console.timeEnd('Forward Fill');
// Test 2: Reverse filling
console.time('Reverse Fill');
const reverse = [];
for (let i = SIZE - 1; i >= 0; i--) {
reverse[i] = Math.random();
}
console.timeEnd('Reverse Fill');
// Test 3: Push + reverse (the smart way)
console.time('Push + Reverse');
const pushReverse = [];
for (let i = 0; i < SIZE; i++) {
pushReverse.push(Math.random());
}
pushReverse.reverse();
console.timeEnd('Push + Reverse');
Typical results (your mileage may vary):
Forward Fill: ~8ms
Reverse Fill: ~25ms ← 3x slower!
Push + Reverse: ~12ms
The Hidden Class Problem
V8 uses "hidden classes" to optimize object property access. Arrays have similar optimizations. When you create sparse arrays, you're forcing the engine through multiple transitions:
const arr = [];
// Hidden class: EmptyArray
arr[0] = 1;
// Hidden class: DenseArray_1
arr[1000] = 2;
// Hidden class: SparseArray ← Performance cliff!
Once an array becomes sparse, it's often permanently deoptimized for that session.
Real-World Scenarios Where This Matters
1. Processing Data in Reverse Order
// ❌ BAD: Creates holes during construction
function processReverse(data) {
const result = [];
for (let i = data.length - 1; i >= 0; i--) {
result[i] = expensiveTransform(data[i]);
}
return result;
}
// ✅ GOOD: Fill forward, access reversed
function processReverseBetter(data) {
const result = [];
for (let i = 0; i < data.length; i++) {
result[i] = expensiveTransform(data[data.length - 1 - i]);
}
return result;
}
// ✅ EVEN BETTER: Push and reverse
function processReverseBest(data) {
const result = [];
for (let i = data.length - 1; i >= 0; i--) {
result.push(expensiveTransform(data[i]));
}
return result;
}
2. Building Arrays from the End
// ❌ BAD: Reverse indexing
function buildFromEnd(count) {
const arr = [];
let index = count - 1;
while (index >= 0) {
arr[index] = computeValue(index);
index--;
}
return arr;
}
// ✅ GOOD: Use unshift (if array is small)
function buildFromEndUnshift(count) {
const arr = [];
for (let i = 0; i < count; i++) {
arr.unshift(computeValue(i));
}
return arr; // Note: unshift is O(n) per call, but keeps density
}
// ✅ BEST: Fill forward, reverse once
function buildFromEndOptimal(count) {
const arr = new Array(count);
for (let i = 0; i < count; i++) {
arr[i] = computeValue(count - 1 - i);
}
return arr;
}
3. Pre-allocating Arrays
// ✅ GOOD: Pre-allocate and fill forward
function createLargeArray(size) {
const arr = new Array(size); // Pre-allocate length
for (let i = 0; i < size; i++) {
arr[i] = generateItem(i);
}
return arr;
}
// ✅ ALSO GOOD: Using fill for initialization
function createFilledArray(size, value) {
return new Array(size).fill(value);
}
// ✅ MODERN: Using Array.from
function createMappedArray(size) {
return Array.from({ length: size }, (_, i) => generateItem(i));
}
Checking Array Type in V8
Want to see if your array is optimized? You can check with Node.js:
// Run node with --allow-natives-syntax flag
const arr1 = [1, 2, 3];
console.log(%HasFastProperties(arr1)); // Should be true
const arr2 = [];
arr2[1000] = 1;
console.log(%HasFastProperties(arr2)); // Likely false
Best Practices Summary
- Always fill arrays sequentially from index 0 upward
-
Use
push()for dynamic arrays—it maintains density -
Pre-allocate with
new Array(size)if you know the length -
Use
Array.from()orArray(n).fill()for initialization - Reverse the final array if you need reverse order (one-time O(n) cost is worth it)
- Avoid creating holes by skipping indices during construction
When Reverse Filling Is Acceptable
There are cases where the performance hit doesn't matter:
- Small arrays (< 100 elements)—the overhead is negligible
- One-time operations where the array isn't reused
- Already sparse data where you're working with inherently sparse structures
But for hot paths, tight loops, or large datasets, always fill forward.
The Bottom Line
JavaScript engines are incredibly sophisticated, but they rely on predictable patterns. Filling arrays forward is a signal that says "optimize me," while reverse filling whispers "I might be weird, better play it safe."
Your arrays will thank you with:
- Faster construction
- Lower memory usage
- Better iteration performance
- Consistent optimization across engines
Next time you reach for that reverse loop to fill an array, remember: direction matters.
Top comments (0)