When a long-lived service (a shared observer, a viewport store) calls back into short-lived components, hold those subscribers through WeakRef and deref() them on each delivery, pruning the ones that return undefined — and separately sweep observed elements that are no longer isConnected, because the observer holds its targets strongly and weak references cannot release those.
Problem / Scenario Context
A dashboard has a global "viewport service" that combines a ResizeObserver on the main layout with visualViewport events and notifies subscribed components of layout changes. Components subscribe with an object that has an onLayout method. Some components unsubscribe in their teardown; some, written by other teams, do not. Every forgotten subscription keeps a component instance — and through it, its DOM subtree, its data and its chart library — alive for the lifetime of the page. After an afternoon of navigating, memory is several hundred megabytes higher than at load.
The service is holding the components strongly through its subscriber list. The WeakMap Observer Registries topic covers keying bookkeeping by element; this page covers the other direction — the service's references to its subscribers.
Mechanics Explanation
A WeakRef holds a reference that does not keep its target alive. ref.deref() returns the target if it is still alive, or undefined once it has been collected. Three rules follow from the specification:
- Within one synchronous job, a target observed via
deref()stays alive. You can safely call methods on the dereferenced object in the same task. - Collection timing is unspecified. A subscriber whose component unmounted may still
deref()successfully for a while; the service must tolerate calling into a component that is logically dead (the component should ignore calls after teardown). - Do not rely on it for correctness. Weak references reduce the damage of forgotten cleanup (the leak ends when the garbage collector runs); they do not replace cleanup.
Weak references do not help with observer targets. A ResizeObserver or IntersectionObserver holds each observed element strongly; a component that is gone but whose element is still observed leaks the element and its subtree regardless of how the service holds the component. The fix for that is a periodic or event-driven sweep: unobserve elements that are no longer isConnected.
Comparison Table: Holding Subscribers
| Storage | Forgotten unsubscribe | Overhead | Behaviour after unmount |
|---|---|---|---|
Set<Subscriber> |
leaks until page unload | lowest | called forever |
Set<WeakRef<Subscriber>> |
leak ends at next GC | small | may be called until GC |
Set<WeakRef> + FinalizationRegistry prune |
same, list pruned | small | may be called until GC |
| Explicit unsubscribe (+ weak as safety net) | none | small | never called |
Minimal Reproducible Example
interface LayoutSubscriber { onLayout(width: number): void }
const subscribers = new Set<LayoutSubscriber>();
new ResizeObserver(([e]) => {
for (const s of subscribers) s.onLayout(e.contentBoxSize[0].inlineSize); // keeps every subscriber alive
}).observe(document.querySelector('main')!);
export const subscribe = (s: LayoutSubscriber) => { subscribers.add(s); return () => subscribers.delete(s); };
A component that calls subscribe(this) and never calls the returned function stays in memory forever.
Production-Safe Solution
interface LayoutSubscriber { onLayout(width: number): void; disposed?: boolean }
class LayoutService {
#subs = new Set<WeakRef<LayoutSubscriber>>();
#pruneRegistry = new FinalizationRegistry<WeakRef<LayoutSubscriber>>((ref) => this.#subs.delete(ref));
#width = 0;
constructor(target: Element) {
new ResizeObserver(([e]) => {
this.#width = e.contentBoxSize[0].inlineSize;
for (const ref of this.#subs) {
const s = ref.deref();
if (!s) { this.#subs.delete(ref); continue; } // collected: prune
if (s.disposed) continue; // unmounted, not yet collected
s.onLayout(this.#width);
}
}).observe(target);
}
subscribe(s: LayoutSubscriber): () => void {
const ref = new WeakRef(s);
this.#subs.add(ref);
this.#pruneRegistry.register(s, ref, ref);
s.onLayout(this.#width);
return () => { this.#subs.delete(ref); this.#pruneRegistry.unregister(ref); };
}
}
export const layout = new LayoutService(document.querySelector('main')!);
Components that unsubscribe are removed immediately. Components that forget are no longer kept alive by the service; once collected, their WeakRef is pruned either by the registry callback or on the next delivery. The disposed flag lets a component that has torn down but not yet been collected ignore late calls.
For shared observers with element targets, add a sweep for detached elements, triggered cheaply — on route changes, or at idle time — rather than on every callback:
export function sweepDetached(ro: ResizeObserver, handlers: Map<Element, unknown>): number {
let removed = 0;
for (const el of handlers.keys()) {
if (!el.isConnected) { ro.unobserve(el); handlers.delete(el); removed++; }
}
return removed;
}
Keeping a strong Map of observed elements is acceptable here because the sweep bounds it; the map exists precisely to know what to unobserve. Log the sweep count in development — a non-zero count means some component forgot to unsubscribe, which the FinalizationRegistry audit can then name.
When Weak References Are the Wrong Tool
Weak references add unpredictability, and most observer code does not need them:
- Component-owned observers — created and disconnected by one component — have no long-lived holder; there is nothing to weaken.
- Per-element bookkeeping is better as a
WeakMapkeyed by element, which is simpler and has noderefdance. - Anything user-visible must not depend on collection timing. If a stale subscriber could produce a visible glitch, it must be removed explicitly.
Weak references are for one situation: a long-lived holder of references to short-lived objects whose owners might forget to unregister. Shared services, global event buses and caches fit; most component code does not.
Verification Steps
- Plant a component that never unsubscribes, navigate away, force GC and confirm it is collected (heap snapshot or FinalizationRegistry audit).
- Confirm unsubscribed components are never called again.
- Run the detached sweep after route changes and log the count in development.
- Test that disposed-but-uncollected subscribers ignore late
onLayoutcalls. - Compare memory after an hour of navigation before and after the change.
Common Mistakes to Avoid
- Expecting WeakRef to release observed elements. The observer holds them strongly.
- Relying on collection timing. Always unsubscribe explicitly; weak references are a safety net.
- Keeping a strong reference to the target alongside the WeakRef. It defeats the purpose.
- Weakening references that do not need it. Adds complexity and unpredictability for no gain.
FAQ
Is it safe to call a method on a dereferenced WeakRef?
Yes, within the same synchronous job. Once deref returns an object, the engine keeps it alive until the current job ends, so calling methods on it is safe.
Why check a disposed flag if the subscriber is weakly held?
Because collection can happen much later than unmount. Until then, deref still returns the component, and without the flag it would receive calls after teardown.
Can I make an IntersectionObserver hold its targets weakly?
No. Observers hold their targets strongly while observing them, by specification. You must unobserve targets yourself, or sweep detached ones.
Is the FinalizationRegistry needed if I prune on delivery?
Not strictly. Pruning on delivery is enough when deliveries are frequent. The registry keeps the set small between deliveries, which matters for services that deliver rarely.
What does isConnected tell me?
Whether the node is currently attached to a document. A detached element that is still observed is almost always a forgotten unobserve.
Are WeakRefs supported everywhere?
Yes, in all current browsers and in Node. They were added in ES2021.
Related
- WeakMap vs Map for Observer Target Tracking — the element-keyed side
- Using FinalizationRegistry to Audit Observer Cleanup — naming the components that forget
- Disconnecting Pooled Observers on Route Change — where to run the sweep
↑ Back to WeakMap Observer Registries