WeakRef & FinalizationRegistry
Learn WeakRef, deref, FinalizationRegistry, weak caches, and why garbage-collection cleanup timing cannot be trusted in JavaScript.
- 01Read weak references safelyUse
new WeakRef(target)andderef()while handlingundefinedevery time. - 02Register finalization hintsConnect
FinalizationRegistry, held values, unregister tokens, and the rule that holdings must not be the target. - 03Design honest weak cachesCombine
Mapkeys,WeakRefvalues, and guarded registry cleanup without depending on callback timing.
Weak reachability, not weak responsibility
JavaScript normally works with strong references: if a variable, object property, closure, array, or map entry can reach an object, the garbage collector must keep that object. A weak reference is different. It lets your code point at a target without making that target reachable for the future.
WeakRef holds an object or non-registered symbol weakly. Its deref() method returns the target if it is still alive, or undefined if it has been collected. FinalizationRegistry can run a callback with a held value after a target is collected, but that callback is never guaranteed to run at a specific time, or at all.
Start by separating this from WeakMap and WeakSet. A WeakMap weakens its keys: the object key can disappear without the map keeping it alive. A WeakRef weakens a target you read through deref(), so it is closer to a weak value. Both ideas depend on the reachability rules from memory management.
A photo lets you look at a friend without making them stay nearby. A WeakRef has the same shape: ask, check the answer, and move on.
- In real life: A photo shows your friend
- In JavaScript: A
WeakRefpoints to a target - In real life: The photo does not keep them nearby
- In JavaScript: The weak reference does not keep the target alive
- In real life: You check whether they are around
- In JavaScript: Call
deref()and handleundefined
Where the analogy stops: A photo stays visible after someone leaves. A weak reference can later return undefined, but JavaScript does not promise when.
| Tool | What is weak or deterministic | Good first use |
|---|---|---|
WeakMap | Weak keys with ordinary values | Attach metadata to an object key without keeping the key alive. |
WeakRef | Weak value-like reference | Hold a target weakly and ask deref() whether it is still available. |
FinalizationRegistry | A possible cleanup notification | Receive a held value after a target is collected, if the engine runs the callback. |
| Explicit cleanup | Deterministic code you call | Use try/finally, .close(), .dispose(), or using for essential resources. |
This lesson also connects back to closures, because closures can accidentally keep values alive, and symbols, because non-registered symbols can be weak targets. We will link forward to resource management for deterministic cleanup patterns such as using.
WeakRef and deref()
STEP THROUGHCreating a weak reference is direct: new WeakRef(target). Reading it is deliberately indirect: call ref.deref(), then branch on the result. If the result is an object, use it immediately. If it is undefined, rebuild the value or treat the cache as a miss.
The first experiment shows a subtle guarantee from the specification. After a target is created as a WeakRef target or returned by deref(), it is kept alive until the end of the current synchronous job. That is sometimes called KeepDuringJob. It does not make the object permanent.
Step through a WeakRef read. The lesson is not forcing garbage collection; it shows what is guaranteed inside one synchronous job.
script
const ref = new WeakRef(target);target = null; const first = ref.deref();const second = ref.deref();console.log(first === second);console.log(first?.label ?? "collected");Read the code line by line: line 1 creates the object, line 2 creates the weak reference, and line 3 removes the ordinary variable. Lines 5 and 6 call deref() in the same turn, so line 7 prints true. In a later task, both calls must still be treated as possibly undefined.
Do not store a deref() result for long and assume it represents cache ownership. A local variable is a strong reference while it exists. Weak references are most honest when code reads, checks, uses, and lets go quickly.
What can be weak?
TYPE RULESWeak targets must be values the garbage collector can track independently. Objects work. Since ES2023, non-registered symbols from Symbol() also work. Registered symbols from Symbol.for() and primitives like numbers, strings, booleans, null, and undefined are not valid targets.
const target = { name: "object target" };const ref = new WeakRef(target);console.log(ref.deref() === target);trueObjects and non-registered symbols are valid WeakRef targets. deref() returns the same target during this turn.
Symbol.for() and primitives fail on purpose.const objectTarget = { name: "object target" };const localSymbol = Symbol("local target"); console.log(new WeakRef(objectTarget).deref() === objectTarget);console.log(new WeakRef(localSymbol).deref() === localSymbol);The next two examples fail on purpose. The lesson marks them as expected errors so the editor can show the TypeError without treating the example as broken.
const target = Symbol.for("shared target");new WeakRef(target);new WeakRef(42);FinalizationRegistry
BEST EFFORTFinalizationRegistry lets you register a target and a separate held value. If the target is collected and the engine later runs finalizers, your callback receives the held value. That wording is careful because collection and callback scheduling are not deterministic.
| API | Meaning |
|---|---|
new FinalizationRegistry(callback) | Creates a registry whose callback receives held values after collection, if it runs. |
register(target, heldValue, token) | Watches an object or non-registered symbol target. heldValue must not be the target. |
unregister(token) | Removes registrations that used that token and returns whether anything was removed. |
| Callback timing | May be soon, late, never, or skipped before page unload. Treat it as a hint. |
const registry = new FinalizationRegistry((heldValue) => { console.log("cleanup requested for", heldValue);}); const token = {};const widget = { id: "preview" };registry.register(widget, "preview card", token);console.log(registry.unregister(token));console.log(registry.unregister(token));Line 7 registers widget with a string held value and a token object. Line 8 removes that registration and prints true. Line 9 prints false because there is nothing left for the same token.
A held value is kept so the callback can receive it later. If the held value were the target object itself, the registry would keep the target alive and defeat the whole purpose, so the API throws a TypeError.
const registry = new FinalizationRegistry(() => {});const target = {};registry.register(target, target);Weak caches: Map<key, WeakRef>
STEP THROUGHA weak cache often uses a normal Map for stable keys such as URLs or file paths, and WeakRef values for large objects such as decoded images or ASTs. A FinalizationRegistry can later remove stale keys, but only after checking the current map entry.
The guard matters because callbacks can be late. A file path might be decoded again before a finalizer for the old object runs. If the callback blindly deletes the key, it can delete fresh work. The helper below calls ref.deref() === undefined before deleting.
A weak cache combines an ordinary Map, WeakRef values, and a registry callback that only deletes a key after checking the current ref.
script
const registry = new FinalizationRegistry((key) => { deleteIfStale(key);}); function deleteIfStale(key) { const ref = records.get(key); if (ref?.deref() === undefined) { records.delete(key); }} function remember(key, value) { records.set(key, new WeakRef(value)); registry.register(value, key, value);} function read(key) { const value = records.get(key)?.deref(); if (value === undefined) { records.delete(key); } return value;} remember("app.js", { type: "OldProgram" });remember("app.js", { type: "NewProgram" });// A late finalizer for the old object would call this helper.deleteIfStale("app.js");console.log(read("app.js").type);In production, remember would run when you decode or parse a value. read would run when the page asks for the value. The finalizer callback is only a best-effort way to trim stale keys; normal cache reads still need to handle misses.
Why cleanup timing is not guaranteed
POSSIBLE OUTCOMESGarbage collection is intentionally invisible. An engine may collect late, never collect before a process exits, or skip finalizer callbacks before a page unloads. The TC39 weak references guidance says to avoid these features where possible. Use them only when every missed callback is harmless.
const registry = new FinalizationRegistry((label) => { console.log("cleanup for", label);}); let node = { label: "preview" };registry.register(node, "preview");node = null; // After this point the callback may run soon, much later, or never.lateThe engine can delay garbage collection until memory pressure or another scheduling point.
Browser tools can help you experiment manually. In Chrome DevTools, the Memory panel has a Collect garbage button. That is useful for learning and debugging, but it is not something page code can rely on. A demo that says “click this and the object will be collected” is teaching the wrong mental model.
finallyconst handle = { close() { console.log("closed"); },}; try { console.log("use handle");} finally { handle.close();}The same rule applies to explicit resource management. Use try/finally, a documented close() or dispose() method, framework teardown, and in newer JavaScript the using family taught in the next lesson. A finalizer is not a substitute for those paths.
Where these tools are practical
SORT ITThe best uses are optional: caches of big decoded images, parsed ASTs, or formatted data that can be recreated; observers that should not own what they observe; and leak-detection tests where losing a notification fails safe. The wrong uses are essential cleanup tasks.
| Situation | Fit | Reason |
|---|---|---|
| Decoded images or parsed ASTs | Good weak-cache fit | Recreate them when deref() returns undefined; never promise they stay cached. |
| Observing objects without ownership | Possible fit | A monitor can remember a weak target without extending its lifetime. |
| Leak detection in tests | Careful fit | A child process with exposed GC can sometimes prove collection, but production code must not depend on it. |
| Sockets, locks, files, listeners | Use explicit cleanup | These resources need deterministic release, not a finalizer that may never run. |
- Cache a decoded hero image that can be recreated from a URL.
- Store parsed ASTs by file path in a developer tool.
- Notice in a test that an object eventually became collectable.
- Record approximate telemetry when preview objects disappear.
- Close a WebSocket when a component unmounts.
- Remove a DOM event listener added by a widget.
Sort each task by the safest tool. If a missed callback would break the app, it belongs in explicit cleanup.
Some test suites prove collection in a child process with --expose-gc and macrotask waits, then repeat the run many times. That can be valid test engineering, but it is not a production pattern and this lesson does not claim deterministic collection in page code.
Common misconceptions
- “
WeakRefmakes collection happen.” It only stops your reference from keeping the target alive. The engine chooses when to collect. - “If
deref()worked once, it will work later.” The result can beundefinedin a later job. Check every time. - “Finalizers are a cleanup hook.” They are best-effort notifications. Essential cleanup belongs in explicit code.
- “The held value can be the target.” It cannot; that would keep the target alive, so the API throws.
- “A registry callback can blindly delete a cache key.” It must check the current
WeakRef, because the key may have been re-added. - “WeakRef is a general performance trick.” It is a niche tool. Prefer simpler strong caches with size limits unless weak ownership is the actual requirement.
| Question | WeakMap | WeakRef | FinalizationRegistry | Explicit cleanup |
|---|---|---|---|---|
| What is weak? | Object keys | The target returned by deref() | The registered target | Nothing; you call it directly |
| Can I read a value later? | Only if I still have the key | Maybe; deref() can be undefined | No direct read; callback gets holdings | Yes; the resource contract decides |
| Can timing be trusted? | No cleanup callback exists | No | No | Yes, if you call it in the right place |
Practice exercises
5 EXERCISESRead the code and type the two printed values.
let target = { label: "preview" };
const ref = new WeakRef(target);
target = null;
const a = ref.deref();
const b = ref.deref();
console.log(a === b);
console.log(a?.label ?? "gone");The current job keeps the target alive after creation and deref, so the snippet prints true and then preview.
The starter prints once and then throws. Which target is rejected?
const local = Symbol("local");
console.log(new WeakRef(local).deref() === local);
new WeakRef(Symbol.for("shared"));new WeakRef(local) is valid, so it prints true. new WeakRef(Symbol.for("shared")) throws a TypeError because registered symbols are not weak targets.
Predict the two booleans printed at the end of the registry snippet.
const registry = new FinalizationRegistry((heldValue) => {
console.log("cleanup requested for", heldValue);
});
const token = {};
const widget = { id: "preview" };
registry.register(widget, "preview card", token);
console.log(registry.unregister(token));
console.log(registry.unregister(token));The first unregister succeeds and prints true; the second finds no registration for that token and prints false.
The broken callback deletes the key blindly. What expression should it check first?
const records = new Map();
records.set("app.js", { deref: () => ({ type: "NewProgram" }) });
function finalizerCallback(key) {
records.delete(key);
}
finalizerCallback("app.js");
console.log(records.has("app.js"));const records = new Map();
records.set("app.js", { deref: () => ({ type: "NewProgram" }) });
function finalizerCallback(key) {
const ref = records.get(key);
if (ref?.deref() === undefined) records.delete(key);
}
finalizerCallback("app.js");
console.log(records.has("app.js"));The fixed callback reads the current ref and deletes only when ref?.deref() === undefined. That preserves a fresh entry for the same key.
A page opens a socket or a file-like handle. Which cleanup style is required?
const handle = {
close() {
console.log("closed");
},
};
try {
console.log("use handle");
} finally {
handle.close();
}Use explicit cleanup: try/finally, a documented close() or dispose(), framework teardown, or the using feature in environments that support it.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which sentence best defines
WeakRef?Choose an answer to see the explanation.
Question 2 of 7What does the current-job
deref()example print?Read the code, then predictlet target = { label: "decoded image" }; const ref = new WeakRef(target); target = null; const first = ref.deref(); const second = ref.deref(); console.log(first === second); console.log(first?.label ?? "collected");Choose an answer to see the explanation.
Question 3 of 7What happens when a registered symbol is used as a WeakRef target?
Read the code, then predictconst target = Symbol.for("shared target"); new WeakRef(target);Choose an answer to see the explanation.
Question 4 of 7What does unregistering the same token twice print?
Read the code, then predictconst registry = new FinalizationRegistry((heldValue) => { console.log("cleanup requested for", heldValue); }); const token = {}; const widget = { id: "preview" }; registry.register(widget, "preview card", token); console.log(registry.unregister(token)); console.log(registry.unregister(token));Choose an answer to see the explanation.
Question 5 of 7Why is this registration invalid?
Read the code, then predictconst registry = new FinalizationRegistry(() => {}); const target = {}; registry.register(target, target);Choose an answer to see the explanation.
Question 6 of 7What does the guarded weak-cache example print?
Read the code, then predictconst records = new Map(); const registry = new FinalizationRegistry((key) => { deleteIfStale(key); }); function deleteIfStale(key) { const ref = records.get(key); if (ref?.deref() === undefined) { records.delete(key); } } function remember(key, value) { records.set(key, new WeakRef(value)); registry.register(value, key, value); } function read(key) { const value = records.get(key)?.deref(); if (value === undefined) { records.delete(key); } return value; } remember("app.js", { type: "OldProgram" }); remember("app.js", { type: "NewProgram" }); // A late finalizer for the old object would call this helper. deleteIfStale("app.js"); console.log(read("app.js").type);Choose an answer to see the explanation.
Question 7 of 7Which task must not rely on a finalizer?
Choose an answer to see the explanation.
Key takeaways
WeakRefdoes not keep a target alive;deref()may returnundefined.- Creation and
deref()keep a target alive only for the current synchronous job. - Objects and non-registered symbols can be weak targets; primitives and registered symbols cannot.
FinalizationRegistrycallbacks are best-effort and must never be essential cleanup.- A weak cache deletes stale keys only after checking the current
WeakRef.
Remember the one-liner.
Weak references are optional observations, not ownership. Use them when losing the value is fine, and clean up real resources explicitly.
Up next: explicit resource management.