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.
Step-by-Step Fix
- Inventory finalizer-dependent resources. Search for
new FinalizationRegistryand 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. - 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: callingdispose()releases the resource immediately. - Call it deterministically. Use
try/finally, component teardown hooks,usingdeclarations 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. - 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.
- 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.
- Release at shutdown explicitly. Close remaining resources in
process.on('beforeExit')/signal handlers or inpagehide. Verification: no resources remain open after orderly shutdown.
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.
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.
Related
- Reference Counting vs Tracing GC Algorithms — the parent topic
- WeakRef deref() and Object Lifetime Guarantees — the companion weak-reference API
- Forcing Garbage Collection with --expose-gc — testing finalizers deterministically
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview