Every measurement and every output in this post was produced on Node 24.16.0, V8 13.6.233.17. The element layouts in section 7 were inspected with node --allow-natives-syntax.
1. What an array is, and why the definition matters
An array, in the data structure sense, is a contiguous block of memory holding elements of equal and known size. That single sentence carries everything else:
int xs[5];
// xs[3] is not a lookup. It is arithmetic:
// address(xs[3]) = address(xs) + 3 * sizeof(int)
Because the elements are the same size and sit next to each other, the address of element i is computable with one multiplication and one addition. Constant time indexing is not a feature the structure provides. It is a consequence of the layout, and it is unavoidable: there is nothing to search, nothing to hash, nothing to walk.
The same layout forces the rest of the contract. The length is fixed, because the block was allocated once. Every slot is the same size, because the arithmetic needs a constant stride. That usually means a uniform element type, although an array of pointers satisfies it while pointing at different things. There are no gaps, because every byte in the block is part of some element whether you wrote to it or not.
Hold on to those four properties: contiguous, fixed-size slots, fixed length, no gaps. They are what the word means. Everything else that borrows the name is borrowing it.
2. What JavaScript gives you instead
In the ECMAScript specification, an Array is an exotic object: an ordinary object with a customised [[DefineOwnProperty]] and a length property that maintains itself. Note the noun. It is an object.
That is not a metaphor. Property keys in JavaScript come in two kinds, strings and symbols, and array indices are always the first kind:
const a = [10, 20, 30];
Object.keys(a); // ["0", "1", "2"]
typeof Object.keys(a)[0]; // "string"
a['1'] === a[1]; // true
Reflect.ownKeys(a); // ["0", "1", "2", "length"]
a[1] is not an offset. It is a property lookup whose key happens to be the string "1", and the number you wrote got converted to that string on the way in. There is no address arithmetic anywhere in that abstraction. Whether any arithmetic happens underneath is a decision the engine makes later, and section 7 is about how it decides.
3. The index is not a number, it is a very specific string
If indices are string keys, then something has to decide which strings are indices. The specification does, and the rule is narrow: a key is an array index when it is the canonical decimal string of a non-negative integer below 232 minus 1. Canonical means the round trip survives, so String(Number(key)) === key. Non-negative and integer are separate requirements, and each one excludes a case that looks like it should work.
Everything else is an ordinary property that happens to live on an array:
const c = [1, 2, 3];
c[-1] = 'negative';
c[1.5] = 'fractional';
c['01'] = 'leading zero';
c.length; // 3, unchanged by all three
Object.keys(c); // ["0", "1", "2", "-1", "1.5", "01"]
Three writes that look like indexing, none of which indexed anything, and each one fails a different clause of the rule. -1 is not non-negative. 1.5 is not an integer. '01' is not canonical, because String(Number('01')) is '1', not '01'.
The upper bound is just as real:
const b = [];
b[4294967294] = 'last valid index'; // 2**32 - 2
b[4294967295] = 'not an index'; // 2**32 - 1
b.length; // 4294967295
Object.prototype.hasOwnProperty.call(b, '4294967295'); // true, as a plain property
const big = [];
big.length = 2 ** 32; // RangeError: Invalid array length
In a real array the index is an offset, bounded by the allocation. Here it is a string, bounded by a spec rule, and stepping one past the boundary does not fail. It silently stops being an index and becomes a property, which is a far stranger failure mode than a segmentation fault.
4. The structure admits absence
A real array cannot have a gap. Every slot is bytes, and bytes are always something, even if that something is uninitialised garbage.
A JavaScript array can be missing an element entirely, because a missing property is a thing objects can have:
const d = [1, 2, 3];
delete d[1];
d.length; // 3
1 in d; // false
d[1]; // undefined
Object.keys(d); // ["0", "2"]
JSON.stringify(d); // "[1,null,3]"
The length still says three. The key "1" is gone. Reading it yields undefined, which is exactly what you would get from a property that was never there, because that is what it now is.
Note that a hole and a stored undefined are different states that read identically:
const e = [1, undefined, 3];
1 in e; // true, the property exists and holds undefined
And because a hole is an absent property, reading it does what reading any absent property does: it walks the prototype chain.
const f = [1, , 3];
Array.prototype[1] = 'from the prototype';
f[1]; // "from the prototype"
That is the whole argument in one line. No array in the data structure sense can resolve a read by consulting a parent object, because there is no lookup to intercept, only arithmetic.
5. length is a setting, not a measurement
In a real array the length is a property of the allocation. You do not assign to it. In JavaScript it is a writable property, and writing to it is how you resize:
const g = [1, 2, 3, 4, 5];
g.length = 2; // [1, 2] the last three are gone
g.length = 5; // [1, 2, <3 empty items>]
3 in g; // false
Truncation destroys data. Extension manufactures holes. And length is not the number of elements, because nothing counts anything. The rule is narrower: writing an array index at or beyond the current length sets length to that index plus one, and writing to length directly sets it to whatever you asked for.
Both halves of that matter, because either one alone would be a nicer story than the truth:
const a = [];
a.length = 100;
Object.keys(a).length; // 0, a hundred elements it never had
const b = [1, 2, 3];
b.length = 1; // now 1, even though index 2 was assigned
That is why Object.keys(d).length and d.length disagree the moment a hole exists.
6. It is an object, so it carries whatever you attach
const h = [1, 2];
h.author = 'arthur';
h.length; // 2
Object.keys(h); // ["0", "1", "author"]
JSON.stringify(h); // "[1,2]"
The property exists, the length ignores it, and the serialiser drops it. Three subsystems, three different opinions about whether it is part of the array.
And the type system, such as it is, agrees with none of them:
typeof [1, 2, 3]; // "object"
Array.isArray([1,2,3]); // true
Array.isArray exists precisely because typeof cannot tell you. There is no array type to report.
7. So why is it fast? Because the engine lies for you
Everything above describes a dictionary with rules. If engines implemented it literally, array code would be unusably slow. They do not. V8 tracks what it calls an elements kind for every array and picks a representation to match.
The kinds form a lattice from most specific to most general, and the transitions are visible:
const a = [1, 2, 3]; // PACKED_SMI_ELEMENTS
a.push(4.5); // PACKED_DOUBLE_ELEMENTS
a.push('text'); // PACKED_ELEMENTS
Each step widens what a slot may hold, and each step costs something. The packed small integer case stores tagged small integers directly in a contiguous backing store, which is an actual array in the sense of section 1. The packed double case stores unboxed doubles. The general case uses tagged slots that hold either kind: a small integer still sits in the slot directly, and anything else is a reference to the heap.
A second axis tracks whether holes are possible:
| what you wrote | resulting kind |
|---|---|
[1, 2, 3] | PACKED_SMI |
[1.1, 2.2] | PACKED_DOUBLE |
[] | PACKED_SMI |
new Array(3) | HOLEY_SMI |
[1,2,3] then delete a[1] | HOLEY_SMI |
[1,2,3] then a[10] = 4 | HOLEY_SMI |
[1,2,3] then a[100000] = 1 | DICTIONARY_ELEMENTS |
Holey representations must check, for every read, whether the slot is a hole, and if it is, fall back to the prototype walk from section 4. Dictionary mode abandons the buffer entirely and stores the elements in a hash table, which is much closer to the property lookup the specification actually describes.
The transitions do not reverse on their own. Removing the element that caused the change does not restore the previous representation:
const h = [1.5, 2.5, 3.5]; // PACKED_DOUBLE
h.push('x'); // PACKED_ELEMENTS
h.pop(); // still PACKED_ELEMENTS
They are not strictly one way, though. An operation that rewrites every slot can produce a narrower kind, and fill is the clearest case in current V8. It takes a holey array back to packed, and in my measurement it took a PACKED_ELEMENTS array all the way back to PACKED_SMI:
const a = [1, 2, 3];
delete a[1]; // HOLEY_SMI
a.fill(0); // PACKED_SMI, back down the lattice
So the useful formulation is not that the lattice is one way, it is that you never climb back down it by accident. Widening happens as a side effect of ordinary writes; narrowing takes an operation that rewrites the whole backing store.
This is the real answer to the title. The array you think you are using is a representation the engine grants you while your usage stays inside a set of assumptions, and silently revokes when it does not. The specification promises an object with a self-maintaining length. The contiguous buffer is a performance optimisation, not a guarantee.
8. The cliff, measured
Three arrays of 100,000 integers, identical contents and identical length, differing only in elements kind. Summing each with a plain indexed for loop, 100 warmup runs, median of 51 measured runs:
| elements kind | median | relative |
|---|---|---|
PACKED_SMI | 0.053 ms | 1x |
HOLEY_SMI | 0.080 ms | about 1.2x to 1.5x across runs |
DICTIONARY | 2.94 ms | about 43x to 56x across runs |
The holey penalty is real but modest, and it moved enough between runs that I would not quote a precise multiplier for it. The dictionary penalty is not modest and did not move: it is the difference between arithmetic and hashing, on the same data, in the same loop.
What produced the dictionary case is worth staring at. It was one assignment:
const a = [];
for (let i = 0; i < 100000; i++) a.push(i);
a[5000000] = 1; // one index far past the end
a.length = 100000; // even undoing it does not restore the representation
A single write to a distant index converted a contiguous buffer into a hash table, and the array kept every appearance of being an array while running fifty times slower.
9. The real array is spelled differently
JavaScript does have the structure from section 1. It has another name:
const t = new Int32Array(3);
t.BYTES_PER_ELEMENT; // 4
t.buffer.byteLength; // 12, exactly 3 * 4
t[5] = 99; // out of bounds: discarded
t[5]; // undefined
Object.prototype.hasOwnProperty.call(t, '5'); // false, no property was created
t.length = 10; // TypeError in strict mode: length has only a getter
Fixed length, uniform element type, contiguous backing buffer, and a write outside the bounds is discarded rather than turned into a property. Twelve bytes, three integers, address arithmetic. That is an array.
One qualification on “fixed”. Since resizable ArrayBuffer, a typed array constructed over a resizable buffer tracks it, and its length changes when the buffer does:
const rab = new ArrayBuffer(8, { maxByteLength: 64 });
const t = new Int32Array(rab); // t.length is 2
rab.resize(32); // t.length is now 8
new Int32Array(3), the case above, cannot do that. Its buffer is not resizable, so its length is settled at construction.
It is still an object, and it will still hold what you attach to it (t.foo = 'bar' works). But the indexed part is no longer negotiable, which is the entire difference.
10. What follows in practice
All of the following was measured the same way, and it is the practical residue of the argument:
| construction | resulting kind |
|---|---|
[] then push in a loop | PACKED_SMI |
new Array(3) | HOLEY_SMI |
new Array(3).fill(0) | PACKED_SMI |
Array.from({length: 3}, (_, i) => i) | PACKED_SMI |
[1,2,3] then splice(1, 1) | PACKED_SMI |
[1,2,3] then delete a[1] | HOLEY_SMI |
map over a holey array | HOLEY_SMI |
filter over a holey array | HOLEY_SMI |
Four things follow, in descending order of how much they matter:
Never write far past the end. This is the only one with a catastrophic failure mode, and section 8 measured it. Everything else on this list costs a fraction; this one costs an order of magnitude, and it survives the removal of what caused it.
Remove with splice or pop, not delete. delete punches a hole by definition: it removes a property. splice re-indexes and keeps the array packed.
If you pre-size, fill. new Array(n) is holey from birth. new Array(n).fill(0) and Array.from({length: n}, fn) are not.
Holes propagate. map and filter over a holey array return holey arrays, even though filter drops the holes. If a hole enters your data early, it travels.
And a calibration, because this topic attracts folklore: on the measurement above, holey cost roughly twenty to fifty percent on a tight summing loop. That is worth avoiding, not worth rewriting code in a panic over. Dictionary mode is the one to actually fear.
11. Conclusion
The name is the problem. “Array” imports an expectation of contiguous memory, constant time indexing by arithmetic, fixed-size slots and fixed length, and JavaScript never promised any of the four. What the language defines is an object whose integer-looking string keys get special treatment and whose length maintains itself.
That the thing usually behaves like an array anyway is the work of the engine, not the language, and the engine does it conditionally. Every quirk in sections 3 through 6 is not a wart on an array. It is the object showing through, which is what was there the whole time.
The practical value of knowing this is not micro-optimisation. It is that the behaviours stop being surprises. A hole resolving through the prototype chain, a length that disagrees with Object.keys, an index that quietly becomes a property, a fiftyfold slowdown with no visible cause: none of those are anomalies to memorise once you know you are holding an object that is pretending, competently and conditionally, to be an array.
