Elements Kinds: How Arrays Really Store Data

Here are two arrays. Same numbers, same order, same everything your code can see.

const a = [1, 2, 3];
const b = [1, 2, 3];

b.push(4.5); // a temporary float
b.pop();     // ...gone a line later

a and b are now equal element by element. JSON.stringify agrees, a[i] === b[i] at every index agrees, b reads back as [1, 2, 3]. But inside V8 they are no longer the same kind of array. a is still a packed block of small integers, the tightest and fastest array representation V8 has. b is a packed block of doubles, and it can never go back. That one 4.5, popped off a millisecond after it arrived, permanently changed how b is stored.

This is the array version of the story hidden classes told about objects. Arrays carry an invisible tag describing what they hold and how densely they hold it, the tag can only ever move toward more general (slower) storage, and a single stray value can tip a whole hot array off the fast path for the rest of its life. Learn the handful of tags and the one rule about them and array performance stops being mysterious.

Arrays are not just objects with number keys

The spec describes an array as an object whose keys happen to be integer strings. If V8 took that literally, nums[i] would be a property lookup: hash the key, walk the shape, find the slot. Ruinous inside a loop that runs a billion times.

So V8 does not take it literally. It splits an object’s storage in two. Named properties (obj.name, obj.id) live in one place, governed by hidden classes. Integer-indexed values (arr[0], arr[1], arr[2]) live in a separate, contiguous backing store built for dense sequential access. That backing store is what gets its own fast-path machinery, and the tag on it is called the elements kind.

The elements kind answers two questions about the backing store at once: what type of values does it hold, and how densely are they packed. Both answers feed straight into the code V8 emits for every read and write.

The three kinds that matter

There are two type ladders and a density axis. Start with type. From most specific to most general, the three elements kinds you will actually meet are:

  • PACKED_SMI_ELEMENTS. Every element is a Smi, V8’s word for a small integer it can store inline as a tagged value without any heap allocation. [1, 2, 3] is this, and it is the fastest array there is. (The representation of Smis, and why they are cheaper than other numbers, is the subject of Smis and doubles.)
  • PACKED_DOUBLE_ELEMENTS. The array holds floating-point numbers, or integers too big to be a Smi. V8 stores the raw eight-byte doubles back to back, with no per-number box object. Still a fast, specialised path.
  • PACKED_ELEMENTS. The array holds arbitrary values: strings, objects, a mix, undefined, whatever. Elements are stored as tagged values (a Smi or a pointer), so anything fits, and nothing about their type can be assumed.
PACKED_SMI_ELEMENTSsmall integers, inline1234a tagged value each,no heap allocationfastestPACKED_DOUBLE_ELEMENTSraw floats, inline1.52.53.54.5eight bytes each,no box objectsstill fastPACKED_ELEMENTSarbitrary tagged values7strobjnila Smi or a pointer,type unknowngeneral
The three packed backing stores. Small integers pack inline as tagged Smis, doubles pack inline as raw eight-byte floats with no box, and arbitrary values pack as tagged pointers where nothing about the type is known ahead of time.

That middle box is the one people miss. Outside a double-elements array, a floating-point number is a boxed value: V8 allocates a little heap object to hold the eight bytes and stores a pointer to it. In a PACKED_DOUBLE_ELEMENTS array those doubles are unboxed and laid out flat, one after another, so the loop reads them directly with no pointer chase and no extra objects for the garbage collector to clean up. The double kind is a genuine optimisation, not a consolation prize. It just is not quite as tight as the Smi kind.

Packed versus holey

Now the density axis, and this is where arrays get their sharpest performance cliff. An array is packed if its indices run 0, 1, 2, ... with no gaps. It is holey the moment a gap appears.

You make a hole three ways, and all of them are things people do without thinking:

const arr = [1, 2, 3];

arr[5] = 6;      // indices 3 and 4 were skipped → holes
delete arr[1];   // index 1 is now a hole
new Array(3);    // three holes from birth

Why does a gap matter so much? Because of what V8 has to store in the gap. A hole is not undefined sitting in the slot. It is a special internal marker meaning “there is genuinely nothing here”. And an empty index is not the end of the story, because Array.prototype (or something on the prototype chain) could define that index. So reading a holey array cannot just grab the slot. It has to check whether the slot is a hole, and if it is, walk up the prototype chain looking for that index before it can return undefined.

PACKEDno gaps, indices 0 to 5010111212313414515read arr[3]:go to slot 3, done.HOLEYgaps at indices 2 and 40101112hole3134hole515read arr[2]: a hole. now search up:Array.prototype: has index 2?Object.prototype: has index 2?nothing found, return undefinedand every read runsthe slower holey path,even for filled slots.
A packed read jumps straight to the slot. A holey read must first check for the hole marker, and on a hole it walks the prototype chain to be sure no inherited index answers, which is far more work per access.

The prototype walk only actually happens when you read a hole. But the cost of holey-ness is broader than that: once an array is holey, V8 compiles every access through the branchier, more defensive code that knows a hole might turn up, so even reads of filled slots lose the tightest path. Packed is a promise the engine can lean on. Holey withdraws it.

The lattice, and the one rule

Cross the three types with the two densities and you get six elements kinds. V8 arranges them as a lattice, and it tracks more than a dozen kinds in total once you count frozen, sealed, and dictionary variants, but these six carry the story. The exact count and names are internal to V8 and shift between releases, so treat them as the shape of the idea, not scripture.

Here is the whole map. The only thing you need to memorise is the direction of the arrows.

small intsfloatsanythingPACKED_SMIfastest, most specificPACKED_DOUBLEunboxed floatsPACKED_ELEMENTStagged valuesHOLEY_SMIgaps allowedHOLEY_DOUBLEgaps allowedHOLEY_ELEMENTSmost general, slowest+float+object+hole+hole+holeevery arrow points down or right, toward more general storagethere is no arrow back: an array never climbs to a more specific kind
The elements-kinds lattice. Transitions flow only toward more general storage: rightward as the type widens from Smi to double to arbitrary, and downward as a packed array turns holey. No transition ever runs back up or left.

That is the rule, and it is the whole lesson: elements kinds only ever transition toward more general. Never back. An array of Smis that meets a float becomes a double array and stays one. A double array that meets an object becomes an elements array and stays one. A packed array that grows a hole becomes holey and stays holey. Fill the hole, overwrite the float with an integer, pop the offending value off, it does not matter. The tag records the worst thing the array has ever held, not what it holds right now.

One stray value, permanent cost

Put the rule next to real code and the failure mode is obvious. You build a tight numeric array, and somewhere far away a single value of the wrong type slips in.

const scores = [10, 20, 30, 40]; // PACKED_SMI_ELEMENTS, the fast path

scores.push(50.5);   // one float → PACKED_DOUBLE_ELEMENTS
scores[4] = 50;      // overwrite it back to an integer...
// ...but the array is still PACKED_DOUBLE_ELEMENTS. The tag never returned.
1. start10203040PACKED_SMIfastest path2. push(50.5)1020304050.5PACKED_DOUBLEwidened by one float3. scores[4] = 501020304050contents back to all intsstill PACKED_DOUBLEV8 flag –trace-elements-transitions prints the one-way move (illustrative):elements transition in add: 0x… [PACKED_SMI_ELEMENTS] to [PACKED_DOUBLE_ELEMENTS]step 3 prints nothing: there is no transition back.
A packed Smi array meets one float and widens to packed double. Overwriting that float with an integer changes the contents back but not the tag: the array stays on the double representation permanently.

If you run Node or Chrome with --trace-elements-transitions, V8 will print a line every time an array changes kind, tagged with the old kind and the new one. It is the most direct way to catch a stray value pessimising an array you meant to keep tight. Adding an object to a Smi array skips the double rung and jumps straight to PACKED_ELEMENTS, because the lattice lets you fall as far as the value demands in one step.

A widened kind is not a crime by itself. A PACKED_DOUBLE_ELEMENTS array of a million floats is exactly right and exactly fast. The bug is accidental widening: an array you believed was integers-only quietly holding a NaN, an Infinity, a stray null, or the -0 that some math produces, any of which nudges it off the kind you were counting on. Those show up as a real, permanent representation change in a hot deoptimised loop, and they are maddening to find precisely because the contents look innocent.

new Array(n) is a hole factory

The most common way people make an array holey without meaning to is trying to be helpful about size.

const a = new Array(1000); // 1000 holes → HOLEY_SMI_ELEMENTS from birth
for (let i = 0; i < 1000; i++) a[i] = i; // filling holes does not un-holey it

const b = [];              // empty, packed
for (let i = 0; i < 1000; i++) b.push(i); // grows packed → PACKED_SMI_ELEMENTS

Preallocating with new Array(n) feels like an optimisation, the sort of thing you would do in C to avoid resizes. In V8 it does the opposite. It hands you a thousand-slot array that is holey before you have written a single value, and by the one-way rule, filling every slot afterward does not earn the packed tag back. The array you carefully sized is stuck on the holey path for its whole life.

new Array(6)holey before any writeholeholeholeholeholeholeHOLEY_SMI_ELEMENTSfill every slot012345still HOLEY_SMI_ELEMENTSfull, but reads keep the holey path[] then pushgrows one dense slot at a timeemptyPACKED_SMI_ELEMENTSpush 0..5012345still PACKED_SMI_ELEMENTSdense, direct slot readsthe fast path, kept
new Array(n) starts holey and stays holey even after every slot is filled, so reads keep the defensive path. An empty array grown with push stays packed and keeps direct slot reads.

There is one honest exception. If you are going to fill the array completely and immediately, new Array(n).fill(0) gives you a packed array, because fill writes a real value into every slot in one go and never leaves a hole. The trap is specifically new Array(n) followed by writes that dribble in later, or that skip indices. When in doubt, an empty literal grown with push is the reliably packed choice.

Watch a kind degrade

Push integers, floats, and objects into the array below, or punch a hole in it, and watch the elements kind slide down the lattice. It can only ever move one direction. The kinds and transitions are simulated to make the rule visible; they mirror V8’s behaviour, they are not read out of a live engine.

interactiveElements-kind visualiser: push values, watch the one-way slide

Notice what you cannot do in that demo: there is no button that moves the kind back up. Push a float and the “push int” button will never restore PACKED_SMI. That missing button is the whole point.

Typed arrays opt out entirely

If your data really is a fixed-type block of numbers, there is a structure that never plays this game: a typed array. An Int32Array, a Float64Array, a Uint8Array has one representation, fixed when you create it, backed by a flat ArrayBuffer of raw bytes. It cannot hold a mixed bag of types, cannot grow a hole, and therefore never transitions. You picked the layout up front, so V8 does not have to discover it or defend against it changing.

const pixels = new Float64Array(1000); // one representation, forever
pixels[0] = 3.14; // no boxing, no elements-kind lattice, no holes possible

That rigidity is the feature. For number crunching over large, fixed-type collections, audio samples, pixel data, vectors, matrices, a typed array sidesteps the entire elements-kind question by construction. A regular array is more flexible and the right default; a typed array is what you reach for when the flexibility is exactly what you do not want.

The honest calibration

Time for the part that matters more than the mechanism. Elements kinds are worth thinking about in a narrow band of code and are noise everywhere else.

They matter when you have a large array that you read or write in a hot loop, and the array is meant to be numeric. Physics steps over particle positions, pixel buffers, audio frames, the inner loop of a numeric algorithm, a big data grid you scan every frame. There, staying on PACKED_SMI or PACKED_DOUBLE instead of falling to holey or generic elements is a representation difference multiplied by millions of iterations, and it is real.

They do not matter for the array of eight items you build once and map over twice. They do not matter for a list of DOM nodes, a handful of config strings, or the results of one API call. The elements kind of a short-lived array is not your bottleneck, and reordering how you populate a ten-element list to “keep it packed” is effort spent on nothing. You will make the code stranger and measure no difference.

What this means for your code

  • Keep hot numeric arrays one type. A stray float in an integer array, or an object in a number array, widens it permanently. Guard the values that flow into arrays you loop over, and watch for NaN, Infinity, null, and -0 sneaking in.
  • Keep them dense. Do not delete elements, do not assign past the end and skip indices, do not leave holes. Once holey, always holey.
  • Do not preallocate with new Array(n) then scatter writes. Grow an empty array with push, or if you must size up front, fill it completely in one step with .fill(...).
  • Reach for a typed array when the data is fixed-type and numeric. It sidesteps elements kinds entirely by having one representation you cannot accidentally change.
  • Calibrate. All of this is for large arrays in hot paths. For small or cold arrays, write the clearest code and move on.

Summary

  • V8 stores an array’s integer-indexed values in a separate backing store from its named properties, and tags that store with an elements kind describing both the type of the contents and how densely they are packed.
  • The kinds that matter form a lattice: PACKED_SMI (small integers, fastest), PACKED_DOUBLE (unboxed floats, still fast), PACKED_ELEMENTS (arbitrary tagged values), each with a HOLEY counterpart for arrays with gaps. Holey arrays run a defensive path and can trigger prototype-chain lookups on missing indices.
  • Transitions are one-way. An array only ever moves toward more general, slower storage, and never back. One float marks it DOUBLE forever, one object marks it ELEMENTS forever, one hole marks it holey forever, even after you undo the change. The tag records the worst value the array has ever held.
  • new Array(n) creates a holey array from birth; an empty literal grown with push stays packed. A typed array has a single fixed representation and opts out of the whole system.
  • The exact kinds, counts, and triggers are V8-specific and shift between releases; SpiderMonkey and JavaScriptCore specialise array storage the same two ways under different names.

What this means for your code: for the numeric arrays you loop over hard, keep them dense and single-typed and let them stay on the fast path. For everything else, this is trivia, not a rule to police.