Use FinalizationRegistry as a development audit, not as cleanup: register each observed element (or component) with a token when it subscribes, mark the token when the component properly unsubscribes, and have the finalizer warn if an object was collected without its cleanup having run — plus periodically list tokens whose objects were never collected at all.

Problem / Scenario Context

A large React application uses a shared observer pool with an explicit unsubscribe per element, following WeakMap vs Map for observer target tracking. Heap snapshots occasionally show detached elements retained by the pool, which means some component somewhere forgets to unsubscribe — but with hundreds of components, finding which one requires hours of manual snapshot diffing. The team wants the application to tell them, in development, which component leaked.

Garbage-collection notifications are exactly the missing signal. The WeakMap Observer Registries topic covers weak references for bookkeeping; this page uses FinalizationRegistry for auditing.

Mechanics Explanation

FinalizationRegistry lets you register an object with a held value: registry.register(target, heldValue, unregisterToken). At some point after target is garbage collected, the registry's callback may be called with heldValue. Three properties dictate how to use it:

  • Callbacks are not guaranteed. The engine may never collect an object (it is still reachable, or GC simply has not run), and even after collection the callback may be delayed or, at page teardown, skipped. It must never be used for required cleanup.
  • The held value must not reference the target, or the target can never be collected.
  • Observed elements are strongly held by the observer. An element that is still observed will not be collected at all — so a leaked observation shows up as no finalization, not as a finalization.

That last point shapes the audit. There are two failure signals:

  1. Collected without cleanup: the finalizer fires for a component whose unsubscribe never ran. The observation must have been released some other way (the observer itself was collected), but the component's code path is still wrong.
  2. Never collected: after the component unmounted, its element should become unreachable; if the finalizer never fires after a forced GC, something — often the observer — still holds it.

The Audit Lifecycle for One RegistrationFive steps. On subscribe, register the element with a held value describing the component and a cleaned flag. On proper unsubscribe, set cleaned to true and record the unmount time. When the element is collected, the finalizer checks the flag and warns if cleanup never ran. Periodically, entries unmounted long ago but never finalized are reported as likely leaks. In production, the whole audit is compiled out.1Subscriberegister(element, { component, cleaned: false })2Unsubscribecleaned = true, unmountedAt = now3Finalizer firescleaned false ⇒ warn: collected without cleanup4Never finalizedunmounted long ago, still alive ⇒ likely leak5ProductionAudit compiled out entirely

Comparison Table: Leak-Detection Techniques

Technique Finds Effort Runs automatically
Manual heap snapshot diff retained objects + retainer path high no
CI instance count (DevTools protocol) count regressions medium yes, in CI
FinalizationRegistry audit which component skipped cleanup / never freed low once built yes, in dev
Mock observer in unit tests unbalanced observe/unobserve low yes, in tests
Code review obvious omissions varies no

Minimal Reproducible Example

TSX
function Card({ id }: { id: string }) {
  const ref = React.useRef<HTMLDivElement>(null);
  React.useEffect(() => {
    pool.observe(ref.current!, onVisible);      // no cleanup returned: leaks on unmount
  }, []);
  return <div ref={ref}>{id}</div>;
}

declare const pool: { observe(el: Element, cb: () => void): () => void };
declare function onVisible(): void;

Every unmounted Card stays observed, and its element is retained by the pool's observer — silently.

Production-Safe Solution

TypeScript
// leak-audit.ts — development only
interface Held { component: string; cleaned: boolean; unmountedAt?: number; stack?: string }

const DEV = import.meta.env?.DEV ?? false;
const live = new Set<Held>();

const registry = DEV ? new FinalizationRegistry<Held>((held) => {
  live.delete(held);
  if (!held.cleaned) {
    console.warn(`[observer-audit] ${held.component}: element collected without unsubscribe()`, held.stack);
  }
}) : null;

export function auditSubscribe(el: Element, component: string): (() => void) {
  if (!registry) return () => {};
  const held: Held = { component, cleaned: false, stack: new Error().stack };   // held value: no ref to el
  live.add(held);
  registry.register(el, held);
  return () => { held.cleaned = true; held.unmountedAt = performance.now(); };
}

/** Call from the console or a dev overlay after navigating around and forcing GC. */
export function reportSuspectedLeaks(olderThanMs = 10_000): void {
  const now = performance.now();
  const suspects = [...live].filter((h) => h.cleaned && h.unmountedAt && now - h.unmountedAt > olderThanMs);
  const neverCleaned = [...live].filter((h) => !h.cleaned);
  console.table(suspects.map((h) => ({ component: h.component, sinceUnmountMs: Math.round(now - h.unmountedAt!) })));
  console.info(`[observer-audit] ${neverCleaned.length} registrations still subscribed`);
}
TypeScript
// In the shared pool, wrap subscription so every caller is audited automatically.
export function observe(el: Element, cb: (e: IntersectionObserverEntry) => void, component = 'unknown'): () => void {
  const release = poolObserve(el, cb);
  const markCleaned = auditSubscribe(el, component);
  return () => { release(); markCleaned(); };
}

declare function poolObserve(el: Element, cb: (e: IntersectionObserverEntry) => void): () => void;

With the audit wired into the pool, a component that forgets to unsubscribe shows up in reportSuspectedLeaks() as "still subscribed" long after navigating away, with the component name and the stack captured at subscription. A component that does unsubscribe but whose element is still retained by something else shows up as "cleaned, but not collected after N seconds".

In Chromium, force a collection before reporting with the DevTools Memory panel's collect-garbage button (or --js-flags=--expose-gc and gc() in automated runs), since finalizers only fire after the engine actually collects.

Interpreting the Audit ReportA decision chain for each audited registration. If the finalizer fired and cleanup had run, everything is correct. If the finalizer fired without cleanup, the component skipped unsubscribe but something else released it; fix the component. If cleanup ran but the element is still alive long after unmount, something else retains it; take a heap snapshot and follow the retainers. If cleanup never ran and the element is alive, the observation itself is leaking; fix the component's teardown.Finalized, and cleaned?Correct — nothing to doyesnoFinalized, not cleaned?Component skipped unsubscribe; fix its teardownyesnoCleaned, but alive long after unmount?Other retainer: heap snapshot, follow retainersyesnoNot cleaned and alive: the observation is leaking the element.

Why Not Use It for Cleanup Itself?

It is tempting to let the registry perform the cleanup: when an element is collected, unobserve it. That cannot work for observers, because the observer holds the element strongly while it is observed, so it is never collected, so the finalizer never fires. Even for bookkeeping that does not keep the element alive, finalization timing is unspecified — seconds, minutes or never — which makes it unsuitable for anything users can notice.

The robust pattern remains: explicit unobserve in teardown, WeakMaps for per-element bookkeeping so forgotten entries do not add their own retention, and FinalizationRegistry in development to catch the places where teardown was forgotten. The weak-reference counterpart, WeakRef callbacks for detached observer targets, covers the few runtime uses where weak references do belong.

Audit Tool Versus Cleanup MechanismTwo columns. As an audit tool, FinalizationRegistry reports components that skipped cleanup and elements that were never freed, runs only in development, and tolerates unpredictable timing. As a cleanup mechanism it fails: observed elements are never collected so the finalizer never fires, timing is unspecified, and callbacks may be skipped at page teardown.As a development auditNames components that skipped cleanupSurfaces elements that were never freedTiming does not matter for a reportAs cleanupObserved elements are never collectedSo the finalizer never runsTiming unspecified; may never fire

Verification Steps

  • Plant a known leak (a component without cleanup) and confirm it appears in the report.
  • Navigate around, force GC, and run reportSuspectedLeaks(); the list should be empty in a healthy build.
  • Confirm the audit is absent from production bundles (search the output for the warning string).
  • Check that held values never reference elements, or the audit itself would leak.
  • Add the report to an end-to-end test that navigates, forces GC and asserts an empty list.

Common Mistakes to Avoid

  • Using finalizers for real cleanup. They may never run, and observed elements are never collected.
  • Holding the target in the held value. It prevents collection entirely.
  • Expecting immediate callbacks. Force GC in development and allow time.
  • Shipping the audit to production. Stack captures and registries cost memory and CPU.

FAQ

When does a FinalizationRegistry callback run?

Some time after the registered object has been garbage collected, if at all. The engine decides when to collect and when to run cleanup callbacks, and it is allowed to skip them entirely, for example when the page is closing.

Why does a leaked observation not trigger the finalizer?

Because the observer holds the observed element strongly. The element stays reachable, so it is never collected, so the finalizer has nothing to report. The audit detects this as "unmounted but never finalized".

Can I force garbage collection from code?

Not in normal pages. In development you can use the DevTools collect-garbage button, or launch Chromium with the expose-gc flag for automated tests, which provides a global gc function.

What is the unregister token for?

Passing a token to register lets you later call unregister(token) to cancel the registration, for example when an element is reused for a different component and should be audited under a new name.

Is the stack capture expensive?

Creating an Error to capture a stack is relatively expensive per subscription, which is fine in development and a reason to keep the whole audit out of production builds.

Does this work in Safari and Firefox?

FinalizationRegistry is supported in all current engines. Collection timing differs between engines, so run leak reports in the browser you profile with, usually Chromium.


↑ Back to WeakMap Observer Registries