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.
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.
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.
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.
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.
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.
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-0sneaking in. - Keep them dense. Do not
deleteelements, 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 withpush, 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 aHOLEYcounterpart 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
DOUBLEforever, one object marks itELEMENTSforever, 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 withpushstays 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.