The Arrays article described an array as a block of same-sized slots sitting side by side in memory. If you write Go, that description matches what you type every day. If you write JavaScript, it probably doesn't: you never tell a JavaScript array how big it is, what it holds, or when to grow.
Here is the same list of four numbers in both languages:
Both languages run on the same hardware, and the hardware only knows how to do one kind of array. This article looks at what each language does with that fact, using Uint32Array to measure what a JavaScript array actually costs in memory.
Why Go asks for the size
In Go, the length of an array is part of its type. [4]uint32 and [5]uint32 are different types, as different as int and string:
Because the type carries the length, the compiler knows the exact size of every array before the program runs. A [4]uint32 is 4 elements × 4 bytes = 16 bytes, no more:
There is no header, no capacity field and no type tag stored next to the data. Indexing is the start + index × 4 calculation from the parent article, and the compiler can place the whole array on the stack or inline inside a struct, because it knows how many bytes to reserve.
The cost is the rigidity you saw in the Go snippet: the size can't change, and it has to be known when you write the code. For everything else, Go gives you slices, a small header (pointer, length, capacity: 24 bytes on 64-bit) that points into an array and replaces it with a bigger one when it fills up. The Arrays and slices article covers how they grow. What matters here is that Go makes the choice explicit: you pick a fixed array or a growable slice, and the element type is always fixed.
What a JavaScript Array really is
The ECMAScript specification doesn't define Array as a block of memory. It defines it as an exotic object: a regular object whose keys are strings, with special treatment for keys that look like array indices (integers from 0 to 2³² − 2) and a length property that the engine keeps one above the largest index.
That definition explains everything JavaScript lets you do with an array:
None of this fits the definition from the parent article. There is no fixed element size, no guarantee of contiguity and no fixed length. This is why some books and courses refuse to call the JavaScript Array an array at all and describe it as a list-like object or a hash map with integer keys. As far as the language specification is concerned, they're right.
How V8 makes it fast anyway
If JavaScript engines implemented that specification literally, every arr[i] would be a hash table lookup. They don't. V8 (the engine behind Chrome and Node.js) tracks what each array contains and, whenever it can, stores the elements in a real contiguous array behind the scenes.
It does this by assigning each array an elements kind. The three main ones are:
| Elements kind | Holds | Stored as |
|---|---|---|
PACKED_SMI_ELEMENTS | Only small integers (SMIs) | Tagged integers, one per slot |
PACKED_DOUBLE_ELEMENTS | Numbers, including fractions and large ints | Raw 64-bit floats, one per slot |
PACKED_ELEMENTS | Anything (strings, objects, mixed) | A pointer per slot |
Each one also has a HOLEY_ variant for arrays with gaps, and an array that is too sparse falls back to DICTIONARY_ELEMENTS, which is an actual hash table. You can watch the transitions in Node with the --allow-natives-syntax flag:
Transitions only go one way. Once a holds a string, removing the string doesn't bring it back to PACKED_DOUBLE_ELEMENTS, and once an array has a hole it stays HOLEY_ even after you fill the hole.
Growth works like the dynamic arrays from the parent article, with a different factor. When an array runs out of room, V8 allocates new_length + new_length / 2 + 16 slots and copies the elements over. Pushing one million integers one at a time causes 26 reallocations and leaves the array with room for 1,304,209 elements.
Uint32Array: a real array in JavaScript
JavaScript does have arrays in the sense of the parent article: typed arrays. A Uint32Array is a view over an ArrayBuffer, a raw block of bytes, and it behaves much more like Go's [N]uint32 than like Array:
The length is fixed, every element is an unsigned 32-bit integer, and there is no push, no holes and no mixed types. In exchange, the memory layout is exactly the one from the parent article:
4 bytes per element, 16 bytes in total
Compare it with an Array that went through PACKED_ELEMENTS. The slots are still contiguous, but each one is 8 bytes, and the values that aren't SMIs live somewhere else on the heap:
8-byte slots; index 1 and 2 point to separate heap objects
Measuring the footprint
To put numbers on this, I allocated one million elements in each shape and measured the heap (and, for the typed array, the ArrayBuffer memory) before and after, forcing garbage collection on both sides:
On Node.js 24 (V8 13.6, 64-bit Linux), with the Go equivalent from unsafe.Sizeof for reference:
| Container | Elements kind | Bytes per element |
|---|---|---|
Go [1_000_000]uint32 | n/a | 4.00 |
Uint32Array | n/a | 4.00 |
Array.from with small integers | PACKED_SMI_ELEMENTS | 8.00 |
push with small integers | PACKED_SMI_ELEMENTS | 10.44 |
push with fractional numbers | PACKED_DOUBLE_ELEMENTS | 10.44 |
push with mixed values | PACKED_ELEMENTS | 18.44 |
The numbers line up with the layouts above:
- 4 bytes is the data and nothing else, in both Go and the typed array.
- 8 bytes is one tagged slot per SMI, or one raw double per number. Without pointer compression a slot is 8 bytes, so small integers that would fit in 4 bytes take 8.
- 10.44 bytes is the same 8-byte slot plus the unused capacity left by the growth formula: 1,304,209 slots × 8 bytes spread over one million elements.
- 18.44 bytes adds the boxed numbers. Half the values don't fit in an SMI, and each of those becomes a separate 16-byte heap object behind its pointer.
The mixed array uses 4.6 times the memory of the Uint32Array for the same million numbers, and all of them fit in 32 bits. In Chrome, pointer compression halves the slot size, so the gap there is smaller, but the shape of the result is the same.
What we give up for developer experience
JavaScript's Array is designed so that you never have to think about memory. You don't pick a size, you don't pick a type, and the array is never "full". The engine makes that work by guessing, tracking and converting behind your back, and you pay for it in a few places:
- Memory: at least 2× the bytes of a typed array for integers in Node.js, plus growth slack, plus a heap object for every number that isn't an SMI.
- Predictability: whether
arr[i]is a direct offset calculation, a pointer dereference or a dictionary lookup depends on the history of the array, not on its declaration. A singlearr.push('')orarr[1e6] = 1changes the representation for good. - Locality: in
PACKED_ELEMENTS, the slots are contiguous but the values aren't. Iterating means following pointers to scattered objects, which loses the cache advantage described in the parent article. - Type guarantees: nothing stops a string from ending up in an array of prices.
What you get in return is real: shorter code, no out-of-bounds compile errors to fight, and an array that adapts to whatever the program does with it. For most application code (a list of users, the items in a cart) the difference in bytes doesn't matter, and Array is the right choice.
Typed arrays are worth the rigidity when the data is large, numeric and uniform:
- Binary data: files, network packets, image pixels (
Uint8ClampedArraybacks the CanvasImageData). - Audio samples and WebGL vertex buffers, where the APIs expect typed arrays directly.
- Numeric work on millions of values, where 4 or 8 predictable bytes per element beats 10 to 18.
- Sharing memory with WebAssembly, whose linear memory is an
ArrayBuffer.
Summary
| Container | Size decided | Element type | Can grow | Bytes per uint32-sized value (Node.js) |
|---|---|---|---|---|
Go [N]uint32 | At compile time | Fixed | No | 4 |
Go []uint32 | At runtime | Fixed | Yes (append) | 4 + spare capacity |
JS Uint32Array | At creation | Fixed (uint32) | No | 4 |
JS Array | Never, grows on demand | Anything | Yes | 8 to 18+, depending on elements kind |
- In Go, the array length is part of the type, so the compiler knows the exact byte size and the layout is the one from the parent article: data and nothing else.
- The JavaScript
Arrayis specified as an object with string keys and a magiclength. That is why some authors don't consider it an array at all. - V8 hides that by tracking elements kinds and storing elements contiguously when it can, but the representation depends on what the array has held and only ever becomes more general.
Uint32Arrayis JavaScript's real array: fixed length, fixed type, exactly 4 bytes per element over anArrayBuffer.- In Node.js 24, the same million integers took 4 bytes each in a
Uint32Arrayand between 8 and 18.44 bytes each in anArray, depending on how it was built and what else it held.