Topics

Jump to a topic.

Search questions

Find a question by title, tag, or summary.

How does V8 store arrays?

Questions in this chapter (5)
  • Draft
  • hard
  • Askedrarely
  • Verified October 2026

Answer

This is V8 behaviour, per V8's elements-kinds blog and checked on Node 24.19 (V8 13.6). The language only specifies that arrays behave like objects with index keys.

V8 keeps array-indexed properties in an elements store, separate from named properties (fast properties). The store's representation is tracked as the array's elements kind:

  • Value kind: SMI (small integers), DOUBLE (numbers, stored as raw doubles), or plain ELEMENTS (anything else, including objects and undefined).
  • Packing: PACKED (no holes) or HOLEY (at least one missing index).

That gives PACKED_SMI_ELEMENTS, PACKED_DOUBLE_ELEMENTS, PACKED_ELEMENTS and the three HOLEY_ forms. There are also other kinds: a very sparse array moves to DICTIONARY_ELEMENTS, a hash table.

Transitions are one-way, from specific to general: Smi to double to generic, and packed to holey. Pushing 4.5 into a Smi array makes it double; pushing a string makes it generic. Emptying the array does not reset the kind. Per the blog, -0, NaN and Infinity count as doubles, so one of them downgrades a Smi array.

Why it matters. Code and inline caches specialise on the kind, so a function that sees several kinds is polymorphic, just like with object shapes. Reading a hole forces a prototype-chain lookup, because arr[i] on a missing index may find a value on Array.prototype. Packed arrays avoid that check.

Habits: build arrays with literals or push, not new Array(n) followed by out-of-order writes; keep one value type per hot array; do not read past the end.

Example

// kinds.mjs. Run: node --allow-natives-syntax kinds.mjs | grep "elements kind"
// %DebugPrint is a debug-only native, not public API. Node 24.19, V8 13.6.
const a = [1, 2, 3];
%DebugPrint(a); // → elements kind: PACKED_SMI_ELEMENTS
a.push(4.5);
%DebugPrint(a); // → elements kind: PACKED_DOUBLE_ELEMENTS
a.push('x');
%DebugPrint(a); // → elements kind: PACKED_ELEMENTS
a.length = 0;
a.push(1);
%DebugPrint(a); // → elements kind: PACKED_ELEMENTS (never goes back)

const h = [1, 2, 3];
h[5] = 6;
%DebugPrint(h); // → elements kind: HOLEY_SMI_ELEMENTS

%DebugPrint prints much more; the grep keeps the one line each comment shows.

Pitfalls

  • Believing a Smi array turns into a double array only when you add a fraction. -0 and NaN do it too, per the blog, and I confirmed [1, 2, 3, -0] is PACKED_DOUBLE_ELEMENTS.
  • Believing "once holey, always holey" without checking. The blog describes one-way transitions between kinds, but on Node 24.19 new Array(3) is HOLEY_SMI_ELEMENTS and new Array(3).fill(0) printed PACKED_SMI_ELEMENTS. I did not find the reason in a source; do not rely on this either way.
  • Treating [1, undefined] as a number array. It printed PACKED_ELEMENTS.
  • Expecting %DebugPrint to be a stable interface. Treat kind names and the transition rules as version-dependent.

Follow-ups

Are arrays in JavaScript really contiguous memory?
Not by the language. In V8, the packed kinds use a contiguous backing store, but a sparse array moves to a dictionary. big[100_000_000] = 1 printed DICTIONARY_ELEMENTS in my run.
Does putting an integer in a double array change its kind?
No. [1.5, 2.5] followed by push(1) stayed PACKED_DOUBLE_ELEMENTS; the value is stored as a double.
What about [1, 2, 3] literals?
In %DebugPrint the backing store of the literal showed (COW): copy-on-write, so literals can share a backing store until written. I did not research the sharing rules.

Exercises

Predict the kinds

// predict.mjs. Run: node --allow-natives-syntax predict.mjs | grep "elements kind"
%DebugPrint([1, , 3]);
%DebugPrint([1.5, 2.5, 3]);
%DebugPrint(Array.from({ length: 3 }, (_, i) => i));