WeakRef deref() and Object Lifetime Guarantees

Your WeakRef-based cache sometimes returns an object and sometimes undefined for the same key, your test that expects collection fails even though nothing references the object, and a colleague asks whether deref() can hand back a half-collected object. This guide from Reference Counting vs Tracing GC Algorithms, part of JavaScript Memory Fundamentals & Runtime Mechanics, sets out exactly what the language guarantees about a WeakRef target’s lifetime and how to write caches and tests that rely only on those guarantees.

Symptom Root Cause Immediate Action Measurable Impact
Test calls gc() but deref() still returns the object Target kept alive until the end of the current job Await a macrotask before collecting and checking Deterministic collectability tests
Cache hit, then miss, for the same key moments apart Target collected between jobs, as allowed Treat a miss as normal and recompute Correct cache behaviour
Code calls deref() twice and gets different results Crossed a job boundary (e.g. an await) between calls Call deref() once, keep a strong local for the operation Consistent object within an operation
WeakRef cache map grows with dead entries Map keys and WeakRef wrappers are never removed Remove entries via FinalizationRegistry or on miss Map size tracks live objects
Memory still grows with WeakRef cache Something else holds strong references Find the strong retainer in a snapshot Cache objects actually collectable

Root Cause: Weak, but Stable Within a Job

A WeakRef holds a target object without keeping it alive. ref.deref() returns the target if it has not been collected, or undefined if it has. The specification adds one crucial rule to make this usable: creating a WeakRef or calling deref() adds the target to a “kept alive” list for the current job. A job is the synchronous piece of code currently running — a script, an event handler, a timer callback or a promise reaction — and the list is cleared once it completes. Until then, the target cannot be collected. This is sometimes called the KeepDuringJob rule.

From that rule follow the practical guarantees. Within one synchronous run, repeated deref() calls return the same object, so you can check and use it without a race. Across an await, a timer or any other job boundary, the target may be collected, so a later deref() may return undefined. Once collected, always collected: after deref() returns undefined, it will never return an object again. And no partial objects: deref() returns either the complete, usable object or undefined; there is no intermediate state.

The rule surprises people in two places. In tests, calling global.gc() in the same synchronous block in which the WeakRef was created or dereferenced cannot collect the target, so the test fails on correct code — the fix is to await a macrotask first, as shown in writing memory leak tests with Vitest and --expose-gc. In caches, the map that holds WeakRefs is itself a strong structure: its keys and the WeakRef wrapper objects stay forever unless you remove them, so a naive WeakRef cache still leaks, just more slowly. Pairing it with a FinalizationRegistry to delete entries after collection — accepting that finalizers run late or never — plus deleting entries on observed misses, keeps it bounded.

The KeepDuringJob rule Job one creates a WeakRef and calls deref, which returns the object; the target is kept alive until job one ends. Between jobs, a garbage collection may run. In job two, deref may return the object again if it survived, which keeps it alive for job two, or undefined if it was collected. Once deref returns undefined, it returns undefined forever after. Job 1 new WeakRef(obj); ref.deref() → obj obj kept alive until job ends Between jobs kept-alive list cleared a GC may collect obj Job 2 deref() → obj (kept for job 2) or → undefined (collected) undefined is permanent no later deref() revives it within a job: stable, no races across jobs: may disappear at any time

Step-by-Step Fix

  1. Call deref() once per operation. At the start of a synchronous operation, call deref() once and store the result in a local variable; use that local for the rest of the operation. Verification: no code path calls deref() again after an await.
  2. Handle undefined as a normal outcome. Every deref() caller must have a path for undefined — recompute, refetch or skip. Verification: tests cover the miss path by simulating a collected target.
  3. Remove dead cache entries. Pair the WeakRef map with a FinalizationRegistry that deletes the key after collection, and also delete the key when a deref() returns undefined. Verification: after a long run with churn, the map’s size stays close to the number of live cached objects.
  4. Guard against replacing a live entry. In the finalizer, delete the key only if the map still holds the same WeakRef, because the key may have been re-cached with a new object. Verification: a test that re-caches a key before the old target’s finalizer runs keeps the new entry.
  5. Fix collectability tests. In tests, await a macrotask, then gc(), then check deref(). Verification: the test passes consistently on correct code and fails on a deliberately leaky version.
  6. Confirm nothing else retains cached objects. Take a heap snapshot after dropping all users of a cached object and check its retainers. Verification: the only remaining edge is the weak one from the WeakRef.
WeakRef cache map size with and without cleanup A WeakRef cache that never removes entries grows its map to about 120,000 keys over an hour of churn, even though only about 800 objects are alive. With finalizer-driven deletion and deletion on misses, the map stays near 900 entries. 120k 0 keys + WeakRef wrappers never removed finalizer + on-miss deletion (~900 keys) minutes of churn 0 → 60

Command and Code Reference

Use case: a WeakRef cache that cleans up after itself. Values can be collected under memory pressure; keys and wrappers are removed when that happens.

// weak-cache.js — cache expensive objects without pinning them in memory
export class WeakValueCache {
  #map = new Map();                                   // key → WeakRef
  #registry = new FinalizationRegistry((key) => {
    const ref = this.#map.get(key);
    // Only delete if the entry still points at a dead target (not a re-cached value)
    if (ref && ref.deref() === undefined) this.#map.delete(key);
  });

  get(key) {
    const ref = this.#map.get(key);
    if (!ref) return undefined;
    const value = ref.deref();                        // stable for the rest of this job
    if (value === undefined) this.#map.delete(key);   // clean up on observed miss
    return value;
  }

  set(key, value) {
    this.#map.set(key, new WeakRef(value));
    this.#registry.register(value, key);              // held value: the key, not the object
  }

  get size() { return this.#map.size; }               // diagnostic only
}

Use case: use a dereferenced value safely across an await. Keep a strong local for the operation instead of calling deref() again later.

async function renderThumbnail(cache, id) {
  let bitmap = cache.get(id);                // one deref; strong local from here on
  if (!bitmap) {
    bitmap = await decodeThumbnail(id);      // recompute on miss
    cache.set(id, bitmap);
  }
  await nextFrame();                         // job boundary: the local keeps bitmap alive
  draw(bitmap);                              // safe: we never re-deref after the await
}

Verification and Regression Prevention

Check three behaviours. Correctness: code that dereferences behaves identically whether the target is present or collected (test both by constructing the miss case directly). Boundedness: after a long soak with churn, the cache map’s size tracks live objects rather than every key ever inserted. Collectability: a WeakRef-based test shows cached objects are collected once their users drop them, which proves nothing else retains them.

Use WeakRef caches only where recomputation is acceptable and occasional misses are harmless; for predictable behaviour and memory bounds, a size-bounded LRU from building a bounded LRU cache in JavaScript is usually a better default. Document in code which caches are weak and why, so future readers do not “fix” a legitimate miss as a bug.

Three behaviours to test for WeakRef code Test correctness by constructing the miss case directly so dereferencing code behaves the same whether the target is present or collected. Test boundedness with a soak where the cache map tracks live objects, with cleanup of dead entries. Test collectability with a forced-GC test. Test all three Correctness Code behaves identically on hit and on miss; test the miss directly. Boundedness Map size tracks live objects after a churn soak. Collectability Targets are collected once only WeakRefs remain.

Edge Cases and Gotchas

Microtasks and the job boundary

Engines clear the kept-alive list when the current synchronous job finishes; exactly how that interacts with microtasks queued in the same turn is an implementation detail you should not rely on. Treat every await as a point where the target may disappear.

Weak references to primitives are impossible

WeakRef targets must be objects (or non-registered symbols in newer engines). Caching strings weakly is not possible; cache the object that owns the string, or use a bounded strong cache.

Engines may collect later than you expect

The absence of strong references permits collection; it does not trigger it. In quiet processes, weakly held objects may live for a long time, so a WeakRef cache does not reduce memory until the collector decides to run — it only makes collection possible.

Debug tools can hold strong references

Inspecting a cached object in the DevTools Console, or storing it as a global for debugging, creates a strong reference that keeps it alive. Clear the Console before testing collection behaviour.

Frequently Asked Questions

Can deref() return an object after it has returned undefined?

No. Once a target has been collected and deref() returns undefined, every later call also returns undefined. Collection is permanent.

Why does my WeakRef target survive gc() in a test?

Because creating the WeakRef or calling deref() keeps the target alive until the current synchronous job ends. Await a macrotask (for example a zero-delay timeout) before calling gc() and checking deref().

Is a WeakRef cache enough to prevent memory leaks?

Only partly. The cached objects become collectable, but the map’s keys and WeakRef wrappers stay unless you remove them. Delete entries when deref() returns undefined and via a FinalizationRegistry, and make sure nothing else holds strong references to the cached objects.

When should I use WeakRef at all?

For caches of large, recomputable objects where memory pressure should be allowed to win, and for diagnostics. For ordinary caching with predictable memory, a size-bounded strong cache is simpler and more predictable. For tagging or associating data with objects, WeakMap and WeakSet are better fits.