Array Elements Kinds and Holey Array Memory
An array of a million prices uses 8 MB in one code path and 20+ MB in another, a single undefined or string pushed into a numeric array makes it permanently heavier, and a sparse array indexed by user ID quietly becomes a hash table. This guide from Object Shapes, Strings and Collection Memory Costs, part of JavaScript Memory Fundamentals & Runtime Mechanics, explains V8’s elements kinds, why transitions between them only go one way, and how to keep large arrays in their most compact form.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Numeric array uses ~2–3× expected memory | Elements kind degraded to generic, so doubles are boxed heap numbers | Keep numeric arrays pure, or use Float64Array |
~8 bytes per number instead of pointer + heap number |
| Array became slower after one assignment | Transition to a more general kind (e.g. a string or object stored) | Store mixed data in separate arrays or objects | Faster, specialised access paths |
new Array(n) arrays behave worse than literals |
Pre-sized arrays start holey | Fill sequentially from empty, or use typed arrays | Packed kind with fewer checks |
| Array indexed by large sparse IDs uses a hash table | Very sparse arrays switch to dictionary elements | Use a Map keyed by ID |
Predictable memory, no huge holes |
Holes (delete arr[i], arr[10000] = x) persist |
Holey kinds are permanent for that array | Avoid creating holes; rebuild arrays if needed | Packed representation retained |
Root Cause: Arrays Remember the Most General Thing They Ever Held
V8 tracks, for every array, an elements kind that describes what its elements contain and whether it has holes. The main kinds form a lattice from most specific to most general:
PACKED_SMI_ELEMENTS— only small integers, no holes. Stored as tagged small integers.PACKED_DOUBLE_ELEMENTS— numbers including non-integers, stored unboxed as raw 64-bit floats, 8 bytes each.PACKED_ELEMENTS— anything (objects, strings, mixed). Each element is a tagged pointer; non-integer numbers become separate heap-number objects.HOLEY_*variants of each — the array has (or had) holes, so every read must check for the hole and possibly consult the prototype chain.DICTIONARY_ELEMENTS— very sparse arrays whose elements are stored in a hash table keyed by index.
Transitions only go towards more general kinds. Push 1.5 into a SMI array and it becomes a double array; push a string into it and it becomes a generic array — permanently, even if you remove the string later. Create a hole by writing past the end, by delete arr[i], or by pre-sizing with new Array(n), and the array becomes holey for good. Assign to a very large index far beyond the current length and V8 may switch to dictionary elements rather than allocate millions of empty slots.
The memory consequences are significant for big arrays. A million doubles in PACKED_DOUBLE_ELEMENTS cost about 8 MB. The same million numbers in PACKED_ELEMENTS — because one null or string slipped in — cost a pointer per element plus a separate heap number object for every non-integer value, often more than twice the memory, with more work for the garbage collector. Dictionary elements trade huge empty backing stores for per-entry hash-table overhead, which is compact for truly sparse data but far heavier than a packed array for dense data. The same shape-stability principle that governs objects, discussed in dictionary-mode objects and the delete operator, applies to elements.
Step-by-Step Fix
- Identify the large arrays. Sort a heap snapshot by Shallow Size and look at the biggest
(array)backing stores; note which application arrays own them. Verification: you have a short list of arrays worth optimising. - Inspect their elements kind in Node. In a diagnostic script run with
node --allow-natives-syntax, call%DebugPrint(arr)on a representative array and read theelements kindline. Verification: you know whether each array is packed or holey, SMI, double or generic. - Keep numeric arrays pure. Never store
null,undefined, strings or objects in arrays meant for numbers; useNaNas a sentinel if needed. Verification:%DebugPrintreportsPACKED_DOUBLE_ELEMENTSorPACKED_SMI_ELEMENTS. - Avoid creating holes. Build arrays by pushing from empty or with
Array.from({ length: n }, fn), never write past the end, and avoiddelete arr[i]; usespliceor a sentinel instead. Verification: kinds stay packed after the array is populated. - Use typed arrays for large numeric data.
Float64Array,Int32Arrayand friends have a fixed element type, no holes and storage outside the V8 heap. Verification: memory per element matches the type size and GC work drops. - Use a
Mapfor sparse keyed data. If indices are IDs spread over a large range, switch toMap<number, value>. Verification: no huge backing stores or dictionary-elements arrays remain in snapshots.
Command and Code Reference
Use case: see elements-kind transitions (diagnostics only).
// kinds.js — node --allow-natives-syntax kinds.js 2>&1 | grep "elements kind"
const a = [1, 2, 3];
%DebugPrint(a); // PACKED_SMI_ELEMENTS
a.push(4.5);
%DebugPrint(a); // PACKED_DOUBLE_ELEMENTS
a.push(null);
%DebugPrint(a); // PACKED_ELEMENTS — permanent, even after a.pop()
const b = new Array(3); // pre-sized: starts holey
%DebugPrint(b); // HOLEY_SMI_ELEMENTS
Use case: keep numeric data compact and packed.
// Avoid: pre-sized holey array, null as a "missing" marker
const pricesBad = new Array(n);
for (let i = 0; i < n; i++) pricesBad[i] = rows[i].price ?? null; // generic + holey
// Better: packed doubles with NaN as the sentinel
const prices = [];
for (let i = 0; i < n; i++) prices.push(rows[i].price ?? NaN); // PACKED_DOUBLE
// Best for large data: a typed array with a fixed element type
const pricesTyped = new Float64Array(n);
for (let i = 0; i < n; i++) pricesTyped[i] = rows[i].price ?? NaN;
Use case: sparse IDs in a Map instead of an array.
// Avoid: arr[userId] with userIds like 10_000_000+ creates a sparse/dictionary array
const byId = new Map();
for (const u of users) byId.set(u.id, u); // memory proportional to entries only
Verification and Regression Prevention
Verify with %DebugPrint in a diagnostic script that your large arrays stay in the intended kind after realistic population and updates, and with a heap snapshot that their backing stores are the expected size. For hot numeric code, benchmark before and after; packed double and typed-array access is typically faster as well as smaller.
Guard against regressions by giving large numeric datasets a typed-array representation from the start — it cannot degrade — and by keeping “missing value” handling explicit (NaN, a separate validity mask) rather than mixing null into numeric arrays. Code review should flag new Array(n) for large numeric data, delete on arrays, and arrays indexed by sparse IDs. These habits complement reducing garbage churn in hot paths.
Edge Cases and Gotchas
-0, NaN and Infinity
Storing -0, NaN or Infinity in a SMI array transitions it to doubles, because they are not small integers. That is harmless for double data but surprising if you expected integers.
Array methods can produce different kinds
map, filter and slice create new arrays whose kinds depend on the values produced. A map that returns undefined for some elements produces a generic array even if the source was packed doubles.
Holey arrays and prototype lookups
Reading a hole in a holey array must check the prototype chain for an indexed property. It is correct but slower, and it is one reason engines optimise packed arrays more aggressively.
Elements kinds are per array, feedback is per site
Call sites remember which kinds they have seen. A function that processes both SMI and generic arrays becomes polymorphic and slower. Keep hot functions fed with arrays of one kind.
Frequently Asked Questions
What is a holey array in JavaScript?
In V8, a holey array is one that has, or has ever had, missing indices — for example created with new Array(n), or written past its end, or had an element deleted. V8 marks it with a holey elements kind, and reads must check for holes. The designation is permanent for that array.
Does mixing types in an array use more memory?
Often, yes. Once an array holds a non-number, its elements kind becomes generic, and non-integer numbers are stored as separate heap-number objects referenced by pointers, rather than raw 8-byte doubles. For large numeric arrays that can more than double memory use.
Are typed arrays always better for numbers?
For large, homogeneous numeric data, usually: fixed element size, no holes, no boxing and storage outside the V8 heap. For small arrays, or arrays that must hold mixed values or grow frequently, regular arrays are simpler and perfectly efficient.
When does an array become a dictionary?
When it is very sparse — for example when you assign to an index far beyond the current length, or delete many elements from a large array. V8 then stores elements in a hash table keyed by index. For sparse keyed data, a Map is the clearer choice.
Related
- Object Shapes, Strings and Collection Memory Costs — the parent topic
- Map vs Object vs Array Memory Overhead in JavaScript — choosing the container
- Large Object Space and When Objects Skip New Space — where large backing stores live
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview