ESC

Type to search the knowledge base.

FinalizationRegistry

FinalizationRegistry and WeakRef — non-deterministic cleanup hooks, what never to put in a finalizer, and rare valid use cases.

advanced3 min read
  • javascript
  • finalizationregistry
  • weakref
  • gc

FinalizationRegistry lets you register a callback that may run after an object becomes garbage. It is not a destructor. It is not reliable for freeing critical resources. Interviews bring it up to see whether you understand GC non-determinism — and whether you avoid building business logic on finalizers.

API sketch

const registry = new FinalizationRegistry((heldValue) => {
  // object was GC'd; heldValue is whatever you registered (NOT the object)
  console.log('cleaned', heldValue);
});

function track(obj, id) {
  registry.register(obj, id /*, unregisterToken */);
}

{
  const cacheEntry = { data: new ArrayBuffer(1024) };
  track(cacheEntry, 'entry-1');
} // when cacheEntry becomes unreachable, callback may run later

The held value must not keep the object alive. Usually a string id or a small descriptor.

const token = {};
registry.register(obj, 'res-1', token);
// later if you free manually:
registry.unregister(token);

Pair with WeakRef

const ref = new WeakRef(element);
// later
const el = ref.deref();
if (el) {
  // still alive
} else {
  // collected
}

WeakRef reads can resurrect timing subtleties; engines have restrictions about when deref is allowed in the same turn as creation. Read the spec notes before shipping clever caches.

What finalizers are for

Rare, valid-ish:

  • Diagnostic counters (“how many widgets leaked?”)
  • Secondary caches that can drop offline resources
  • WASM/native handle tables only as a last-resort safety net after explicit free

Never for:

  • Closing sockets that must close
  • Unlocking mutexes / releasing file locks
  • User-visible side effects
  • Anything that must run for correctness
// BAD idea
const registry = new FinalizationRegistry((socket) => {
  socket.close(); // may never run; order undefined
});

Always expose an explicit dispose() / Symbol.dispose and use await using / try/finally when the environment supports it.

Why non-deterministic

GC runs when the engine decides. An object can remain reachable longer than you think (closures, detached DOM, maps). Finalizers may run much later, in batch, or — theoretically in some embeddings — not at all before process exit.

// forcing GC is non-standard (e.g. --expose-gc in Node tests)
// never rely on it in app code

Interview answer

“FinalizationRegistry schedules a callback after an object is garbage-collected, receiving a held value you provided—not the object. It’s non-deterministic and unsuitable for required resource cleanup. Prefer explicit dispose. WeakRef gives a weak pointer via deref(); both are advanced tools for caches and diagnostics, not everyday app logic.”

Interaction with WeakMap caches

const cache = new WeakMap();

function getMaterialized(source) {
  let view = cache.get(source);
  if (!view) {
    view = expensiveView(source);
    cache.set(source, view);
  }
  return view;
}

WeakMap often removes the need for FinalizationRegistry: when source is collected, the cache entry disappears without a callback. Prefer that model. Use FinalizationRegistry only when you must actively release an external resource id that the GC cannot see (e.g., a handle table in WASM) — and still expose explicit free() as the primary path.

Scheduling and observability

Finalizer callbacks run on the engine’s schedule, often during GC, with limited ability to allocate or take locks (host-defined). Keep them tiny: push an id onto a queue and process later on a macrotask.

const pending = [];
const registry = new FinalizationRegistry((id) => {
  pending.push(id);
  queueMicrotask(flush); // or setTimeout(flush, 0)
});

Never depend on ordering between finalizers. Treat them as best-effort telemetry.

Further reading

Related guides