Collections And Typed Arrays
Map keeps order because it appends; WeakMap keeps nothing alive because the marker treats it specially; a typed array never changes representation because its type is its kind. This part is the ordered hash table behind Map and Set, the three weak primitives and what the collector does with each, ArrayBuffer and its views and why they are the fastest thing you have, a what-to-use-when table with the mechanism beside each row, and the tools.
Map and Set: an ordered hash table
"Map keeps insertion order. Doesn't that make it slower than a plain hash table?"
No, because of how it is built. V8's Map and Set are one structure: a bucket array that indexes into an entries array where keys and values are appended in insertion order, with chain pointers for collisions. Lookup is a hash and a short chain walk; iteration is a linear walk of the entries array; order is a free consequence of appending. Deletion leaves a hole that a later rehash compacts. The same structure, without the order guarantee for integer keys, is what a dictionary-mode object uses.
set(k, v),add(v): hash k (strings cache their hash; objects get an identity hash written into a hidden field on first use; numbers hash by value); find the bucket; walk the chain comparing with SameValueZero; if absent, append an entry and link it. Amortised O(1); a growth rehash when the table fills.get(k),has(k): hash, bucket, chain walk. 10 to 30 ns typical. Monomorphic in optimised code when the key type is stable (all strings, or all objects).delete(k): find; replace the entry with a hole; count it. O(1). Holes are skipped by iteration and reclaimed by the next rehash (triggered by growth or by the hole count).size: stored; O(1).clear(): replaces the table with a fresh empty one; O(1), and live iterators see the end.- Iteration (
keys,values,entries,forEach,for...of): a walk of the entries array by index, skipping holes. Entries added during iteration are visited (they are appended); entries deleted are skipped. A rehash during iteration transitions the iterator's index through the old table's forwarding information, so iterators never break. - Key semantics: SameValueZero:
NaNis one key;+0and-0are one key; objects by identity; strings by content.
// object as a map vs Map, for dynamic string keys
const counts = {}; for (const w of words) counts[w] = (counts[w] ?? 0) + 1
// each new key: internalise w (hash + table probe), then a transition or (soon) dictionary mode. ~1000 distinct keys → dictionary
// each read: internalise w again, hash lookup in the NameDictionary. plus: "constructor", "__proto__" are keys you did not expect
const counts = new Map(); for (const w of words) counts.set(w, (counts.get(w) ?? 0) + 1)
// each op: hash w (cached on the string after the first time), bucket, chain. no internalisation, no shape, no prototype pollution
// object-as-map is right when: keys are a known small set (a record), or you need JSON.stringify directly, or it is a literal config
// Map is right when: keys are dynamic, many, deleted, or objects; you need insertion order or size; or it is hot- Dynamic keys, many keys, deletions, object keys, size, ordered iteration, hot path: Map.
- A fixed set of known keys (a record), JSON in and out, literal configs: an object; keep its shape consistent (part 3).
- The failure modes of object-as-map: dictionary mode after enough keys, prototype keys (
"constructor","__proto__","toString") colliding with inherited properties (useObject.create(null)if you must), and internalisation of every dynamic key on every access.
node --allow-natives-syntax -e "const m=new Map(); for(let i=0;i<5;i++) m.set('k'+i,i); m.delete('k1'); %DebugPrint(m)". The OrderedHashMap shows capacity, number of elements, and number of deleted elements: the hole is counted, not removed.WeakMap, WeakRef, FinalizationRegistry
Three ways to reference an object without keeping it alive, each resolved by the garbage collector during a major GC's finalisation. They exist so you can attach data to objects you do not own, cache without leaking, and release external resources, with the understanding that the collector's timing is not yours.
- Semantics: an ephemeron table. A key is not rooted by the map; a value is reachable if and only if its key is. When the key is collected, the entry vanishes silently.
- Implementation: a hash table keyed by identity hash, with the marker treating entries specially: a fixpoint loop during marking (values of marked keys get marked, which may mark other keys, repeat), then clearing of entries with unmarked keys at finalisation. The cost scales with the number of weak entries per major GC.
- Restrictions that follow: keys must be objects (or non-registered symbols): things that can die. No iteration, no size, no clear: the contents are a function of GC state, and exposing them would expose GC timing.
- Uses: per-object metadata (DOM node → layout cache; library object → wrapper), private state (the pre-
#fieldpattern, still useful for objects you did not create), memoisation keyed by object, "have I processed this object" sets. - Cost: get and set are a hash lookup like Map (the identity hash is cached on the object after first use). Slightly more GC work per entry. No leak possible through the map itself.
new WeakRef(obj),ref.deref(): the target, or undefined once collected.- KeepDuringJob: a target that was dereferenced is kept alive until the end of the current job, so that within one synchronous run,
deref()is consistent. Across jobs, anything can happen. - Uses: caches where recomputation is acceptable and the cached object is large; observer lists that should not keep observers alive (with cleanup of dead refs on iteration). Not for correctness; not as a general "weak pointer" in logic.
registry.register(target, heldValue, unregisterToken?): when target is collected, the callback is called with heldValue, in a separate task, at some later time.unregister(token)cancels.- Guarantees, or the lack of them: the callback may run long after collection, may run never (process exit, page unload), and must not depend on the target (it is gone). The heldValue must not reference the target or it never dies.
- Uses: releasing a resource the JS object wrapped and that the collector cannot see: a WebGL buffer, a Wasm allocation, a native handle, a file descriptor held by a wrapper. As a safety net behind explicit
dispose(), never instead of it. usingdeclarations andSymbol.dispose(explicit resource management, ES2026, shipping) are the deterministic alternative: cleanup at scope exit, no GC involved. Prefer them where the lifetime is lexical.
node --expose-gc -e "let o={}; const r=new WeakRef(o); o=null; gc(); setTimeout(()=>console.log(r.deref()),0)" prints undefined. Remove the setTimeout and print synchronously after gc(): the object is still there, kept by KeepDuringJob until the job ends.ArrayBuffer, typed arrays, DataView
A typed array is the one collection whose representation never changes under you: a view of a fixed element type over a block of raw bytes. No hidden classes, no elements-kind lattice, no boxing, no write barrier. For bulk numeric data it is the fastest and smallest thing the language offers, and it is the interface to everything outside the engine: files, sockets, GPU, audio, WebAssembly.
- ArrayBuffer: a fixed-length byte block, allocated outside the JS heap (external memory), zero-filled, referenced by a small JS object.
byteLength;slicecopies;transfer()moves the bytes and detaches the source; resizable buffers (maxByteLength) can grow in place. - Typed arrays:
Int8/Uint8/Uint8Clamped/Int16/Uint16/Int32/Uint32/Float32/Float64/BigInt64/BigUint64Array, andFloat16Array. Each is a view: buffer, byteOffset, length. Indexed access converts to and from JS numbers (or BigInts) according to the type: wrapping for ints, clamping for Uint8Clamped, rounding to the float precision. - DataView: explicit
getX/setX(byteOffset, littleEndian)for every type at any alignment. For parsing mixed-layout binary formats. - Node's Buffer is a
Uint8Arraysubclass with encoding helpers and a slab allocator for small buffers; everything above applies. - SharedArrayBuffer: the same memory visible from several agents (workers); views work the same;
Atomicsfor ordering and waiting. Requires cross-origin isolation in browsers (the browser course, part 9).
- Access in optimised code: a bounds check and a typed load or store. No type guard on the element, ever. The optimiser specialises on the view's type (which is the map of the typed array object:
Float64Arrayinstances share one map and elements kind). - Memory: exactly element size × length, plus one small header for the view and one for the buffer. A million floats is 8 MB; the same as an array of HeapNumbers would be ~20 MB scattered.
- No barrier, no boxing: storing numbers writes bytes. A hot loop over a Float64Array allocates nothing.
- Out-of-bounds: reads return undefined, writes are ignored, no exception; the optimiser deopts the first time it sees OOB at a site and compiles a slower path after. Keep indices in range in hot loops.
- Methods:
set(bulk copy, memcpy-fast for same-type),subarray(a new view, no copy),slice(copy),fill,map/filter/reduce/sort(typed results;sortis numeric by default, unlike Array). - Conversions:
Array.from(typed)boxes everything;new Float64Array(arr)converts each element. Both O(n); do them at boundaries, not in loops.
struct of arrays, in numbers (1 M points, x and y as doubles):
[{x, y}, …] 1 M objects × ~24 B + the array's 4 MB of pointers ≈ 28 MB, scattered, each object a GC unit, the elements array has pointers (barrier on writes)
xs, ys: Float64Array 2 × 8 MB = 16 MB, contiguous, zero GC objects beyond two, no pointers
sum of x: both ~1 ns/element when optimised; the typed version wins on cache behaviour and is immune to deopts
the typed layout is also the one you can hand to a worker (transfer), to WebGL, or to Wasm without conversion.Arrays as collections: what to use when
| Need | Use | Why (mechanism) | Avoid |
|---|---|---|---|
| Ordered list of values, push/pop, iteration | Array | Elements kinds; builtins with fast paths; the optimiser knows it | Holes, mixed types, new Array(n) then fill by index |
| Queue (FIFO) with many operations | A ring buffer, or a Map/linked structure; a two-stack queue | shift() is O(n) on a plain array (elements move; V8 has a left-trim trick that helps for small arrays) | shift() in a hot loop on large arrays |
| Stack | Array with push/pop | O(1) at the end; the elements store grows by 1.5× plus a constant | Nothing; this is the ideal case |
| Lookup by string key, dynamic keys | Map | Ordered hash table; no internalisation; no shape | Object as a map (dictionary mode, prototype keys) |
| Lookup by object identity | Map (strong) or WeakMap (does not retain) | Identity hash cached on the object | Arrays with indexOf (O(n)); JSON-stringified keys |
| Membership test | Set | Hash lookup | arr.includes in a loop (O(n) each) |
| Fixed set of small-integer keys | Array indexed by key, or a typed array | Direct indexing; PACKED elements | Map, when the keys are dense ints |
| Bulk numbers | Typed array | No boxing, no barrier, exact memory | Array of HeapNumbers; array of objects for points |
| Bytes and binary protocols | Uint8Array / Buffer, DataView for headers | Raw bytes; encoding helpers | Strings as byte containers |
| Sorted order with inserts | A sorted array with binary search (small), or a tree/skip list library (large) | Array splice is O(n) but fast for thousands | Sorting after every insert |
| LRU cache | Map with delete-and-reinsert (see code), or a ring over typed arrays for hot paths | Insertion order gives recency for free | Arrays with indexOf and splice |
| Set operations | Set with union, intersection, difference, isSubsetOf (ES2025) | Builtins with fast paths | Array filter with includes (O(n²)) |
| Group by | Map.groupBy / Object.groupBy (ES2024) | One pass | reduce with object accumulation into dictionary mode |
| Immutable snapshots | Structural sharing libraries, or spread for small objects | CloneObject IC makes {...o} cheap | JSON.parse(JSON.stringify()) for copies (O(size), loses types) |
// an LRU cache in one Map, because deletion + reinsertion moves the entry to the end of the order
class LRU {
#map = new Map(); #max
constructor(max) { this.#max = max }
get(k) { if (!this.#map.has(k)) return undefined; const v = this.#map.get(k); this.#map.delete(k); this.#map.set(k, v); return v }
set(k, v) { if (this.#map.has(k)) this.#map.delete(k); else if (this.#map.size >= this.#max) this.#map.delete(this.#map.keys().next().value); this.#map.set(k, v) }
}
// cost per get: has + get + delete + set = 4 hash ops; the delete leaves a hole that the next rehash compacts.
// for a hot path with a small max, a ring buffer over two typed arrays (keys as ids) beats it; for everything else this is fine.Reading collections: tools
| Tool | Shows | Use it for |
|---|---|---|
%DebugPrint(map) | OrderedHashMap capacity, element count, deleted count; the backing FixedArray | How many holes a churned Map carries; whether it is about to rehash |
%DebugPrint(typedArray) | Elements kind (e.g. FLOAT64_ELEMENTS), length, byte offset, buffer, whether detached, whether on-heap (small typed arrays have inline storage) or external | Confirming the representation; spotting detached buffers after a transfer |
process.memoryUsage().arrayBuffers, .external | Off-heap bytes held by ArrayBuffers and other external memory | Buffers that the heap limit does not count but RSS does |
DevTools Memory snapshot: Map, Set, WeakMap entries with retained sizes; ArrayBuffer and (array buffer) entries; "system / JSArrayBufferData" | Which collections retain what; buffer sizes | The unbounded-collection leak; buffers kept by forgotten views |
--trace-gc-verbose "ephemeron" lines, or marking phase times growing with weak entry count | GC cost of large WeakMaps | A WeakMap with millions of entries slowing every major GC |
--trace-deopt reason "out of bounds" on typed array access | OOB in a hot loop | An off-by-one costing optimisation |
--expose-gc + gc() + deref() in a later task | Weak reference clearing | Verifying WeakRef / FinalizationRegistry behaviour in a test |
| A benchmark of Map vs object for your key distribution | The crossover on your machine | Settling the argument with your data, not folklore |