Fast arrays & elements kinds
Learn how V8 classifies JavaScript arrays as packed, holey, Smi, double, generic, or dictionary elements, and what makes them slow.
- 01Read the elements-kind latticeExplain packed versus holey arrays, and why Smi, double, and generic element storage only widen toward more general kinds.
- 02Spot slow array patternsRecognize holes, very sparse indexes, out-of-bounds reads, and array-like objects before blaming ordinary arrays.
- 03Use V8 evidence carefullyRun Node-only probes for elements kinds and backing-store capacity while keeping JavaScript semantics separate from implementation details.
Arrays have element storage
JavaScript arrays look flexible: you can put numbers, strings, objects, and missing indexes in one value. Engines still try to keep the common case fast. V8 stores numeric indexes separately from named properties, then labels that indexed storage with an elements kind.
An elements kind is V8's internal label for an object's numeric-indexed storage. It records whether indexes are packed or holey, and whether the values are small integers, doubles, generic JavaScript values, or dictionary entries.
The language still says an array is an object whose indexes are property keys. The engine strategy is extra. You can observe it only with V8-only probes such as %HasSmiElements and %DebugPrint, not from normal web code.
This lesson follows the V8 team's Elements kinds in V8 article. It links sideways to array basics, iterating arrays, and typed arrays for language-level behavior. The previous hidden classes lesson covers named properties; this one covers indexed elements.
A full tray of same-size eggs is easy to count and move. A missing egg, a larger egg, or a toy in the tray needs more careful checks.
- In real life: Every slot has an egg
- In JavaScript: A packed Smi array like
[1, 2, 3] - In real life: One slot is empty
- In JavaScript: A holey array; reads need extra checks
- In real life: A larger egg joins the tray
- In JavaScript: Double elements after
2.5,NaN, or-0 - In real life: A toy is placed in the tray
- In JavaScript: Generic elements after a string or object
Where the analogy stops: You can repack an egg tray by hand. V8 usually does not scan an array to regain a more specific kind after it widened.
Packed arrays and holes
Packed means every index from 0 to length - 1 is present. Holey means at least one of those indexes is missing. A missing index is called a hole. It is not the same as a present value whose value happens to be undefined.
const packed = ["Asha", "Ravi", "Meera"];const holey = new Array(3);holey[1] = "Ravi"; console.log(0 in packed);console.log(0 in holey);console.log(holey.length);Line 1 creates three present indexes. Line 2 creates a length without values, so the slots are holes. Line 3 fills only index 1. That is why line 6 prints false: index 0 is missing, even though the array's length is 3.
| Kind | How you get there | Why it matters |
|---|---|---|
PACKED_SMI_ELEMENTS | Dense indexes, all small integers | Best case for integer-heavy arrays such as [1, 2, 3]. |
PACKED_DOUBLE_ELEMENTS | Dense indexes, at least one double-only number | Stores numbers in an unboxed double backing store; -0, NaN, and Infinity belong here. |
PACKED_ELEMENTS | Dense indexes, generic JavaScript values | Holds strings, objects, symbols, and numbers through normal tagged values. |
HOLEY_* variants | At least one missing index | Every read needs extra checks because the value might be on the prototype chain. |
DICTIONARY_ELEMENTS | Very sparse numeric indexes | Uses a dictionary-like store instead of allocating a huge mostly-empty backing store. |
Packed operations can skip some checks. Holey operations must ask, “is the value really present here, or should I look on the prototype chain?” Once V8 marks an array holey, filling the visible holes later does not make it packed again in the normal transition lattice.
Smi, double, and generic elements
V8 uses Smi for small integers that fit directly in a tagged value. In official Node 22 builds on 64-bit machines, pointer compression is off, so the Smi range includes 2 ** 31 - 1. Chrome builds commonly use pointer compression, where the exact range differs. Treat that number as V8-build-specific, not JavaScript semantics.
const counts = [1, 2, 3];counts.push(-0);counts.push(NaN);counts.push(Infinity); console.log(Object.is(counts[3], -0));console.log(Number.isNaN(counts[4]));console.log(counts[5] === Infinity);Line 2 pushes -0. JavaScript can distinguish -0 from +0 with Object.is, so V8 cannot store it as a Smi. Lines 3 and 4 add NaN and Infinity; both are double values too.
function label(array) { if (%HasDictionaryElements(array)) return "DICTIONARY_ELEMENTS"; const prefix = %HasHoleyElements(array) ? "HOLEY" : "PACKED"; if (%HasSmiElements(array)) return prefix + "_SMI_ELEMENTS"; if (%HasDoubleElements(array)) return prefix + "_DOUBLE_ELEMENTS"; if (%HasObjectElements(array)) return prefix + "_ELEMENTS"; return "OTHER_ELEMENTS";} function report(name, array) { console.log(name + ": " + label(array));} console.log("Node " + process.version);console.log("V8 " + process.versions.v8.split(".").slice(0, 2).join("."));const values = [1, 2, 3];report("packed smi", values);values.push(4.5);report("after double", values);values.push("x");report("after string", values);values.pop();values.pop();report("after popping back", values);const holey = new Array(3);report("new Array", holey);holey[0] = 1;holey[1] = 2;holey[2] = 3;report("filled holey", holey);const short = [1, 2, 3];short[10] = 1;report("write index 10", short);const minusZero = [1, 2, 3];minusZero.push(-0);report("minus zero", minusZero);const notANumber = [1, 2, 3];notANumber.push(NaN);report("NaN", notANumber);const infinity = [1, 2, 3];infinity.push(Infinity);report("Infinity", infinity);const sparse = [];sparse[1e6] = 1;report("sparse", sparse);The lesson test runs that code in a child Node 22 process with --allow-natives-syntax. It proves V8 12.4 reports packed Smi, then packed double, then packed generic elements, and that popping values does not return the array to Smi.
console.log("Node " + process.version);console.log("V8 " + process.versions.v8.split(".").slice(0, 2).join("."));console.log("pointer compression", process.config.variables.v8_enable_pointer_compression);console.log("2**31 - 1", %IsSmi(2 ** 31 - 1));console.log("2**31", %IsSmi(2 ** 31));Tests assert only stable facts: Node starts with v22., V8 starts with 12.4., official Node reports pointer compression disabled, %IsSmi(2 ** 31 - 1) is true, and %IsSmi(2 ** 31) is false.
Elements-kind transitions only widen
V8's common lattice moves from specific to general: packed Smi, packed double, packed generic, and the matching holey variants. The important rule is one-way movement. Once an array widens, V8 normally does not scan it later to prove it can narrow again.
Watch one array move down V8's elements-kind lattice. The replay is a teaching model backed by real V8 tests.
script
fares.push(4.5);fares.push("₹");fares.pop();fares.pop();console.log(fares.join(","));console.log(fares.length);Line 2 widens the array from packed Smi to packed double. Line 3 widens it again to packed generic elements. Lines 4 and 5 remove the values, so JavaScript prints 1,2,3 and length 3, but the V8-native test proves the internal kind stays generic.
%DebugPrint shape of a packed Smi arrayJavaScriptconst arr = [1, 2, 3];%DebugPrint(arr);Node 22's %DebugPrint([1, 2, 3]) includes an elements kind: PACKED_SMI_ELEMENTS line on the array's map. The test matches the format without asserting addresses or architecture-specific details.
const model = createElementsKindModel();model.push(1); // PACKED_SMI_ELEMENTSmodel.push(2.5); // PACKED_DOUBLE_ELEMENTSmodel.push("₹"); // PACKED_ELEMENTSmodel.writePastEnd(); // HOLEY_ELEMENTSmodel.push(-0); // still HOLEY_ELEMENTSNo actions yet. Start with `Push 1`, then compare `Push 2.5`, `Push ₹`, and `Make a hole`.
Start with an empty packed Smi array. Add values or create a hole to watch the predicted V8 lattice move downward.
The playground is honest about its limits: it predicts V8's lattice for the shown sequence; it does not inspect the browser running this page. The test compares the same sequences with Node's real V8 natives.
Dictionary elements and sparse arrays
A sparse array has many missing indexes compared with its length. V8 can keep a moderately holey array in a fast holey backing store, but a very sparse array is better represented as a dictionary: a table of present indexes and values.
A short contact list works when saved numbers are close together. If only far-apart numbers matter, a list of saved entries is better than a huge blank list.
- In real life: Nearby numbers fill a short list
- In JavaScript: A packed or mildly holey backing store
- In real life: Only far-apart numbers are saved
- In JavaScript: A sparse array with dictionary elements
- In real life: You search for one saved number
- In JavaScript: Dictionary lookup by numeric key
- In real life: A short list is faster to scan
- In JavaScript: Dense arrays are the optimized common case
Where the analogy stops: You choose which contacts to save. V8 uses heuristics and implementation details that can change across versions.
const sparse = [];sparse[1_000_000] = "Jaipur"; console.log(sparse.length);console.log(0 in sparse);console.log(1_000_000 in sparse);The V8-native test creates const sparse = [], writes sparse[1e6] = 1, and verifies %HasDictionaryElements(sparse) through the reported DICTIONARY_ELEMENTS label.
If those numeric IDs are really sparse identifiers rather than positions in a list, a Map or plain object often communicates the data model better than an array with a million-length gap.
Backing-store growth
The array length is JavaScript-visible. The backing-store capacity is how much room the engine has allocated behind the scenes. Engines usually grow capacity by more than one slot so repeated push calls do not allocate every time.
Step through V8's backing-store growth model for pushes into an initially empty packed array.
script
orders.push(1);for (let ticket = 2; ticket <= 18; ticket += 1) { orders.push(ticket);}orders.push(19);console.log(orders.length);function newElementsCapacity(requestedCapacity) { return requestedCapacity + (requestedCapacity >> 1) + 16;} console.log(newElementsCapacity(1));console.log(newElementsCapacity(18));console.log(newElementsCapacity(44));V8's source helper JSObject::NewElementsCapacity is documented as “old capacity plus 50% plus 16.” For the push path shown here, the capacity requested at the crossing is the new required length. That is why the first push asks for 1 and gets 17, while the 18th value asks for 18 and gets 43.
| Moment | Visible length | Backing-store capacity | Why |
|---|---|---|---|
| After first push | length 1 | capacity 17 | The requested capacity is 1, so 1 + 0 + 16 gives 17 slots. |
| After push 18 | length 18 | capacity 43 | The 18th value crosses 17 slots; 18 + 9 + 16 gives 43. |
| After push 44 | length 44 | capacity 82 | The next crossing uses 44 + 22 + 16, matching Node 22's DebugPrint output. |
Capacity numbers are V8 12.4 implementation details. The safe lesson is the shape of the strategy: grow with spare room, keep dense arrays dense, and do not depend on exact capacities in app code.
Array-likes are not arrays
An array-like object has numeric keys and a length, but it is not an actual array. The classic examples are arguments, older DOM collections, and objects you build yourself. Array methods can often be borrowed, but the optimized array path is not the same as a real array.
function logArguments() { console.log(Array.isArray(arguments)); console.log(arguments.length);} logArguments("Asha", "Ravi"); const list = { 0: "Asha", 1: "Ravi", length: 2 };Array.prototype.forEach.call(list, (name, index) => { console.log(index + ":" + name);});console.log(Array.from(list).join("|"));| Value | How it behaves | Performance habit |
|---|---|---|
| Real array | Array.isArray(value) is true; methods live on Array.prototype. | V8 can choose specialized element access and optimized built-ins. |
arguments | Indexed values plus length, but not a real array. | Prefer rest parameters when you want an actual array. |
| DOM or object list | Looks like { 0: item, length: n }. | Use Array.from(list) once if you will run several array methods. |
If you will run several array operations, convert once with Array.from or use rest parameters instead of arguments. If you only need one simple loop, clarity matters more than folklore.
Use the knowledge safely
Elements kinds explain surprising profiles; they should not make normal code strange. Start with readable arrays. Measure the actual user-visible path. If a profile points at hot array work, then test a concrete hypothesis: avoid holes, avoid mixed value families in that hot array, or convert array-likes before repeated built-in calls.
const readings = [];for (const value of [18, 19, 20]) { if (Number.isFinite(value) && !Object.is(value, -0)) { readings.push(value); }} console.log(readings.join(","));- Prefer array literals or repeated
pushovernew Array(n)followed by filling when you care about packed storage. - Normalize or reject
-0,NaN, andInfinityonly when the data model truly wants finite integers. - Use
Mapor objects for sparse identifiers instead of pretending they are positions in a list. - Use typed arrays for large numeric buffers when fixed element types are part of the problem.
- Measure before and after; exact V8 thresholds can change.
[1, 2, 3]created as a literal.[1, 2, 3].push(-0).const a = new Array(3); a[0] = a[1] = a[2] = 1;const a = [1, 2, 3]; a[10] = 1;const a = []; a[1_000_000] = 1;- Calling
Array.prototype.forEach.call(arguments, fn).
Put each pattern where V8's array storage is likely to land. Think about holes and sparsity first, then value family.
Common misconceptions
- “A hole is just `undefined`.” No. A hole is a missing property;
0 in arraycan befalse. - “Popping the weird value makes the array fast again.” The visible values change, but V8's elements kind does not usually narrow.
- “Every sparse array is dictionary immediately.” V8 has heuristics; the lesson proves a very sparse
1e6write, not every gap. - “Elements kinds are JavaScript types.” They are V8 implementation details. Other engines use their own strategies and names.
- “Engine internals beat clarity.” Clear code plus measurement beats cargo-cult rewrites.
| Idea | What it means | Do not confuse it with |
|---|---|---|
| Hole | A missing index: 0 in arr is false. | Not the same as an explicit undefined value. |
| Element | A numeric-indexed value stored separately by the engine. | Not the same store as named properties from the hidden-classes lesson. |
| Array length | A JavaScript-visible number one greater than the highest assigned index. | Not the backing-store capacity, which V8 can over-allocate. |
| Array-like | An object with indexes and length. | Not a real array with V8's optimized array built-ins. |
Practice exercises
Read the snippet and type the two console lines.
const fares = [1, 2, 3];
fares.push(4.5);
fares.push("₹");
fares.pop();
fares.pop();
console.log(fares.join(","));
console.log(fares.length);It prints 1,2,3 and then 3. V8 still remembers the array widened, but normal JavaScript output shows only the current values and length.
-0Which elements family does -0 force?
-0 forces the double elements family, so [1, 2, 3].push(-0) becomes PACKED_DOUBLE_ELEMENTS in the verified V8 build.
Before any slot is filled, what does the hole check print?
const seats = new Array(3);
console.log(0 in seats);
seats[0] = "Asha";
console.log(seats[0]);The first line prints false. Filling seats[0] afterwards creates that property, but the initial array was holey.
Which built-in should you reach for when an array-like value needs repeated array work?
const list = { 0: "Asha", 1: "Ravi", length: 2 };
console.log(Array.from(list).join("|"));const names = Array.from(list);
console.log(names.join("|"));Array.from(list) reads the array-like indexes once and returns a real array, so later array methods use normal array behavior.
What loop condition should replace i <= items.length?
const items = [10, 20, 30];
let total = 0;
for (let i = 0; i <= items.length; i += 1) {
total += items[i] ?? 0;
}
console.log(total);for (let i = 0; i < items.length; i += 1) {
total += items[i];
}Use < items.length, not <= items.length. The fixed loop never reads items[3], so it avoids an unnecessary out-of-bounds case.
A site stores a few order records at numeric IDs such as 42 and 1_000_000. What structure should you consider instead of an array?
If the numbers are scattered IDs, use a Map or object. Arrays are best when indexes mean dense positions in order.
Check your understanding
Question 1 of 8What is an elements kind in V8?
Choose an answer to see the explanation.
Question 2 of 8What does this sparse-looking snippet print?
Read the code, then predictconst seats = []; seats[2] = "Ravi"; console.log(seats.length); console.log(0 in seats);Choose an answer to see the explanation.
Question 3 of 8Which value widens
[1, 2, 3]from Smi elements to double elements in V8?Choose an answer to see the explanation.
Question 4 of 8After an array reaches
PACKED_ELEMENTS, what happens if you pop the string that caused it?Choose an answer to see the explanation.
Question 5 of 8What does this capacity model print for the 18th requested slot?
Read the code, then predictfunction newElementsCapacity(requestedCapacity) { return requestedCapacity + (requestedCapacity >> 1) + 16; } console.log(newElementsCapacity(18));Choose an answer to see the explanation.
Question 6 of 8What does this array-like example print?
Read the code, then predictconst list = { 0: "Asha", 1: "Ravi", length: 2 }; Array.prototype.forEach.call(list, (name) => console.log(name));Choose an answer to see the explanation.
Question 7 of 8When is
DICTIONARY_ELEMENTSa likely V8 choice?Choose an answer to see the explanation.
Question 8 of 8Which habit follows from elements kinds?
Choose an answer to see the explanation.
Key takeaways
- V8 stores numeric indexes as elements, separate from named properties and hidden classes.
- Packed arrays have every index present; holey arrays have missing indexes and need extra checks.
- Smi arrays widen to double arrays, then generic arrays; transitions normally do not narrow again.
- Very sparse arrays can use dictionary elements, and array-like objects are not the same as real arrays.
- Backing stores grow with spare capacity; exact numbers are V8 details, so measure before optimizing.
In one line: dense, consistent arrays are the easy fast path; holes, mixed values, and sparse indexes ask the engine to do more work.
Next, Functions & closures inside the engine looks at function objects, shared function info, and the context objects closures retain.