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.

Three References a Shared Service HoldsThree stacked layers. The service's subscriber list holds components; with WeakRef it no longer keeps them alive. The observer's target list holds elements strongly; that cannot be made weak, so detached elements must be swept with unobserve. The WeakMap from element to handler holds handlers keyed by element and adds no retention of its own.Subscriber listWeakRef to components: forgotten subscribers can be collected.Observer targetsStrong, by design: sweep detached elements with unobserve.Element → handlerWeakMap: no retention of its own.

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

TypeScript
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

TypeScript
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:

TypeScript
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.

Delivery With Weak SubscribersFour boxes. The ResizeObserver reports a new layout width. The service iterates its WeakRef subscriber set. References that deref to undefined are pruned. Live subscribers that are marked disposed are skipped, and the rest receive the new width.RO: new widthmain layout resizedIterate WeakRefsderef() eachundefined → prunecollected subscriberLive → onLayoutskip if disposed

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 WeakMap keyed by element, which is simpler and has no deref dance.
  • 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.

Should This Reference Be Weak?A decision chain. If the holder lives no longer than the referenced object, use a normal reference. Otherwise, if the reference is per-element bookkeeping, use a WeakMap keyed by the element. Otherwise, if the holder is a long-lived service holding short-lived subscribers, use WeakRef as a safety net alongside explicit unsubscribe. Observed elements themselves always need explicit unobserve or a detached sweep.Holder lives no longer than the object?Normal referenceyesnoPer-element bookkeeping?WeakMap keyed by the elementyesnoLong-lived service, short-lived subscribers?WeakRef as a safety net + explicit unsubscribeyesnoObserved elements: explicit unobserve or a detached sweep — weak references cannot help.

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 onLayout calls.
  • 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.


↑ Back to WeakMap Observer Registries