Weak references & ephemerons
Learn how collectors treat WeakMap, WeakRef, finalizers, ephemerons, and job-level liveness without relying on unreliable cleanup timing.
- 01Tell strong and weak references apartExplain why a weak reference can read an object without keeping it alive, and find the strong reference that does keep it.
- 02Explain ephemeronsUse a small marking model to show why a WeakMap value is kept only after its key is kept.
- 03Avoid timing bugsUse WeakRef and finalizers safely: know the current-job promise, and write cleanup that must happen yourself.
Weak references in the collector
The garbage collector (the collector, for short) is the part of the JavaScript engine that frees memory. It keeps every object your program can still reach and throws the rest away.
An object is reachable when your code can get to it from a starting point, such as a variable that is still in use. These starting points are called roots.
Most references are simple. If a reachable object points at another object, the collector keeps both. A few built-in tools work differently. They let your code look at an object, or attach extra information to it, without keeping that object alive.
A weak reference points at an object but does not keep it alive. An ephemeron is a pair of a key and a value. The collector keeps the value only if something else already keeps the key alive.
This lesson looks at what the collector does with these tools. Two earlier lessons show how to use them in everyday code: WeakMap and WeakSet and WeakRef and finalization.
The previous lesson showed how the collector splits its work into small pieces, so the page does not freeze. Weak references add one more rule to that work. The next lesson, Garbage collection across engines & the DOM, compares how real engines handle JavaScript objects together with the objects of a web page.
Strong and weak references
A strong reference is an ordinary reference, like a property or a variable. The collector follows it. If a reachable object has a property that points at a cart, the cart is reachable too.
People who study collectors often draw objects as dots and references as lines between them. Such a line is called an edge. So a strong reference is also called a strong edge, and a weak reference a weak edge.
A WeakRef is a built-in object that holds a weak reference to one object, its target. Its deref() method gives you the target back. It gives undefined if the target was already thrown away.
const cart = { price: 3 };const strong = { cart };const weak = new WeakRef(cart); console.log(strong.cart.price);console.log(weak.deref()?.price);In plain words, line by line:
- Line 1 makes a cart with a price of 3.
- Line 2 makes an ordinary object,
strong, that holds the cart. This is a strong reference. - Line 3 makes a
WeakRefcalledweakthat points at the same cart. This is a weak reference. - Line 5 reads the price through
strongand prints3. - Line 6 reads the price through
weakand prints3too. The?.means "ifderef()gaveundefined, giveundefinedinstead of an error."
Both reads work because strong still holds the cart.
strong holds the cart, so the cart stays alive. weak only looks at it, so both reads print 3 only because strong still holds it.If you hold a balloon's string, the balloon stays with you. If you only look at a balloon, you can see it while someone else holds the string. Looking never stops it from floating away when nobody holds the string.
- In real life: Holding a balloon string
- In JavaScript: A strong reference keeps the object alive
- In real life: Only looking at a balloon
- In JavaScript: A weak reference can read the object
- In real life: Letting go of the string
- In JavaScript: The last strong reference goes away
- In real life: Balloon floating away
- In JavaScript: The collector may later throw the object away
Weak does not mean broken or hidden. It means: "do not keep the object alive because of this reference." So a cache (a store of saved results) can attach extra information to an object without changing how long that object lives.
A WeakMap entry depends on its key
A WeakMap is a map whose keys must be objects. You store a value with set(key, value) and read it with get(key). It is useful for extra information about an object that you don't want to put inside the object.
Start with the simple case: the key is still in use elsewhere.
const user = { name: "Asha" };const notes = new WeakMap();notes.set(user, "likes tea");console.log(notes.get(user));In plain words, line by line:
- Line 1 makes a
userobject. - Line 2 makes the map.
- Line 3 stores the string
"likes tea"withuseras the key. - Line 4 reads it back and prints
likes tea.
The variable user is an ordinary strong reference to the key. So nothing surprising happens yet.
Think of a name tag on a guest at a party. The tag is useful while the guest is at the party. But the tag never keeps the guest there.
- In real life: A guest at the party
- In JavaScript: A key that is reachable from the roots
- In real life: A name tag for that guest
- In JavaScript: A WeakMap entry
- In real life: A note written on the tag
- In JavaScript: The value stored with the key
- In real life: The guest leaves the party
- In JavaScript: The key is no longer reachable
The important part is the value. An ordinary Map keeps both its keys and its values alive. A WeakMap does not. It keeps a value alive only after the collector has found that the key is alive for some other reason.
Ephemerons: the key first, then the value
The collector's name for one WeakMap entry is an ephemeron. It is a rule, not an ordinary reference: "keep the value only if the key is already kept."
To find live objects, the collector marks them. Marking means putting a "keep" sign on an object. The collector starts at the roots and follows strong references. Every object it reaches gets marked. At the end, unmarked objects are thrown away.
With ephemerons, the collector works in this order:
- First, it marks everything it can reach through strong references.
- Then it looks at each entry. If the key is marked, it marks the value too.
- A newly marked value may point at more objects, so it goes back to the first step. It stops when a full round marks nothing new.
const roots = ["user"];const entry = { key: "user", value: "notes" };console.log(roots.includes(entry.key) ? entry.value : "nothing");This small program is a model, not the real collector. Line 1 says user is a root. Line 2 describes one entry: the key is user and the value is notes. Line 3 uses includes to check whether the key is in the roots list. The ? : picks entry.value if it is, and "nothing" if it is not. The key is a root, so line 3 prints notes.
The real collector repeats this check for many objects. The next model does that with a loop.
const roots = ["user"];const graph = { user: [] };const entries = [{ key: "user", value: "notes" }]; function markEphemerons(roots, graph, entries) { const marked = new Set(roots); let changed = true; while (changed) { changed = false; for (const name of [...marked]) { for (const child of graph[name] ?? []) { if (!marked.has(child)) { marked.add(child); changed = true; } } } for (const entry of entries) { if (marked.has(entry.key) && !marked.has(entry.value)) { marked.add(entry.value); changed = true; } } } return [...marked].sort();} console.log(markEphemerons(roots, graph, entries).join(","));Read it in parts:
- Lines 1 to 3 set up the data: one root,
user, and one entry fromusertonotes.graphlists the strong references of each object;userhas none. - Line 6 marks the roots. A
Setis a list with no repeats. - Line 8 repeats the work as long as the last round marked something new.
- Lines 10 to 14 follow strong references and mark every object they reach.
- Lines 15 to 19 apply the entry rule: if the key is marked, mark the value.
- Line 24 prints the marked names in A-to-Z order:
notes,user.
When a full round marks nothing new, the result stops changing. That stable result is called a fixpoint (a fixed point).
user to the value notes. On the left, a root reaches user, so notes is marked too. On the right, nothing reaches user, so nothing is marked.Step through a small ephemeron marking loop. The value is kept because its key was already reachable another way.
script
const graph = { user: [] };const entries = [{ key: "user", value: "notes" }]; const result = markEphemeronsForReplay(roots, graph, entries);console.log(result.marked.join(","));The step-through calls the same model function as the playground below. Real engines make this faster with queues and other tricks. The rule stays the same: the key first, then the value.
const roots = ["user"];const graph = { user: [] };const entries = [{ key: "user", value: "notes" }]; function markEphemerons(roots, graph, entries) { const marked = new Set(roots); let changed = true; while (changed) { changed = false; for (const name of [...marked]) { for (const child of graph[name] ?? []) { if (!marked.has(child)) { marked.add(child); changed = true; } } } for (const entry of entries) { if (marked.has(entry.key) && !marked.has(entry.value)) { marked.add(entry.value); changed = true; } } } return [...marked].sort();} console.log(markEphemerons(roots, graph, entries).join(","));notes,userThe loop repeats until a full round marks nothing new.
user is a root, so it is marked first. Then the entry lets the model mark notes too.
Why a value cannot save its own key
The order matters most when a value points back at its own key. Suppose the value notes has a reference to its key, user.
If the collector looked at the value first, notes would mark user. Then user would mark notes. The two would keep each other alive forever, even though your program can't reach either one.
const roots = [];const graph = { user: [], notes: ["user"] };const entries = [{ key: "user", value: "notes" }];console.log(markEphemerons(roots, graph, entries).join(",") || "nothing");In plain words, line by line:
- Line 1 has no roots at all.
- Line 2 says
noteshas a strong reference back touser. - Line 3 describes the same entry as before.
- Line 4 runs the model. The
|| "nothing"part printsnothingwhen no name was marked. Nothing markeduser, so the entry never marksnotes. The reference back touseris never followed.
notes points back to user, but no root reaches user. The collector never looks inside notes, so line 4 prints nothing.So "weak key, strong value" is not the full story for WeakMap. The value is kept only after the collector knows the key is alive.
First mark everything reachable the normal way. Then let each marked key mark its value. Repeat until nothing new is marked. This order stops a WeakMap entry from keeping itself alive.
When a WeakRef target can disappear
A WeakRef lets you read its target only while the target still exists. Right now, deref() can return the object. After a later collection, it can return undefined. A WeakRef never promises that the object will stay.
const user = { name: "Asha" };const ref = new WeakRef(user);console.log(ref.deref()?.name);console.log(ref.deref() === undefined ? "gone" : "here");In plain words, line by line:
- Line 1 makes an object for Asha.
- Line 2 makes a
WeakRefto it. - Line 3 prints
Asha. - Line 4 prints
here, because the object still exists right now.
Later, after the last strong reference is gone, the same check on line 4 may print gone.
A FinalizationRegistry lets you register a callback (a function the engine calls for you later). The engine may call it some time after a registered object is thrown away. This callback is called a finalizer. It comes with no promises about time:
- It may run much later.
- It may run at a moment your program did not expect.
- It may never run, for example if the page closes first.
So a finalizer is only for optional record keeping. Never use it to close a file, save a draft, release a lock, or cancel a request.
The table compares four similar ideas. Read each row as: "this thing follows this rule, so think of it like this."
| Thing | What the collector does | How to think about it |
|---|---|---|
| Strong reference | Keeps its target alive while the object holding it is alive. | An ordinary property, such as strong.cart. Like holding a balloon's string. |
WeakRef | Can give you its target, but does not keep it alive. | A look at an object that may be gone later. Always check deref() for undefined. |
WeakMap entry | Keeps its value only after its key is kept alive some other way. | Extra information attached to a user object that is still in use. |
| Finalizer | May run some time after its object is thrown away, or never. | Optional record keeping. Never use it to close or save something. |
For cleanup that must happen, write code that does it yourself:
- Call the object's own
close()method. - Use a
usingdeclaration where it is supported. It calls the object's cleanup method for you when the block ends. - Use an
AbortControllerto cancel work, such as afetchrequest.
These actions happen because your program asked for them, at a time you choose.
A target stays alive until the current job ends
A weak reference would be hard to use if its target could vanish between two reads on nearby lines. So JavaScript gives one small promise. When you create a WeakRef, or get an object from deref(), that object stays alive until the current job ends.
Two words matter here:
- A job is the code that is running right now, from start to end without a pause, plus any promise callbacks it queues before the next task.
- A task is a separate piece of work that the engine runs later, such as a timer callback or a click handler.
let user = { name: "Asha" };const ref = new WeakRef(user);user = null; const first = ref.deref();const second = ref.deref();console.log(first === second, second?.name);In plain words, line by line:
- Line 1 makes Asha's object.
- Line 2 makes a
WeakRefto it. - Line 3 sets
usertonull, so the only strong reference is gone. - Lines 5 and 6 read the target twice.
- Line 7 prints
true Asha: both reads got the same object.
Step through the current job promise: an object you just got from a WeakRef stays alive until the code running now, and its promise callbacks, are done.
script
const ref = new WeakRef(user);user = null; const first = ref.deref();const second = ref.deref();console.log(first === second, second?.name);deref() calls get Asha, so line 7 prints true Asha. In a later task, the object may be gone, so check for undefined.A later timer, a browser event, or any other later task is not covered by this promise. Don't save a weak target and expect it to exist when a later callback runs. Call deref() again there, and check for undefined.
Checking it in Node
Browser code cannot make the collector run. It should also never depend on when the collector runs.
For testing, Node can turn on a test-only function, gc(), when you start Node with the --expose-gc flag. Calling gc() asks for a collection right away. The three checks below use it. They print short words, never memory sizes.
const laterTask = () => new Promise((resolve) => setTimeout(resolve, 0)); function makeWeakOnly() { let value = { label: "weak-only", data: new Array(2000).fill("tea") }; const ref = new WeakRef(value); value = null; return ref;} const ref = makeWeakOnly();let collected = false;for (let attempt = 0; attempt < 12; attempt += 1) { await laterTask(); globalThis.gc(); await laterTask(); if (ref.deref() === undefined) { collected = true; break; }}console.log("weak collected", collected);In plain words:
- Line 1 defines
laterTask. It waits until a later task by usingsetTimeout. - Lines 3 to 8 make an object (line 4), wrap it in a
WeakRef(line 5), and drop the strong variable (line 6). Only theWeakRefis returned. - Lines 12 to 17 try up to 12 times: wait for a later task, call
gc(), wait again, and check whetherderef()givesundefined. - Line 18 prints
weak collected truein the tested Node version.
The loop tries several times because nobody can promise which attempt will work, or how long it takes.
const laterTask = () => new Promise((resolve) => setTimeout(resolve, 0)); function makeEntry() { let key = { name: "Asha" }; let value = { label: "notes", data: new Array(2000).fill("tea") }; const valueRef = new WeakRef(value); const map = new WeakMap(); map.set(key, value); key = null; value = null; return valueRef;} const valueRef = makeEntry();let collected = false;for (let attempt = 0; attempt < 12; attempt += 1) { await laterTask(); globalThis.gc(); await laterTask(); if (valueRef.deref() === undefined) { collected = true; break; }}console.log("ephemeron value collected", collected);In plain words:
- Line 6 gives the value its own
WeakRef. Now the check can watch the value without keeping it alive. - Line 8 stores the key and the value in the
WeakMap. - Lines 9 and 10 drop both variables, so nothing else reaches the key.
- Lines 16 to 21 try up to 12 times, the same way as before.
- Line 22 prints
ephemeron value collected true. Once the key was gone, the value was thrown away too.
let user = { name: "Asha" };const ref = new WeakRef(user);user = null;globalThis.gc();console.log("same job", ref.deref()?.name);Line 3 drops the strong variable, and line 4 even asks for a collection. Line 5 still prints same job Asha, because the job has not ended yet.
This only checks the job promise. Never call a test-only function like gc() in app code. There is no check for finalizers here: their timing is not reliable enough to test.
Using weak tools carefully
A good use for WeakMap is extra information about an object that another part of the app owns. To own an object means to decide how long it lives. For example, a layout helper might store a row number for each user object without adding a property to that object.
const user = { name: "Asha" };const layoutNotes = new WeakMap();layoutNotes.set(user, { row: 3 });console.log(layoutNotes.get(user)?.row);In plain words, line by line:
- Line 1 is the user object. Another part of the app owns it.
- Line 2 makes a private map for layout notes.
- Line 3 stores row 3 for this user.
- Line 4 prints
3.
When nothing else in the app reaches the user, the map does not keep its notes alive.
Don't pick a weak tool just to avoid deciding who owns an object. Weak tools have limits:
- You can't loop over the entries of a
WeakMap. - An entry can disappear any time after its key is gone.
- A weak map is not a cache with a size limit.
Use an ordinary Map when you need to loop over entries, count them, remove old ones by your own rule, or save them.
cart.userpoints to a user object.- A
WeakRefcan later returnundefined. - A marked key lets its WeakMap value be marked.
- A key that nothing reaches does not keep its value.
- Two
deref()calls on nearby lines get the same target. - An event listener that is still added uses a cart.
Sort each card by the rule it describes. Every card uses this lesson's cart, user, and notes examples.
Common mistakes with weak references
- "WeakMap values are always kept." A value is kept only while its key is reached some other way.
- "A WeakRef keeps the object for me." It only looks.
deref()may giveundefinedlater. - "A finalizer can close things." It may run late or never, so cleanup that must happen needs your own code.
- "Apps can call
gc()." That function exists only in Node with a test flag. Browsers don't offer it. - "Two reads on nearby lines can give different answers." Inside one job, the target stays alive, so both reads match.
Read each row as: "this term means this, and it is not that."
| Term | What it really means | Don't mix it up with |
|---|---|---|
WeakMap value | It is kept only while its key is marked some other way. | An ordinary property that the map always keeps. |
WeakRef.deref() | It gives the object now, but may give undefined in a later task. | A handle that keeps the object for you. |
| Finalizer callback | A notice that may come late or never. | A cleanup step that is sure to run. |
| Current job | The code running now, plus the promise callbacks it queues before the next task. | Every later timer or click callback. |
A simple habit helps:
- Use ordinary strong references by default.
- Use a weak tool when the object's owner should decide how long it lives.
- Never make your program depend on when the collector runs.
Practice exercises
Read the four lines. Type what line 4 prints while user is still reachable.
const user = { name: "Asha" };
const notes = new WeakMap();
notes.set(user, "likes tea");
console.log(notes.get(user));It prints likes tea. A WeakMap gives back the value while something else, here the user variable, keeps the key alive.
Predict the marked names. Type them in the printed order, separated by a comma.
const marked = new Set(["user"]);
const entry = { key: "user", value: "notes" };
if (marked.has(entry.key)) marked.add(entry.value);
console.log([...marked].sort().join(","));It prints notes,user. The key user is marked, so line 3 marks its value notes. sort() puts the two names in A-to-Z order.
The value points back to its key, but no root reaches the key. What does the program print?
const roots = [];
const entry = { key: "user", value: "notes" };
const marked = new Set(roots);
if (marked.has(entry.key)) marked.add(entry.value);
console.log([...marked].join(",") || "nothing");It prints nothing. The key was never marked, so the value is never marked either. A value that points back cannot start the marking.
In a later task, after a collection, what can deref() return instead of the object?
deref() can return undefined later. Always check the result before you use it.
Your app must cancel an upload when a panel closes. What kind of cleanup should it use?
Use explicit cleanup: for example, call abort() on an AbortController when the panel closes. Your code decides when it happens, not the collector.
A profile list owns user objects. A layout helper wants to store a row number for each user. What should the helper use as the key in its WeakMap?
Use the user object as the key and store the row information as the value. When the app no longer reaches the user, the row information can be thrown away too.
Check your understanding
For each question, first ask which idea it is about: a strong reference, a key that must be marked first, a later read, or the current job.
Question 1 of 6What does the first WeakMap example print while
useris reachable?Read the code, then predictconst user = { name: "Asha" }; const notes = new WeakMap(); notes.set(user, "likes tea"); console.log(notes.get(user));Choose an answer to see the explanation.
Question 2 of 6What makes an ephemeron different from an ordinary reference?
Choose an answer to see the explanation.
Question 3 of 6A WeakMap value points back to its own key, but nothing else reaches the key. What happens in the model?
Choose an answer to see the explanation.
Question 4 of 6What does this code print right away?
Read the code, then predictconst ref = new WeakRef({ name: "Asha" }); console.log(ref.deref() === undefined ? "gone" : "here");Choose an answer to see the explanation.
Question 5 of 6How should you handle cleanup that must happen?
Choose an answer to see the explanation.
Question 6 of 6For this lesson's rule, what does the current job include?
Choose an answer to see the explanation.
Key takeaways
- A strong reference keeps its target alive. A weak reference can read its target without keeping it alive.
- A
WeakMapentry is an ephemeron: the collector marks the value only after the key is marked, and repeats until nothing changes. - A value that points back to its key cannot keep the entry alive by itself.
deref()can returnundefinedlater. A finalizer may run late or never, so never use it for cleanup that must happen.- Creating a
WeakRefor reading it withderef()keeps the target alive until the current job ends. - Use weak tools for optional extra information, not to avoid deciding who owns an object.
Remember the one-liner.
Weak tools can look at an object, or attach information to it, without keeping that object alive.
Coming next: Garbage collection across engines & the DOM.