WeakRef & FinalizationRegistry
Learn WeakRef, deref, FinalizationRegistry, weak caches, and why garbage-collection cleanup timing cannot be trusted in JavaScript.
- 01Read weak references safelyMake a
new WeakRef(target), read it withderef(), and check forundefinedevery time. - 02Ask for a note after an object is removedUse
FinalizationRegistrywith a held value and a token, and know why the held value must not be the target. - 03Build a safe weak cacheCombine
Mapkeys,WeakRefvalues, and a cleanup that checks first, without counting on when callbacks run.
Pointing at an object without keeping it
JavaScript frees memory for you. A part of the engine called the garbage collector finds objects your code can no longer get to, and removes them. An object is reachable while your code can still get to it, for example through a variable, a property, an array, a map, or a closure.
Most links to an object are strong references. A strong reference keeps the object alive: the garbage collector must not remove it. A weak reference is different. It points at an object, but it does not keep the object alive. The thing a weak reference points at is called its target.
user keeps the object alive. The WeakRef called ref points at the same object, but it does not keep it alive.A WeakRef is a weak reference to an object, or to a symbol made with Symbol(). Its deref() method gives you the target if it still exists. It gives undefined if the garbage collector has removed it.
A FinalizationRegistry can run a function, called a callback, after a target is removed. But nothing promises when that callback runs. It may run late, or never.
Don't mix these up with WeakMap and WeakSet:
- A
WeakMapholds its keys weakly. An object used as a key can be removed, and the map doesn't stop that. - A
WeakRefholds one target weakly. You read the target withderef().
Both follow the reachability rules from the memory management lesson.
A photo lets you see your friend. It does not make your friend stay in the room. To know if your friend is still here, you have to look around and check.
- In real life: A photo shows your friend
- In JavaScript: A
WeakRefpoints at a target - In real life: The photo doesn't make your friend stay
- In JavaScript: The weak reference doesn't keep the target alive
- In real life: You look around to see if your friend is still here
- In JavaScript: You call
deref()and check forundefined
The table below compares four tools that are easy to mix up. Read each row as: "this tool holds this weakly, and here is a good first use for it."
| Tool | What does it hold weakly? | A good first use |
|---|---|---|
WeakMap | Its keys. The values are normal. | Attach extra data to an object without keeping the object alive. |
WeakRef | One target, which you read with deref(). | Point at a big object, and ask deref() later if it still exists. |
FinalizationRegistry | The targets you register with it. | Get a note (the held value) after a target is removed, if the engine sends it. |
| Explicit cleanup | Nothing. You run the cleanup yourself. | Close files and connections with try/finally, .close(), .dispose(), or using. |
This lesson also uses ideas from two earlier lessons. From closures: a closure can keep a value alive by accident. From symbols: some symbols can be weak targets. The next lesson, resource management, shows cleanup you control, such as using.
WeakRef and deref()
STEP THROUGHTo make a weak reference, write new WeakRef(target). To read it, call ref.deref(). Then check what you got:
- If it is the object, use it right away.
- If it is
undefined, the object is gone. Build it again, or treat it as missing.
The examples use two small tools for this check. ?. reads a property, but gives undefined instead of an error when the left side is undefined. ?? gives the value on its right when the left side is undefined or null.
const user = { name: "Asha" };
const ref = new WeakRef(user);
console.log(ref.deref()?.name ?? "missing");Line 1 makes an object and keeps it in the variable user. That is a strong reference. Line 2 makes a weak reference to the same object. Line 3 prints Asha, because user still keeps the object alive. The ?? "missing" part is for a later read, when the object might be gone. It doesn't mean the object is removed now.
function readName(ref) {
const user = ref.deref();
return user?.name ?? "reload";
}
console.log(readName({ deref: () => undefined }));Line 2 calls deref() once and keeps the answer in user. Line 3 returns the name, or reload if there is no user. Line 5 prints reload.
Why? Line 5 doesn't pass a real WeakRef. It passes a small stand-in object whose deref() always returns undefined. That shows you the missing case. It does not make the garbage collector remove anything.
deref() gives you the object while it is still alive, and undefined after it is collected. So check the answer every time.function readLater(ref) {
setTimeout(() => {
const user = ref.deref();
console.log(user?.name ?? "reload");
}, 0);
}
readLater({ deref: () => undefined });A job is one piece of code that runs from start to finish without a break. Code inside a timer runs later, in a new job.
Line 2 uses setTimeout to run some code later, in a new job. Line 3 calls deref() again. Line 4 prints reload, because this stand-in returns undefined. With a real WeakRef, the object might still be there, or it might be gone. A read that worked earlier tells you nothing about a later job.
The next experiment shows a promise from the specification (the spec), the official rulebook for JavaScript. When you create a WeakRef or call deref(), the spec keeps the target alive until the current job ends. The spec calls this rule KeepDuringJob. It does not keep the object forever.
Step through a WeakRef read. Nothing here makes the garbage collector run. It shows what is promised inside one 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");Line 1 makes the object. Line 2 makes the weak reference. Line 3 sets target to null, which removes the strong reference. Lines 5 and 6 call deref() in the same job, so both get the same object. That is why line 7 prints true. In a later job, either call could give undefined.
Don't keep a deref() answer for a long time. While a variable holds the object, that variable is a strong reference, so it keeps the object alive. The safe pattern is short: read, check, use, and let go.
What can be weak?
TYPE RULESA weak target must be something the garbage collector can remove on its own. These work:
- Objects.
- Symbols made with
Symbol(). These are called non-registered symbols. They work since ES2023, the 2023 edition of JavaScript.
These don't work. new WeakRef() throws a TypeError for them:
- Registered symbols from
Symbol.for(). JavaScript keeps them in one shared list, andSymbol.for()with the same name always gives back the same symbol. - Primitives: numbers, strings, booleans,
null, andundefined.
Older engines may not accept symbols as targets. If your code needs symbol targets, check once when the program starts. If the check fails, use an object target instead.
const local = Symbol("local");
let supported = false;
try { supported = new WeakRef(local).deref() === local; }
catch { supported = false; }
console.log(supported ? "symbol target supported" : "use an object target");Line 3 tries a symbol as a target, inside a try block. It checks that deref() gives the same symbol back. If an older engine throws, the catch on line 4 runs instead. On current Node 22, line 5 prints symbol target supported. An older engine that rejects symbols prints use an object target instead.
const target = { name: "object target" };const ref = new WeakRef(target);console.log(ref.deref() === target);trueObjects and Symbol() symbols can be WeakRef targets. Here deref() gives back the same target.
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);Line 1 makes an object. Line 2 makes a non-registered symbol. Lines 4 and 5 make a weak reference to each one. Both print true, because deref() gives back the same target.
The next two examples fail on purpose. They are marked as expected errors, so the editor shows the TypeError and doesn't treat the example as broken.
const target = Symbol.for("shared target");new WeakRef(target);new WeakRef(42);FinalizationRegistry
BEST EFFORTFinalizationRegistry lets you ask for a note after an object is removed. You register two things with it:
- The target: the object to watch.
- A held value: a small value, such as a string, that the registry keeps for you.
If the target is removed, the engine may later call your callback with the held value. This callback is also called a finalizer. You can't control when the target is removed, or when the finalizer runs.
The table lists each part of FinalizationRegistry and what it does.
| Part | What it does |
|---|---|
new FinalizationRegistry(callback) | Makes a registry. If the callback runs, it gets a held value after a target is removed. |
register(target, heldValue, token) | Starts watching a target: an object or a Symbol() symbol. heldValue must not be the target. token is optional. |
unregister(token) | Cancels every registration made with that token. Returns true if it cancelled any, and false if not. |
| When the callback runs | Maybe soon, maybe late, maybe never, and maybe not before the page closes. 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));Lines 1 to 3 make the registry. Its callback prints the held value. Line 5 makes an empty object called token. A token is a key you can use later to cancel a registration.
Line 7 registers widget, with the held value "preview card" and the token. Line 8 cancels that registration and prints true. Line 9 prints false, because nothing is left for that token. The callback never runs here.
widget and keeps the held value, "preview card". After widget is collected, the callback may get that value: maybe late, maybe never.A token is optional. If several registrations share one token, a single unregister(token) cancels them all. Cancelling never runs the callback.
const registry = new FinalizationRegistry(() => {});
const token = {};
const first = { name: "Asha" };
const second = { name: "Mira" };
registry.register(first, "first", token);
registry.register(second, "second", token);
console.log(registry.unregister(token));
console.log(registry.unregister(token));Lines 5 and 6 register two users with the same token. Line 7 cancels both and prints true. Line 8 prints false, because none are left. The garbage collector didn't remove either user here.
The registry keeps the held value, so it can pass it to the callback later. If the held value were the target itself, the registry would keep the target alive. Then the target could never be removed. So register() throws a TypeError.
Wrapping the target in another object is a trap too. For example, { user: target } as the held value doesn't throw, but it still keeps the target alive. Keep held values small, such as a string ID. Also, don't let the callback function use the target.
const registry = new FinalizationRegistry(() => {});const target = {};registry.register(target, target);const registry = new FinalizationRegistry(() => {});
let user = { name: "Asha" };
registry.register(user, { savedUser: user });
user = null;
console.log("registered, but user is still strongly held");Line 3 works, because the held value is a new object, not user itself. Line 4 removes the variable's strong reference. Line 5 prints registered, but user is still strongly held.
Here is why. The registry keeps the held value, and the held value points back at the user. While that path exists, the user can't be removed, so the callback can never run.
Weak caches: Map<key, WeakRef>
STEP THROUGHA cache keeps results you made before, so you don't have to make them again. A weak cache usually has two parts:
- A normal
Map. Its keys are simple values that don't change, such as URLs or file paths. WeakRefvalues that point at big objects, such as decoded images or ASTs. An AST (abstract syntax tree) is a program turned into a tree of objects.
A FinalizationRegistry can remove old keys later. But it must check the current map entry first. You'll see why below.
const records = new Map();
console.log(records.get("tea")?.deref() ?? "not cached");Line 1 starts with an empty map. Line 2 prints not cached, because there is no entry to call deref() on. Your code needs a fallback both for a missing key and for a weak reference that finds nothing.
const records = new Map([["tea", { deref: () => undefined }]]);
const cached = records.get("tea")?.deref();
console.log(cached?.label ?? "cache miss");This time the key exists, but the stand-in on line 1 returns undefined. Line 3 prints cache miss. A cache miss means the cache didn't have the value. This shows what a removed object looks like, without making the garbage collector run.
const records = new Map([["tea", { deref: () => undefined }]]);
function getTea() {
const cached = records.get("tea")?.deref();
if (cached !== undefined) return cached;
const tea = { label: "fresh tea" };
records.set("tea", new WeakRef(tea));
return tea;
}
console.log(getTea().label);Line 3 checks the old entry. It finds nothing, so line 4 doesn't return. Lines 5 and 6 make a new value and store a new WeakRef to it. Line 9 prints fresh tea. If a later read also misses, build the value again. The map alone doesn't promise that a value stays cached.
Now, why must the cleanup check first? Because a callback can arrive late:
- The old object for
app.jsis removed. - Before its callback runs, your code stores a fresh value under
app.js. - The late callback for the old object arrives.
If the callback deletes the key without checking, it deletes the fresh value. So the helper below first checks ref.deref() === undefined. It deletes the key only when that is true.
app.js key after it holds a fresh value. Deleting without checking loses it; checking deref() first keeps it.A weak cache uses a normal Map, WeakRef values, and a registry callback that deletes a key only after it checks 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);Lines 26 and 27 store an old value and then a new value under app.js. Line 29 calls the cleanup helper, the way a late finalizer for the old value would. On line 8, deref() finds the new value, so the key stays. Line 30 prints NewProgram.
In a real app, remember runs when you decode or parse a value. read runs when the page asks for that value. The registry callback is only a best-effort way to remove old keys: it may happen, but nothing promises it. So normal reads must still handle a miss.
If callbacks never run, old string keys can pile up in the normal Map. Remove them when a read misses. If the number of keys can keep growing, also set a size limit, or clear old keys yourself from time to time. Weak values don't make the map's keys weak.
Why cleanup timing is never promised
POSSIBLE OUTCOMESYour code can't see garbage collection, and it can't control it. An engine may:
- remove an object late,
- never remove it before the program ends, or
- skip the finalizers when a page closes.
TC39, the group that designs JavaScript, advises you to avoid these features when you can. Use them only when a missed callback does no harm.
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 may wait to collect garbage until it runs low on memory, or until some other moment it picks.
Browser tools let you try this by hand. In Chrome DevTools, the Memory panel has a Collect garbage button. It is useful for learning and for finding bugs. But your page's code can't press that button. So a demo that says "click here and the object will be removed" teaches the wrong idea.
For cleanup that must happen, use explicit cleanup: cleanup code that you call yourself, at a time you choose.
finallyconst handle = { close() { console.log("closed"); },}; try { console.log("use handle");} finally { handle.close();}Lines 1 to 5 make a handle object with a close() method. Line 7 starts a try block, and line 8 prints use handle. The finally block on line 9 always runs after the try block. So line 10 closes the handle and prints closed. This also works when the work fails.
const handle = { close: () => console.log("closed") };
try {
try { throw new Error("failed"); }
finally { handle.close(); }
} catch (error) {
console.log(error.message);
}Line 3 throws an error. The finally on line 4 still runs and closes the handle, which prints closed. Then the outer catch on line 5 catches the error, and line 6 prints failed. Without that outer catch, the error would go on to the calling code, after the cleanup.
Use explicit cleanup for anything important:
try/finally.- A
close()ordispose()method that you call. - Your framework's teardown step, which runs when a component is removed.
- In newer JavaScript,
using, which the next lesson teaches.
A finalizer can't replace any of these.
Where these tools are useful
SORT ITThese tools fit jobs where losing the value is fine:
- Caches of big things you can make again, such as decoded images, ASTs, or formatted data.
- Code that watches an object but should not keep it alive.
- Tests that look for memory leaks, where a missed note does no harm. A memory leak is memory your program keeps by mistake.
They don't fit cleanup that must happen.
The table lists common situations. For each one, it says whether these tools fit, and why.
| Situation | Do they fit? | Why |
|---|---|---|
| Decoded images or ASTs | Yes, as a weak cache. | When deref() gives undefined, make the value again. Never promise it stays cached. |
| Watching an object you don't own | Sometimes. | Your code can point at the object without keeping it alive. |
| Tests that look for memory leaks | With care. | A test process that can ask for garbage collection may see an object removed. App code must not depend on it. |
| Sockets, locks, files, event listeners | No. Use explicit cleanup. | These must be released for sure, not by a finalizer that may never run. |
- Cache a big decoded image that you can make again from its URL.
- Store ASTs by file path in a developer tool.
- Notice in a test that an object was finally removed.
- Roughly count, for statistics, when preview objects go away.
- Close a WebSocket when a component is removed from the page.
- Remove a page event listener that a widget added.
Put each task under the safest tool. If a missed callback would break the app, it needs explicit cleanup.
A test can run in its own Node process with the --expose-gc flag. That flag lets the test ask for garbage collection, and then check whether a target was removed.
Even then, seeing nothing by a deadline doesn't prove a leak. Removal and finalizers are never promised. Code that runs in a page must never depend on either one.
Common misconceptions
- "
WeakRefmakes the object get removed." It only stops your reference from keeping the target alive. The engine decides when to remove it. - "If
deref()worked once, it will work later." In a later job it can giveundefined. Check every time. - "A finalizer is a cleanup hook." A finalizer is a best-effort note: it may come late, or never. Important cleanup belongs in code you call yourself.
- "The held value can be the target." It can't. That would keep the target alive, so
register()throws aTypeError. - "A registry callback can just delete its cache key." It must check the current
WeakReffirst, because the key may hold a fresh value by then. - "WeakRef makes code faster." It is a tool for rare cases. A normal cache with a size limit is usually simpler. Use
WeakRefonly when the object really must stay removable.
The table compares the four tools side by side. Each row asks one question, and each column answers it for one tool.
| Question | WeakMap | WeakRef | FinalizationRegistry | Explicit cleanup |
|---|---|---|---|---|
| What does it hold weakly? | Its object keys. | The target that deref() returns. | The targets you register. | Nothing. You run it yourself. |
| Can I read the value later? | Yes, while I still have the key. | Maybe. deref() can give undefined. | No. The callback gets the held value instead. | Yes. The resource's own methods decide. |
| Can I trust the timing? | There is no callback to wait for. | No. The target can go at any time. | No. The callback may come late, or never. | Yes, if I call it in the right place. |
Practice exercises
5 EXERCISESRead the code. Type the two values it prints.
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");After you create a WeakRef or call deref(), the target stays alive until the current job ends. So a and b are the same object. The code prints true, then preview.
The code 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) works, so line 2 prints true. new WeakRef(Symbol.for("shared")) on line 3 throws a TypeError, because registered symbols can't be weak targets.
Predict the two values, true or false, that the last two lines print.
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 call cancels the registration and prints true. The second finds nothing for that token and prints false.
The broken callback deletes the key without checking, so the code prints false. What 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. It deletes only when ref?.deref() === undefined. Here deref() finds NewProgram, so the key stays and the code prints true.
A page opens a socket (a live network connection) or a handle to a file. Which kind of cleanup does it need?
const handle = {
close() {
console.log("closed");
},
};
try {
console.log("use handle");
} finally {
handle.close();
}Use explicit cleanup: try/finally, a close() or dispose() method you call, your framework's teardown step, or using where it is supported.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which sentence best describes
WeakRef?Choose an answer to see the explanation.
Question 2 of 7What does this code 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
Symbol.for()symbol is used as aWeakReftarget?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 calling
unregister(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 does this registration throw?
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 this weak cache code 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 depend on a finalizer?
Choose an answer to see the explanation.
Key takeaways
WeakRefdoesn't keep its target alive. Soderef()can returnundefined.- Creating a
WeakRefor callingderef()keeps the target alive only until the current job ends. - Objects and
Symbol()symbols can be weak targets. Primitives andSymbol.for()symbols can't. FinalizationRegistrycallbacks may run late, or never. Never use them for cleanup that must happen.- A weak cache deletes an old key only after checking the current
WeakRef.
Remember the one-liner.
Weak references let you look, not keep. Use them when losing the value is fine, and clean up real resources yourself.
Up next: explicit resource management.