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.
Step-by-Step Fix
- 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 callsderef()again after anawait. - Handle
undefinedas a normal outcome. Everyderef()caller must have a path forundefined— recompute, refetch or skip. Verification: tests cover the miss path by simulating a collected target. - Remove dead cache entries. Pair the
WeakRefmap with aFinalizationRegistrythat deletes the key after collection, and also delete the key when aderef()returnsundefined. Verification: after a long run with churn, the map’s size stays close to the number of live cached objects. - 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. - Fix collectability tests. In tests, await a macrotask, then
gc(), then checkderef(). Verification: the test passes consistently on correct code and fails on a deliberately leaky version. - 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.
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.
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.
Related
- Reference Counting vs Tracing GC Algorithms — the parent topic
- Using WeakSet to Tag Objects Without Leaks — the simpler weak structure for membership
- TTL vs LRU Eviction: Memory Trade-offs — predictable alternatives to weak caches
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview