DEV Community

Cover image for JavaScript arrays are not C arrays: Holes, elements kinds, and resize costs
Mohammed Abdelhady
Mohammed Abdelhady

Posted on Edited on Fully Autonomous

JavaScript arrays are not C arrays: Holes, elements kinds, and resize costs

Question: Is a JavaScript array a contiguous block of memory like a C array?

Answer: ECMAScript does not promise that. It defines an Array as an exotic object with special rules for index-like property names and length. An engine may use contiguous storage when that is profitable, then switch representations when your data becomes sparse or mixed.

The distinction matters. "Arrays are contiguous" is a useful first-week simplification and a bad foundation for performance advice.

This article separates three layers that are often blended together:

  1. What JavaScript guarantees.
  2. What V8 currently optimizes.
  3. What your benchmark actually proves on one workload.

First experiment: a hole is not undefined

Run this in Node or a browser console:

const holey = ['a', , 'c'];
const explicit = ['a', undefined, 'c'];

console.log(holey.length);          // 3
console.log(holey[1]);              // undefined
console.log(1 in holey);            // false
console.log(holey.hasOwnProperty(1)); // false

console.log(explicit.length);       // 3
console.log(explicit[1]);           // undefined
console.log(1 in explicit);         // true
console.log(explicit.hasOwnProperty(1)); // true
Enter fullscreen mode Exit fullscreen mode

Reading either position returns undefined, but the object shapes differ. The hole is an absent property. The explicit value is a present property whose value happens to be undefined.

That difference leaks into array methods:

const source = ['a', , 'c'];

console.log(source.map((value) => value));
// [ 'a', <1 empty item>, 'c' ]

console.log([...source]);
// [ 'a', undefined, 'c' ]

console.log(source.slice());
// [ 'a', <1 empty item>, 'c' ]
Enter fullscreen mode Exit fullscreen mode

Spread consumes the array iterator, which reads a value for every index and materializes the default missing value as undefined. slice() preserves absence. V8 discusses this exact difference in Speeding up spread elements.

If an API cares about "missing" versus "present but empty," serializing, cloning, or iterating can change the information you thought you had.

What the language promises

ECMAScript calls arrays exotic objects. The exotic part is the relationship between array-index properties and length.

const values = [];

values[4] = 'x';
console.log(values.length); // 5
console.log(Object.keys(values)); // [ '4' ]
Enter fullscreen mode Exit fullscreen mode

Writing index 4 raises length to 5. It does not create properties 0 through 3.

Shrinking length performs deletion:

const values = ['a', 'b', 'c', 'd'];
values.length = 2;

console.log(values);       // [ 'a', 'b' ]
console.log(2 in values);  // false
Enter fullscreen mode Exit fullscreen mode

The specification says reducing length deletes configurable own array elements above the new boundary. This is observable object behavior, not merely a pointer change.

Arrays can also carry named properties that do not affect length:

const values = ['a'];

values.owner = 'search-index';
values['01'] = 'not an array index';

console.log(values.length); // 1
console.log(values.owner);  // search-index
console.log(values['01']);  // not an array index
Enter fullscreen mode Exit fullscreen mode

That is enough to reject the simple C-array model. JavaScript arrays participate in prototypes, property descriptors, getters, setters, deletion, and arbitrary named properties.

The specification does not require a growth factor, a backing-store layout, or constant-time access. Those are engine choices.

JavaScript hole behavior separated from possible V8 element stores

What V8 does with ordinary dense arrays

V8 separates integer-indexed elements from named properties. For common dense arrays, the elements store can behave like an internal array. For sparse cases, V8 can use a dictionary representation to avoid reserving a mostly empty region. The overview is in Fast properties in V8.

V8 also tracks an array's elements kind. A simplified set is:

PACKED_SMI_ELEMENTS
PACKED_DOUBLE_ELEMENTS
PACKED_ELEMENTS

HOLEY_SMI_ELEMENTS
HOLEY_DOUBLE_ELEMENTS
HOLEY_ELEMENTS
Enter fullscreen mode Exit fullscreen mode

SMI is V8 terminology for small integers represented efficiently by the engine. A floating-point value requires a double representation. A string or object requires a general tagged-element representation.

const values = [1, 2, 3]; // packed small integers

values.push(4.5);         // transition toward doubles
values.push({ id: 5 });   // transition toward general elements
Enter fullscreen mode Exit fullscreen mode

These transitions let optimized code make narrower assumptions when an array stays uniform. They are not JavaScript-visible types. typeof 1 and typeof 4.5 are both number.

The current V8 guide says transitions generally move from specific to more general kinds. It also records a 2025 exception: Array.prototype.fill can repack some holey arrays. That update is a useful warning against turning an engine blog post into eternal law.

Animated V8 elements-kind transitions from packed small integers to sparse storage

Packed and holey are performance hints, not a religion

Creating a large index gap makes holes:

const values = [1, 2, 3];
values[1000] = 4;
Enter fullscreen mode Exit fullscreen mode

For a packed representation, an indexed load can check bounds and read a slot. A holey load may need extra checks, including whether a value exists on the prototype chain.

Array.prototype[1] = 'from prototype';

const values = ['local', , 'end'];
console.log(values[1]); // from prototype

delete Array.prototype[1];
Enter fullscreen mode Exit fullscreen mode

Do not put indexed properties on Array.prototype in real code. The example shows why a hole cannot always be treated as an internal undefined slot.

V8's own guidance is measured: for real-world code, the packed-versus-holey difference is often too small to matter. Keep arrays dense when it is natural, but do not rewrite clear business logic because a diagram told you HOLEY_ELEMENTS is scary.

Profile first. A network request, DOM layout, JSON parse, or database query can erase any win from polishing an array loop.

Growth and operation cost need honest language

Introductory tables often claim:

index read   O(1)
push         amortized O(1)
shift        O(n)
Enter fullscreen mode Exit fullscreen mode

That is a reasonable model for a dynamic contiguous array. It is not a complexity guarantee written into ECMAScript.

An engine may allocate spare capacity, grow a backing store, copy elements, specialize built-ins, or choose a dictionary. shift() changes the logical index of every remaining item, but an engine is free to use internal tricks instead of physically moving every value on every call.

Use the table as an algorithm-design warning:

  • Repeated push() is usually a natural fast path for dense lists.
  • Repeated shift() is suspicious for a large queue.
  • Inserting at the front fights the array's index model.
  • Huge sparse indices can trigger a representation change.

Then measure the actual runtime and dataset.

For a queue, a head index often communicates intent better than repeated shifting:

class Queue {
  #items = [];
  #head = 0;

  enqueue(value) {
    this.#items.push(value);
  }

  dequeue() {
    if (this.#head === this.#items.length) return undefined;

    const value = this.#items[this.#head];
    this.#head += 1;

    if (this.#head > 1024 && this.#head * 2 > this.#items.length) {
      this.#items = this.#items.slice(this.#head);
      this.#head = 0;
    }

    return value;
  }
}
Enter fullscreen mode Exit fullscreen mode

This keeps dequeue work small and occasionally compacts consumed entries in one batch.

Why microbenchmarks lie so easily

This benchmark changes the array kind while supposedly measuring iteration:

function sum(values) {
  let total = 0;
  for (const value of values) total += value;
  return total;
}

sum([1, 2, 3]);
sum([1.1, 2.2, 3.3]);
sum([1, , 3]);
Enter fullscreen mode Exit fullscreen mode

The call site now receives small-integer, double, and holey inputs. The optimizing compiler may generate different code or handle the site polymorphically. Warmup, garbage collection, dead-code elimination, CPU frequency, and sample size add more noise.

A benchmark should answer a product question such as "which queue representation handles our 99th-percentile burst with less latency and memory?" It should not ask "are arrays fast?"

Measure:

  • Representative sizes and value types.
  • Warm and cold behavior when both matter.
  • Throughput and tail latency.
  • Allocation and garbage collection.
  • More than one engine if the code runs in more than one engine.

Keep the result scoped to the tested versions. Engine teams improve these paths continuously.

When TypedArray is the honest data structure

If you truly need fixed-width numeric elements over an exposed binary buffer, use a TypedArray:

const pixels = new Uint8ClampedArray(4 * width * height);
const samples = new Float32Array(4096);
Enter fullscreen mode Exit fullscreen mode

TypedArrays have a defined element type, byte length, byte offset, and backing ArrayBuffer. They trade ordinary Array flexibility for a binary layout suitable for graphics, audio, file formats, and WebAssembly boundaries.

They still are not a magic performance switch. Conversion cost, bounds, alignment, transfer, and API compatibility matter. Choose them because the data is genuinely fixed-width numeric data.

The working rule

I use this sentence when reviewing performance code:

JavaScript arrays are objects with special index and length behavior; engines often optimize dense, uniform arrays with compact element stores.

Both halves matter. The first prevents false guarantees. The second explains why ordinary arrays are fast enough for most application work.

Keep arrays dense when that matches the problem. Use objects or maps for named sparse data. Use a queue abstraction for queues. Use TypedArrays for binary numeric storage. And when performance matters enough to make the code stranger, bring a profiler before bringing folklore.

Top comments (0)