Skip to main content

Arrays: Go vs JavaScript

12 minutes read•Filed underData Structureson

Why Go makes you declare an array's size and JavaScript doesn't. Compare memory footprints with Uint32Array and see what JavaScript trades for convenience.

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 kindHoldsStored as
PACKED_SMI_ELEMENTSOnly small integers (SMIs)Tagged integers, one per slot
PACKED_DOUBLE_ELEMENTSNumbers, including fractions and large intsRaw 64-bit floats, one per slot
PACKED_ELEMENTSAnything (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:

new Uint32Array([85, 92, 78, 95])
0x1000
85
index 0
0x1004
92
index 1
0x1008
78
index 2
0x100C
95
index 3

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:

[85, 3000000000, 'x', 95] in Node.js
0x2000
SMI 85
index 0
0x2008
→ 3e9
index 1
0x2010
→ 'x'
index 2
0x2018
SMI 95
index 3

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:

ContainerElements kindBytes per element
Go [1_000_000]uint32n/a4.00
Uint32Arrayn/a4.00
Array.from with small integersPACKED_SMI_ELEMENTS8.00
push with small integersPACKED_SMI_ELEMENTS10.44
push with fractional numbersPACKED_DOUBLE_ELEMENTS10.44
push with mixed valuesPACKED_ELEMENTS18.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 single arr.push('') or arr[1e6] = 1 changes 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 (Uint8ClampedArray backs the Canvas ImageData).
  • 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

ContainerSize decidedElement typeCan growBytes per uint32-sized value (Node.js)
Go [N]uint32At compile timeFixedNo4
Go []uint32At runtimeFixedYes (append)4 + spare capacity
JS Uint32ArrayAt creationFixed (uint32)No4
JS ArrayNever, grows on demandAnythingYes8 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 Array is specified as an object with string keys and a magic length. 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.
  • Uint32Array is JavaScript's real array: fixed length, fixed type, exactly 4 bytes per element over an ArrayBuffer.
  • In Node.js 24, the same million integers took 4 bytes each in a Uint32Array and between 8 and 18.44 bytes each in an Array, depending on how it was built and what else it held.