FinalizationRegistry Callbacks That Never Run

You registered objects with a FinalizationRegistry so that file handles, WebGL textures or worker ports would be released when the wrapper was garbage collected — and in production the resources pile up because the callbacks run late or not at all. This guide from Reference Counting vs Tracing GC Algorithms, part of JavaScript Memory Fundamentals & Runtime Mechanics, explains the guarantees the specification actually gives, the common reasons callbacks do not fire, and how to structure resource cleanup so finalizers are a safety net rather than the mechanism.

Symptom Root Cause Immediate Action Measurable Impact
Native resources (handles, textures, ports) accumulate Cleanup relies on finalizers that run late or never Add explicit dispose()/close() and call it deterministically Resource count bounded by live objects
Callback never fires for an object you “dropped” Something still references the object, or GC has not run Check retainers; do not expect prompt collection Correct diagnosis of retention vs timing
Callbacks fire only when the process is busy Collection is driven by allocation pressure Stop depending on timing; dispose explicitly Predictable cleanup
Callback fires with undefined held value Held value was the target itself or referenced it Hold a small token (id, handle number), never the target Callback receives useful data
Cleanup lost at process exit or page unload Finalizers are not run at shutdown Close resources on beforeExit/pagehide explicitly No leaked external state

Root Cause: The Specification Promises Almost Nothing

FinalizationRegistry lets you register a target object together with a held value; after the target has been garbage collected, the engine may call the registry’s cleanup callback with the held value. The specification is deliberately loose about that “may”. Implementations are allowed to call the callback long after collection, to batch several calls, to skip calls entirely (for example when the page is navigated away or the process exits), and to collect the target itself at whatever time the collector chooses — possibly never, if memory pressure never triggers a full collection that reaches it.

Four practical consequences follow. First, timing is unbounded: an object dropped now may be collected in seconds, in minutes, or not before the process exits, depending on allocation rate and heap size. A quiet process with plenty of heap may not run a major collection for a long time, so resources that depend on finalizers stay open. Second, reachability must be truly gone: the held value, the unregister token and anything the callback closes over must not reference the target, or the target stays reachable forever — a common self-inflicted leak. Third, callbacks run as separate tasks, after the collection, so they never run synchronously with gc() in tests. Fourth, shutdown skips them: when a page unloads or a Node.js process exits, pending cleanup callbacks are simply dropped.

That is why the language’s weak-reference features are documented as best-effort tools for caches and diagnostics, not for correctness-critical cleanup. The comparison in WeakMap vs WeakRef vs FinalizationRegistry places finalizers at the “last resort” end. The robust pattern is the one systems languages use: give resources an explicit dispose() (or close()) method that the owner calls deterministically — ideally with try/finally or the explicit resource management syntax (using declarations with Symbol.dispose) where available — and keep a FinalizationRegistry only to detect or clean up after owners that forgot.

Why finalizers cannot be scheduled At time zero the last reference to a wrapper is dropped. An unknown delay follows until a garbage collection happens to collect it, which depends on allocation pressure. After collection, the cleanup callback is queued as a separate task and runs after a further delay. If the page unloads or the process exits before either step, the callback never runs and the native resource is never released by the finalizer. last ref dropped t = 0 ? until a GC collects it depends on allocation pressure collected callback queued ? until cleanup runs separate task, may batch unload / exit at any point here → callback is never called the native resource stays open unless something else closes it explicit dispose() closes the resource at t = 0 instead

Step-by-Step Fix

  1. Inventory finalizer-dependent resources. Search for new FinalizationRegistry and list what each cleanup callback releases — file descriptors, GPU objects, native handles, server-side sessions, object URLs. Verification: you know which resources would leak if the callback never ran.
  2. Add an explicit release method. Give each wrapper a dispose() (and [Symbol.dispose] if your runtime supports explicit resource management) that releases the resource and unregisters the wrapper from the registry. Verification: calling dispose() releases the resource immediately.
  3. Call it deterministically. Use try/finally, component teardown hooks, using declarations or pool release paths so every owner disposes. Verification: resource counts in a test that creates and disposes 10,000 wrappers return to zero without any GC.
  4. Keep the registry as a safety net and detector. In the cleanup callback, release the resource and log or count a “leaked without dispose” event. Verification: development builds warn when a wrapper was collected without being disposed.
  5. Hold only tokens, never the target. Register with a held value such as a numeric handle or ID, and use a separate unregister token object that does not reference the target. Verification: in a heap snapshot, the registry does not appear on the target’s retainer path.
  6. Release at shutdown explicitly. Close remaining resources in process.on('beforeExit')/signal handlers or in pagehide. Verification: no resources remain open after orderly shutdown.
Open handles: finalizer-only versus explicit dispose With cleanup relying only on FinalizationRegistry, open native handles climb to about 4,800 over an hour on a lightly loaded service because major collections are rare. With explicit dispose calls and the registry kept as a safety net, open handles stay around 40, matching the number of live wrappers. 5,000 0 finalizer only: small dips only when a major GC happens explicit dispose(): ~40, equal to live wrappers minutes 0 → 60

Command and Code Reference

Use case: explicit disposal with a finalizer safety net. The registry both cleans up forgotten wrappers and reports them so the missing dispose() can be fixed.

// native-handle.js
const leaked = new FinalizationRegistry((handleId) => {
  // Runs late or never — only as a backstop
  nativeClose(handleId);
  console.warn(`handle ${handleId} was garbage collected without dispose()`);
});

export class NativeHandle {
  #id;
  #token = {};                            // unregister token: does NOT reference `this`
  constructor(path) {
    this.#id = nativeOpen(path);          // returns a number
    leaked.register(this, this.#id, this.#token); // held value is the id, not `this`
  }
  read() { return nativeRead(this.#id); }
  dispose() {
    if (this.#id === -1) return;          // idempotent
    leaked.unregister(this.#token);       // no finalizer needed any more
    nativeClose(this.#id);
    this.#id = -1;
  }
  [Symbol.dispose]() { this.dispose(); }  // enables `using` where supported
}

Use case: deterministic release at the call site.

// With try/finally (works everywhere)
const h = new NativeHandle('/data/input.bin');
try {
  process(h.read());
} finally {
  h.dispose();                            // released now, not "eventually"
}

// With explicit resource management (where the runtime supports `using`)
{
  using h2 = new NativeHandle('/data/other.bin');
  process(h2.read());
}                                         // [Symbol.dispose]() runs at block exit

Verification and Regression Prevention

Verify that cleanup no longer depends on the collector: a test that opens and disposes many wrappers should end with zero open resources without calling gc(), and a long-running soak test on a lightly loaded process (where major collections are rare) should show resource counts tracking live wrappers. In development, the finalizer’s warning log is your regression detector — any new code path that forgets dispose() announces itself.

Keep resource counts as metrics — open handles, textures, object URLs — and alert when they grow while live-object counts do not. For browser-side resources, the same pattern applies to object URLs and GPU objects, covered in ArrayBuffer and Blob memory outside the JS heap and WebGL texture and GPU memory leaks.

Open resources in a quiet soak On a lightly loaded process where major collections are rare, cleanup that relies on FinalizationRegistry callbacks lets open resources accumulate because callbacks may never run. With explicit dispose or using declarations as the primary path, open resource counts track live wrappers without calling gc. open handles soak time, lightly loaded process cleanup only in finalizer explicit dispose; finalizer as backstop

Edge Cases and Gotchas

The callback closure can retain the target

If the cleanup callback is defined in a scope that also references the target (for example as a closure inside the constructor that captures this), the registry keeps the target alive and it is never collected. Define the registry and its callback at module level.

Registering the same object twice

Each register call creates a separate registration; if an object is registered twice with different held values, both callbacks may run. Make disposal idempotent and register each resource once.

Unregister tokens must be objects

The unregister token must be an object (or non-registered symbol in newer engines) and should not be the target itself if you want the target to be collectable independently of the token’s lifetime. A dedicated empty object per wrapper is the simplest choice.

Tests need several ticks

In tests with --expose-gc, call gc(), then await a few macrotasks before asserting that a cleanup callback ran; the callback is scheduled after the collection, not during it.

Frequently Asked Questions

Is FinalizationRegistry guaranteed to call my callback?

No. The specification allows implementations to delay callbacks indefinitely or skip them, and they are never run when a page unloads or a process exits. Use it for best-effort cleanup and leak detection, not for releasing resources that must be released.

Why is my object never garbage collected?

Either something still references it — often the registry’s held value, the unregister token or the callback’s closure — or the engine has not run a collection that reaches it yet. Check the retainers in a heap snapshot; if it is retained, fix the reference; if not, it is simply timing.

What should I use instead of finalizers?

Explicit disposal: a dispose() or close() method called deterministically with try/finally, framework teardown hooks or using declarations. Keep a FinalizationRegistry as a safety net that also reports wrappers collected without being disposed.

Can finalizers help find leaks?

Yes, in reverse: logging from a cleanup callback shows that objects are being collected, and counting registrations versus callbacks shows how many are still alive. That makes registries a cheap diagnostic in development builds.