npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 members after 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.
  • onReap firing 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-collections

MIT © Xyra Sinclair