Part 12 · 5 chapters · ~40 min

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.

81

Map and Set: an ordered hash table

the question

"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.

operations and their cost
  1. 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.
  2. 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).
  3. 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).
  4. size: stored; O(1). clear(): replaces the table with a fresh empty one; O(1), and live iterators see the end.
  5. 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.
  6. Key semantics: SameValueZero: NaN is one key; +0 and -0 are one key; objects by identity; strings by content.
code
// 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
Map versus object, decided
  1. Dynamic keys, many keys, deletions, object keys, size, ordered iteration, hot path: Map.
  2. A fixed set of known keys (a record), JSON in and out, literal configs: an object; keep its shape consistent (part 3).
  3. The failure modes of object-as-map: dictionary mode after enough keys, prototype keys ("constructor", "__proto__", "toString") colliding with inherited properties (use Object.create(null) if you must), and internalisation of every dynamic key on every access.
run it
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.
MAP AND SET: AN ORDERED HASH TABLE
why insertion order is free and deletion leaves holes
swipe the figure sideways, or tap expand for full screen
1/7
empty
new Map(). An OrderedHashMap with a small initial capacity: a buckets array (hash → first entry index), an entries array (key, value, next-in-chain per entry), counts of live entries and deleted entries. Everything in one FixedArray.
82

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.

WeakMap and WeakSet
  1. 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.
  2. 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.
  3. 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.
  4. Uses: per-object metadata (DOM node → layout cache; library object → wrapper), private state (the pre-#field pattern, still useful for objects you did not create), memoisation keyed by object, "have I processed this object" sets.
  5. 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.
WeakRef
  1. new WeakRef(obj), ref.deref(): the target, or undefined once collected.
  2. 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.
  3. 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.
FinalizationRegistry
  1. 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.
  2. 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.
  3. 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.
  4. using declarations and Symbol.dispose (explicit resource management, ES2026, shipping) are the deterministic alternative: cleanup at scope exit, no GC involved. Prefer them where the lifetime is lexical.
run it
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.
WEAK REFERENCES
WeakMap, WeakRef, FinalizationRegistry, and what the collector does with each
swipe the figure sideways, or tap expand for full screen
1/6
ephemeron table
const meta = new WeakMap(); meta.set(node, {measured: 42}). The WeakMap holds an ephemeron table: entries keyed by object identity whose keys are not traced as roots. node stays alive only if something else references it.
83

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.

the objects
  1. ArrayBuffer: a fixed-length byte block, allocated outside the JS heap (external memory), zero-filled, referenced by a small JS object. byteLength; slice copies; transfer() moves the bytes and detaches the source; resizable buffers (maxByteLength) can grow in place.
  2. Typed arrays: Int8/Uint8/Uint8Clamped/Int16/Uint16/Int32/Uint32/Float32/Float64/BigInt64/BigUint64Array, and Float16Array. 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.
  3. DataView: explicit getX/setX(byteOffset, littleEndian) for every type at any alignment. For parsing mixed-layout binary formats.
  4. Node's Buffer is a Uint8Array subclass with encoding helpers and a slab allocator for small buffers; everything above applies.
  5. SharedArrayBuffer: the same memory visible from several agents (workers); views work the same; Atomics for ordering and waiting. Requires cross-origin isolation in browsers (the browser course, part 9).
performance properties
  1. 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: Float64Array instances share one map and elements kind).
  2. 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.
  3. No barrier, no boxing: storing numbers writes bytes. A hot loop over a Float64Array allocates nothing.
  4. 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.
  5. Methods: set (bulk copy, memcpy-fast for same-type), subarray (a new view, no copy), slice (copy), fill, map/filter/reduce/sort (typed results; sort is numeric by default, unlike Array).
  6. Conversions: Array.from(typed) boxes everything; new Float64Array(arr) converts each element. Both O(n); do them at boundaries, not in loops.
worked numbers
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.
ARRAYBUFFER, VIEWS, AND THE ELEMENTS THAT NEVER CHANGE
raw bytes with typed windows
swipe the figure sideways, or tap expand for full screen
1/6
ArrayBuffer
const buf = new ArrayBuffer(16): 16 bytes allocated as a backing store outside the V8 heap (counted in "external" memory, not the old-space limit). Zero-filled. The JS object is a small handle with a pointer and a length.
84

Arrays as collections: what to use when

NeedUseWhy (mechanism)Avoid
Ordered list of values, push/pop, iterationArrayElements kinds; builtins with fast paths; the optimiser knows itHoles, mixed types, new Array(n) then fill by index
Queue (FIFO) with many operationsA ring buffer, or a Map/linked structure; a two-stack queueshift() 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
StackArray with push/popO(1) at the end; the elements store grows by 1.5× plus a constantNothing; this is the ideal case
Lookup by string key, dynamic keysMapOrdered hash table; no internalisation; no shapeObject as a map (dictionary mode, prototype keys)
Lookup by object identityMap (strong) or WeakMap (does not retain)Identity hash cached on the objectArrays with indexOf (O(n)); JSON-stringified keys
Membership testSetHash lookuparr.includes in a loop (O(n) each)
Fixed set of small-integer keysArray indexed by key, or a typed arrayDirect indexing; PACKED elementsMap, when the keys are dense ints
Bulk numbersTyped arrayNo boxing, no barrier, exact memoryArray of HeapNumbers; array of objects for points
Bytes and binary protocolsUint8Array / Buffer, DataView for headersRaw bytes; encoding helpersStrings as byte containers
Sorted order with insertsA sorted array with binary search (small), or a tree/skip list library (large)Array splice is O(n) but fast for thousandsSorting after every insert
LRU cacheMap with delete-and-reinsert (see code), or a ring over typed arrays for hot pathsInsertion order gives recency for freeArrays with indexOf and splice
Set operationsSet with union, intersection, difference, isSubsetOf (ES2025)Builtins with fast pathsArray filter with includes (O(n²))
Group byMap.groupBy / Object.groupBy (ES2024)One passreduce with object accumulation into dictionary mode
Immutable snapshotsStructural sharing libraries, or spread for small objectsCloneObject IC makes {...o} cheapJSON.parse(JSON.stringify()) for copies (O(size), loses types)
code
// 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.
85

Reading collections: tools

ToolShowsUse it for
%DebugPrint(map)OrderedHashMap capacity, element count, deleted count; the backing FixedArrayHow 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 externalConfirming the representation; spotting detached buffers after a transfer
process.memoryUsage().arrayBuffers, .externalOff-heap bytes held by ArrayBuffers and other external memoryBuffers 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 sizesThe unbounded-collection leak; buffers kept by forgotten views
--trace-gc-verbose "ephemeron" lines, or marking phase times growing with weak entry countGC cost of large WeakMapsA WeakMap with millions of entries slowing every major GC
--trace-deopt reason "out of bounds" on typed array accessOOB in a hot loopAn off-by-one costing optimisation
--expose-gc + gc() + deref() in a later taskWeak reference clearingVerifying WeakRef / FinalizationRegistry behaviour in a test
A benchmark of Map vs object for your key distributionThe crossover on your machineSettling the argument with your data, not folklore
the pointer
Part 13 takes the classic design patterns and ties each to the mechanism it rides on: closures (part 8), prototypes (part 7), Proxy (part 7), Map and WeakMap (this part), promises (part 10), generators (part 9). A pattern is a shape of code that keeps the engine's guesses true, or a shape that breaks them; knowing which is the point.