cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Fast arrays & elements kinds

Learn how V8 classifies JavaScript arrays as packed, holey, Smi, double, generic, or dictionary elements, and what makes them slow.

By the end, you can
  • 01
    Read the elements-kind latticeExplain packed versus holey arrays, and why Smi, double, and generic element storage only widen toward more general kinds.
  • 02
    Spot slow array patternsRecognize holes, very sparse indexes, out-of-bounds reads, and array-like objects before blaming ordinary arrays.
  • 03
    Use 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.

Definition

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.

Real-life analogyAn egg tray explains packed 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.

A packed array beside a holey arrayPop out in the code editor (opens in a new tab)JavaScript
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.

Common elements kinds
KindHow you get thereWhy it matters
PACKED_SMI_ELEMENTSDense indexes, all small integersBest case for integer-heavy arrays such as [1, 2, 3].
PACKED_DOUBLE_ELEMENTSDense indexes, at least one double-only numberStores numbers in an unboxed double backing store; -0, NaN, and Infinity belong here.
PACKED_ELEMENTSDense indexes, generic JavaScript valuesHolds strings, objects, symbols, and numbers through normal tagged values.
HOLEY_* variantsAt least one missing indexEvery read needs extra checks because the value might be on the prototype chain.
DICTIONARY_ELEMENTSVery sparse numeric indexesUses 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.

Numbers that force double elementsPop out in the code editor (opens in a new tab)JavaScript
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.

Node-only proof: V8 element nativesJavaScript
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);
Verified runtime fact

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.

Node-only proof: Smi boundary in this Node buildJavaScript
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));
Node and Chrome can differ

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.

Step through elements-kind transitions
Step 0 of 9Ready
Your turn: follow the blue line

Watch one array move down V8's elements-kind lattice. The replay is a teaching model backed by real V8 tests.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
fares.push(4.5);fares.push("₹");fares.pop();fares.pop();console.log(fares.join(","));console.log(fares.length);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Node-only %DebugPrint shape of a packed Smi arrayJavaScript
const arr = [1, 2, 3];%DebugPrint(arr);
Exact debug line checked by tests

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.

Playground: predict an array's elements kind
Teaching model actionsJavaScript
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_ELEMENTS
Predicted kindPACKED_SMI_ELEMENTS
PACKED_SMI_ELEMENTS current kind0 visible lengthno has holesno dictionary

No actions yet. Start with `Push 1`, then compare `Push 2.5`, `Push ₹`, and `Make a hole`.

Step 0 of 5PACKED_SMI_ELEMENTS

Start with an empty packed Smi array. Add values or create a hole to watch the predicted V8 lattice move downward.

This is a teaching model of V8's rules. It does not inspect your browser's engine; the lesson test compares the same sequences with Node's V8 natives.

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.

Real-life analogyA phone contact list for scattered numbers

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.

A very sparse arrayPop out in the code editor (opens in a new tab)JavaScript
const sparse = [];sparse[1_000_000] = "Jaipur"; console.log(sparse.length);console.log(0 in sparse);console.log(1_000_000 in sparse);
Verified runtime fact

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 backing-store growth
Step 0 of 6Ready
Your turn: follow the blue line

Step through V8's backing-store growth model for pushes into an initially empty packed array.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
orders.push(1);for (let ticket = 2; ticket <= 18; ticket += 1) {  orders.push(ticket);}orders.push(19);console.log(orders.length);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
V8 growth helper modelPop out in the code editor (opens in a new tab)JavaScript
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.

Capacity checkpoints proved with %DebugPrint
MomentVisible lengthBacking-store capacityWhy
After first pushlength 1capacity 17The requested capacity is 1, so 1 + 0 + 16 gives 17 slots.
After push 18length 18capacity 43The 18th value crosses 17 slots; 18 + 9 + 16 gives 43.
After push 44length 44capacity 82The next crossing uses 44 + 22 + 16, matching Node 22's DebugPrint output.
Capacity is not a promise

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.

Borrowing an array method, then convertingPop out in the code editor (opens in a new tab)JavaScript
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("|"));
Arrays versus array-like objects
ValueHow it behavesPerformance habit
Real arrayArray.isArray(value) is true; methods live on Array.prototype.V8 can choose specialized element access and optimized built-ins.
argumentsIndexed values plus length, but not a real array.Prefer rest parameters when you want an actual array.
DOM or object listLooks like { 0: item, length: n }.Use Array.from(list) once if you will run several array methods.
Practical rule

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.

Normalize a numeric array at the boundaryPop out in the code editor (opens in a new tab)JavaScript
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 push over new Array(n) followed by filling when you care about packed storage.
  • Normalize or reject -0, NaN, and Infinity only when the data model truly wants finite integers.
  • Use Map or 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.
Sort the array storage pattern
  • [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).
Try it yourself
0 of 6 correct

Put each pattern where V8's array storage is likely to land. Think about holes and sparsity first, then value family.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common misconceptions

  • “A hole is just `undefined`.” No. A hole is a missing property; 0 in array can be false.
  • “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 1e6 write, 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.
Ideas that are easy to confuse
IdeaWhat it meansDo not confuse it with
HoleA missing index: 0 in arr is false.Not the same as an explicit undefined value.
ElementA numeric-indexed value stored separately by the engine.Not the same store as named properties from the hidden-classes lesson.
Array lengthA JavaScript-visible number one greater than the highest assigned index.Not the backing-store capacity, which V8 can over-allocate.
Array-likeAn object with indexes and length.Not a real array with V8's optimized array built-ins.

Practice exercises

Exercise 1 · Warm-upPredict visible output after widening

Read the snippet and type the two console lines.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const fares = [1, 2, 3];
fares.push(4.5);
fares.push("₹");
fares.pop();
fares.pop();
console.log(fares.join(","));
console.log(fares.length);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName the family for -0

    Which elements family does -0 force?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRead a hole check

      Before any slot is filled, what does the hole check print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const seats = new Array(3);
      console.log(0 in seats);
      seats[0] = "Asha";
      console.log(seats[0]);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeConvert an array-like list

        Which built-in should you reach for when an array-like value needs repeated array work?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const list = { 0: "Asha", 1: "Ravi", length: 2 };
        console.log(Array.from(list).join("|"));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · PracticeFind the one-past-the-end bug

          What loop condition should replace i <= items.length?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const items = [10, 20, 30];
          let total = 0;
          for (let i = 0; i <= items.length; i += 1) {
            total += items[i] ?? 0;
          }
          console.log(total);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeChoose a structure for sparse IDs

            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?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              Elements kinds quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What is an elements kind in V8?

                Choose an answer to see the explanation.

              2. Question 2 of 8What does this sparse-looking snippet print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const seats = [];
                seats[2] = "Ravi";
                console.log(seats.length);
                console.log(0 in seats);

                Choose an answer to see the explanation.

              3. Question 3 of 8Which value widens [1, 2, 3] from Smi elements to double elements in V8?

                Choose an answer to see the explanation.

              4. 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.

              5. Question 5 of 8What does this capacity model print for the 18th requested slot?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function newElementsCapacity(requestedCapacity) {
                  return requestedCapacity + (requestedCapacity >> 1) + 16;
                }
                console.log(newElementsCapacity(18));

                Choose an answer to see the explanation.

              6. Question 6 of 8What does this array-like example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const list = { 0: "Asha", 1: "Ravi", length: 2 };
                Array.prototype.forEach.call(list, (name) => console.log(name));

                Choose an answer to see the explanation.

              7. Question 7 of 8When is DICTIONARY_ELEMENTS a likely V8 choice?

                Choose an answer to see the explanation.

              8. 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.

              CompleteFrontend Clear concepts. Working examples.