weak-collections
v0.3.0
Published
Weak data structures and lifetime instruments for JavaScript: iterable weak sets and maps, weak-value maps, composite-key ephemeron maps, weak memoization, bidirectional weak maps, GC-aware LRU caches, self-pruning weak graphs, weak-subscriber emitters, c
Maintainers
Readme
weak-collections
Collections that let their members die. A zero-dependency family built on
WeakRef, WeakMap, and FinalizationRegistry that gives JavaScript the
weak data structures the language left out — iterable weak sets and maps, a
weak-value map, composite-key ephemeron maps, weak memoization, a
bidirectional weak map, a GC-aware LRU cache, and a self-pruning object graph
— plus the production lifetime instruments built on the same discipline: an
emitter whose subscriptions die with their subscribers, cohort-level leak
attribution, a finalized-without-dispose reporter, and the GC test kit that
makes any of this verifiable.
import {
CompositeWeakMap, IterableWeakMap, IterableWeakSet,
WeakBiMap, WeakGraph, WeakLRUCache, WeakValueMap, weakMemo,
} from 'weak-collections'
// Enumerate listeners without keeping any of them alive.
const listeners = new IterableWeakSet<object>()
listeners.add(component)
for (const live of listeners) notify(live)
// An entry keyed by BOTH objects — gone the moment either dies.
const overlaps = new CompositeWeakMap<[object, object], number>()
overlaps.set([rectA, rectB], 0.37)
// Memoize over object arguments; cached results die with their arguments.
const layout = weakMemo((node: object, width: number) => measure(node, width))
// A weak-value cache whose hot set actually survives GC.
const thumbnails = new WeakLRUCache<string, ImageBitmap>(64)The defining guarantee is shell freedom. Every weak target has one
registered shell and an unregister token. Explicit deletion removes that exact
shell; collection removes it through FinalizationRegistry. Once the registry
has drained, no dead target leaves a WeakRef wrapper reachable from the
collection.
ESM and TypeScript declarations are included. Node 18+.
Why this exists
WeakMap and WeakSet deliberately cannot enumerate their contents, cannot
key an entry by more than one object, cannot hold values weakly, and cannot
tell you when something died. Each of those gaps has a well-known workaround —
a Set<WeakRef>, a nested map, a pair of maps, a bare weak-value cache — and
each workaround either leaks bookkeeping, leaks entries, or silently loses
data to the garbage collector. This package makes the cleanup part of each
data structure's invariant and measures the result.
The family
IterableWeakSet / IterableWeakMap
Enumerable weak collections: members and keys are held weakly, iteration
yields only live entries, and dead targets leave no bookkeeping shells behind
(the older iterable-weak-map retains a dead shell per entry until a cleanup
traversal — the retention table below shows 20,000 of them).
IterableWeakMap preserves WeakMap's ephemeron behavior: a value that
references its own key does not keep that key alive.
new IterableWeakSet<T extends object>(values?)
set.add(value) // this — chainable
set.has(value) / set.delete(value)
set.sizeApprox / set.clear()
set[Symbol.iterator]() // live members only
new IterableWeakMap<K extends object, V>(entries?)
map.get(key) / map.set(key, value) / map.has(key) / map.delete(key)
map.getOrInsertComputed(key, compute)
map.entries() / keys() / values() / [Symbol.iterator]()
map.sizeApprox / map.clear()WeakValueMap
Ordinary keys (strings, numbers, symbols, anything), weakly held object values, live iteration — the classic cache shape. Because its keys are strong, it is the one family member with something meaningful to say after a death:
new WeakValueMap<K, V extends object>(entries?, { onReap?: (key: K) => void })
map.get(key) / set(key, value) / has(key) / delete(key)
map.getOrInsertComputed(key, compute)
map[Symbol.iterator]() / map.sizeApprox / map.clear()onReap fires after the registry removes a dead entry — never for explicit
delete, clear, or overwrite — so you can log evictions, refresh caches, or
count deaths without polling.
CompositeWeakMap
The famous "composite keys" gap: a map keyed by an ordered tuple of objects, where the entry dies when any key dies.
const m = new CompositeWeakMap<[object, object], Result>()
m.set([user, document], result)
m.get([user, document])
m.getOrInsertComputed([user, document], compute)
m.has([user, document]) / m.delete([user, document])
m.entries() / keys() / values() / [Symbol.iterator]() // live tuples only
m.sizeApprox / m.clear()Under the hood the value sits at the end of a chain of WeakMap edges — an
ephemeron chain — so the engine reclaims it natively the moment any key dies,
even when the value points back at its own keys. Tuples are order-sensitive
([a, b] ≠ [b, a]), repeated keys are distinct ([a, a] ≠ [a]), and
prefix entries coexist ([a] and [a, b] are independent).
weakMemo
Memoization over full argument tuples, weak in every object argument:
const measure = weakMemo((node: object, width: number) => expensiveLayout(node, width))
measure(node, 640) // computed
measure(node, 640) // cached
// node dies → the cached result is collected, natively, no registry involved
measure.clear()Object arguments descend weak edges, primitives and symbols descend strong
Map edges. Each call needs at least one object argument (an all-primitive
tuple would be immortal and throws instead). This is the memoization shape
where the cache cannot leak what your program has already let go of.
WeakBiMap
A bidirectional map holding both sides weakly — the pair dies when either side dies, and neither side retains the other:
const binding = new WeakBiMap<Model, View>()
binding.set(model, view)
binding.get(model) // view
binding.getKey(view) // model
binding.inverse // live swapped view; inverse.inverse === binding
binding.has(m) / hasValue(v) / delete(m) / deleteValue(v)
binding.sizeApprox / [Symbol.iterator]()set maintains a true bijection (binding a value to a new key unbinds its old
key), and inverse is a shared live view, not a copy.
WeakLRUCache
The cache people actually want when they reach for weak values. Modern V8
collects weakly held values aggressively — a bare weak-value cache can have
near-zero hit rates. WeakLRUCache holds every value weakly plus a strong
LRU window of the most recently touched values, so the hot set survives GC
while the cold tail dies:
const cache = new WeakLRUCache<string, Result>(128) // strong-window capacity
cache.set('query', result)
cache.get('query') // hit — and promoted in the window
cache.has('query') // no promotion
cache.strongCount // exact window size
cache.sizeApprox / delete / clear / [Symbol.iterator]()Falling out of the window is not eviction from the cache — an entry becomes weak-only and still hits while anything else keeps it alive. Capacity 0 gives pure weak-value behavior.
WeakGraph
A directed, optionally labeled graph over weakly held nodes. The graph retains no node; every edge dies with either endpoint; labels ride ephemeron chains so a label referencing its endpoints cannot leak them:
const deps = new WeakGraph<object, string>()
deps.addEdge(consumer, provider, 'imports')
deps.hasEdge(consumer, provider)
deps.getEdge(consumer, provider) // 'imports'
;[...deps.outNeighbors(consumer)]
;[...deps.inNeighbors(provider)]
;[...deps.edges()] // [from, to, label] triples, live only
deps.addNode(n) / hasNode(n) / deleteNode(n) / deleteEdge(a, b)
deps.nodeCountApprox / deps.allNodes() / deps.clear()Drop a subsystem's objects and its whole neighborhood of the graph disappears — dependency tracking, observer wiring, and entity relationships that clean themselves.
IdemWeakRef
new IdemWeakRef(target) returns the same ref object for the same live
target, so refs deduplicate in Sets and Maps. The interning table is weak and
retains nothing.
The instruments
Data structures prevent leaks by construction; these instruments find and contain the leaks the rest of your program still writes. Same invariants: zero dead shells, one registry per core, no timers, no polling.
WeakEmitter
The #1 JavaScript leak class is a listener closure pinning its subscriber
forever. WeakEmitter ties every subscription to a holder object: when
the holder is collected, the subscription — and the listener closure, even one
that captures the holder — becomes collectable with it.
const bus = new WeakEmitter<{ move: { x: number, y: number } }>()
bus.on(component, 'move', (payload, holder) => { ... }) // lives while component lives
bus.once(component, 'move', handler)
bus.emit('move', { x: 1, y: 2 }) // returns listeners invoked
bus.off(component) // or (holder, event) or (holder, event, listener)
bus.listenerCountApprox('move')No removeEventListener bookkeeping, no AbortController plumbing, no
detached-subscriber arrays. Listener errors are isolated per delivery
(rethrown on a microtask), so one bad handler cannot eat the rest of an emit.
RetentionCensus
Production leak attribution: the process grows and nobody can say which scope is responsible. Tag objects into named cohorts at creation; ask later — pull-based, no timers — how many died and how old the survivors are:
const census = new RetentionCensus()
census.track(ctx, 'requestCtx') // at creation, one line
census.snapshot() // { requestCtx: { tracked, finalized, liveApprox } }
;[...census.survivors('requestCtx', 60_000)] // live members older than 60s: { target, ageMs }
census.forget(ctx) // and clear()survivors is the incident tool: "40,000 request contexts older than a
minute" names the leaking scope while the heap snapshot is still downloading.
DisposeSentinel
The generic version of what Node does privately for fs.FileHandle: a
resource collected without having been disposed is a leak — report it with
the stack captured where it was acquired.
const sentinel = new DisposeSentinel({ onLeak: (report) => log.warn(report) })
sentinel.track(connection, 'pg:main') // at acquire (captures the stack)
sentinel.mark(connection) // at proper close — no report
// collected without mark → onLeak({ label, resourceName, trackedAt, stack })Reports fire from the finalizer, so a leak costs nothing until it actually
happens. captureStacks: false drops the per-acquire stack cost for hot
paths. onLeak errors are isolated; pendingApprox counts still-open
resources.
The GC test kit — weak-collections/testkit
Anyone who verifies weak behavior hits the same two V8 deadlock classes this
repository already paid for: WeakRef.deref() pins its target until the end
of the current job, and suspended async test frames pin objects through their
interpreter registers. The kit ships the helpers with the doctrine built in:
import { gcUntil, gcRounds, debugState } from 'weak-collections/testkit'
await gcUntil(() => ref.deref() === undefined) // job boundary before every forced gc
await gcRounds(4) // assert something does NOT get collected
debugState(collection) // { shellCount, finalizationCount }Requires --expose-gc. Allocate in helper functions whose frames pop — the
kit cannot fix a call site that pins from a register (DESIGN.md explains both
classes).
sizeApprox, never size
Every collection exposes sizeApprox: registrations minus explicit removals,
observed deaths, and delivered finalizers. A dead target can be counted
briefly until its callback runs. JavaScript provides no synchronous way to ask
whether an otherwise-unreachable object has been collected, so calling this
size would promise what the platform cannot deliver.
Iteration dereferences once per step and skips dead targets. Mutation follows
the backing Set/Map iterator rules: deleting an unvisited entry skips it;
additions before exhaustion can be visited; deleting and re-adding creates a
tail entry.
Weak targets are objects/functions. Registered symbols are rejected with a
specific TypeError (and this Node 18-compatible API rejects local symbols as
weak targets too). Symbols are perfectly valid WeakValueMap keys and
weakMemo arguments because those positions are held strongly.
Two deliberate strictness divergences from the native collections: has() and
delete() THROW on invalid weak targets (primitives) where native
WeakSet/WeakMap return false — a type error in your program should not read
as a clean miss; and local (unregistered) symbols, though valid weak targets
in ES2023, are excluded as weak targets.
GC evidence
npm run probe drops 20,000 targets, forces GC, waits for both the observer
and collection registries, then records five isolated processes.
heap-estimate walks JavaScript-visible retained graphs;
process.memoryUsage().heapUsed adds a process-level signal that includes
opaque V8 bookkeeping but also GC and allocator noise.
| collection | targets collected | dead shells | visible retained graph | heap delta |
|---|---:|---:|---:|---:|
| IterableWeakSet | 20,000 / 20,000 | 0 | 344 B | 1.54 MiB |
| IterableWeakMap | 20,000 / 20,000 | 0 | 384 B | 2.04 MiB |
| iterable-weak-map | 20,000 / 20,000 | 20000 | 1.08 MiB | 1.76 MiB |
| WeakValueMap | 20,000 / 20,000 | 0 | 256 B | 1.04 MiB |
| weak-value-map | 20,000 / 20,000 | opaque | 24 B | 0.07 MiB |
| CompositeWeakMap | 20,000 / 20,000 | 0 | 384 B | 2.06 MiB |
| WeakBiMap | 20,000 / 20,000 | 0 | 432 B | 2.55 MiB |
| WeakLRUCache(0) | 20,000 / 20,000 | 0 | 456 B | 1.04 MiB |
| WeakGraph | 20,000 / 20,000 | 0 | 1200 B | 5.07 MiB |
| WeakEmitter | 20,000 / 20,000 | 0 | 624 B | 5.89 MiB |
| RetentionCensus | 20,000 / 20,000 | 0 | 592 B | 1.54 MiB |
| DisposeSentinel | 20,000 / 20,000 | 0 | 424 B | 0.79 MiB |
The table shows that every collection in this family eliminates
JavaScript-visible dead shells. Per-entry FinalizationRegistry bookkeeping
leaves a larger V8 heap high-water delta than the native weak-value add-on;
shell freedom is the guarantee, minimal V8 registry capacity is not.
Throughput evidence
npm run bench uses cyclebench to interleave candidates and verify that every
candidate computes the same result. Each timed call performs 500 operations.
| workload | candidates (median per call, fastest first) | |---|---| | set add | native WeakSet 8.62µs · weak-collections 71.3µs · iterable-weak-set 2.42ms | | set has | native WeakSet 1.93µs · weak-collections 5.00µs · iterable-weak-set 2.27ms | | set iteration | native Set 0.503µs · iterable-weak-set 15.1µs · weak-collections 15.7µs | | map set | native WeakMap 8.85µs · weak-collections 80.3µs · iterable-weak-map 2.48ms | | map get | iterable-weak-map 1.99µs · native WeakMap 2.01µs · weak-collections 5.22µs | | map iteration | native Map 1.09µs · weak-collections 19.5µs · iterable-weak-map 24.1µs | | weak-value set | native Map 6.10µs · weak-collections 57.3µs · weak-value-map 102µs | | weak-value get | native Map 1.49µs · weak-collections 8.89µs · weak-value-map 126µs | | weak-value iteration | native Map 1.16µs · weak-collections 17.1µs | | composite set | nested WeakMap 19.8µs · native Map (strong) 27.2µs · weak-collections 129µs | | composite get | nested WeakMap 4.85µs · weak-collections 15.6µs · native Map (strong) 21.3µs | | memo hit | recompute 0.352µs · Map by id 1.84µs · weakMemo 13.8µs | | bimap set | native Map pair 11.8µs · two WeakMaps 16.9µs · WeakBiMap 110µs | | bimap get both ways | native Map pair 3.10µs · two WeakMaps 4.00µs · WeakBiMap 30.6µs | | weak-lru get | native Map 1.45µs · WeakValueMap 9.28µs · WeakLRUCache 49.9µs | | graph addEdge | Map adjacency 28.8µs · WeakGraph 395µs |
Finalization hygiene is not free: adds cost roughly an order of magnitude over
native weak collections, and live iteration costs more than strong-collection
iteration. In exchange, adds avoid the iterable incumbents' linear scans,
caches stop losing their hot sets, and every structure shares one cleanup
discipline. Machine metadata, interquartile bands, call counts, and load are
committed in the repository's evidence directory (it ships with the repo, not
the npm tarball — regenerate with npm run bench).
Verification
The test suite runs in Vitest forks with --expose-gc and covers:
- Set/Map contracts for all eight exports: constructors, stored
undefined, chaining, defensive tuple copies, bijection, inverse views, LRU promotion, graph adjacency repair, and mutation during iteration. - 10,000-entry churn for every collection, with internal shell counts gated on
shells === live membersafter registry drain. - The historical delete-before-read shell leak, pinned for every class.
clear()unregister canaries: no callback may fire after clear.- Ephemeron proofs: key/value cycles, composite tuples whose values reference their own keys, and graph labels referencing both endpoints all collect.
- ANY-death semantics for composite tuples and either-death for bimap pairs.
onReapfiring for collection deaths only.- Deterministic model fuzzing across random add/delete/death/GC sequences.
Two V8 behaviors that silently deadlock naive GC tests — [[KeptAlive]] same-job pinning and suspended-frame interpreter-register pinning — are documented in DESIGN.md and designed around in the test kit.
Install
npm install weak-collectionsMIT © Xyra Sinclair
