Memory management & garbage collection
Learn how JavaScript allocates, uses, and releases memory, how mark-and-sweep finds unreachable objects, and how common leaks happen.
- 01Explain the three stepsDescribe how a value is made, used, and let go, and why you never free memory by hand.
- 02Follow the arrowsStart from the roots, follow the arrows, and say which objects mark and sweep can clean up.
- 03Spot common leaksFix listeners, timers, closures, caches, and removed page elements that keep memory in use.
Memory that cleans itself up, with your help
Every object your code makes takes up some of the computer's memory. When the object is no longer needed, that memory should be given back. In JavaScript you don't give it back yourself. The engine does it for you.
Two earlier lessons help here. In the stack and heap lesson, you saw that a variable can point to an object. In the closures lesson, you saw that a function can keep using variables after the function that made them has finished. This lesson puts both ideas together in real web page code.
Memory management is how values get memory and give it back. It has three steps:
- Your code makes a value. JavaScript finds memory for it.
- Your code uses the value.
- Your code lets go of the value. Later, the engine's garbage collector (the part that cleans up memory) takes it back.
Some languages have a free() command to give memory back. JavaScript has no such command for objects. Your job is different: stop holding on to things you no longer need. You will learn four ways to do that:
- Remove event listeners you no longer need.
- Stop timers.
- Put a size limit on caches (stores of saved results).
- Stop keeping page elements you already removed.
The engine still decides when the clean-up happens. Your code can't choose the moment.
Make, use, let go
STEP THROUGHThe three steps have short names. You will see them in other articles too:
- Allocate: find memory for a new value. This happens on its own when your code makes an object.
- Use: read or change the value.
- Release: let go of the value, so it can be cleaned up.
One more word you will see a lot. A reference is an arrow from a variable (or a property) to an object. The variable doesn't hold the object itself. It holds the arrow.
You put a notebook in your backpack and use it in class. When class ends, you take it out. You don't destroy the notebook yourself. The lost-and-found clears out unclaimed things on its own day.
- In real life: Put a notebook in the backpack
- In JavaScript: Make an object and keep an arrow to it
- In real life: Read the notebook during class
- In JavaScript: Use the object
- In real life: Take the notebook out when class ends
- In JavaScript: Remove the arrow you no longer need
- In real life: The school's lost-and-found decides when to clear out old things
- In JavaScript: The engine decides when to clean up
The table below shows each step. Read each row as: "in this step, my code does this, and this is the careful way to say it."
| Step | What your code does | How to say it carefully |
|---|---|---|
| Allocate (make) | Your code makes objects, arrays, functions, page elements, and closures. | JavaScript finds the memory. You never ask for it yourself. |
| Use | Your code reads and changes values through variables, properties, and functions. | A value your code can still get to must be kept. |
| Release (let go) | Your code removes arrows it no longer needs: sets variables to null, removes listeners, stops timers, limits caches. | The object may now be cleaned up. The engine still decides when. |
Step through this small example. It makes a preview object, reads it, changes it, and then lets go of it. You can know what it prints. You can't know when the old object is cleaned up.
Follow the three memory steps: make an object, use it, let go of it. What can still be reached decides what may be cleaned up later.
script
function openPreview() { let preview = { title: "Cart", rows: ["hat", "bag"] }; const label = preview.title.toUpperCase(); preview.rows.push("coat"); preview = null; return label;} Line 5 needs care. preview = null removes one arrow to the object. It is not an order to the garbage collector. If something else still had an arrow to the object, the object would stay.
Roots and reachability
MODELHow does the garbage collector know what is still needed? It starts from roots. A root is a starting point that is always kept, such as a global variable. From each root, the collector follows the arrows to objects, then the arrows inside those objects, and so on.
- If the collector can get to an object this way, the object is reachable. It must be kept.
- If it can't, the object is unreachable. It may now be cleaned up. We say it is eligible for clean-up.
Start with the people already in the group. Then follow the contacts they shared, and the contacts those people shared. Anyone you can find this way stays. Anyone you can't find can be deleted.
- In real life: People already in the group
- In JavaScript: Roots, such as global variables and running functions
- In real life: A contact someone shared in a message
- In JavaScript: A property that points to another object
- In real life: Someone you can't find through the group
- In JavaScript: An unreachable object
- In real life: Deleting contacts nobody can find
- In JavaScript: Garbage collection
Here are the roots you will meet most often. Read each row as: "this is a starting point, and this is how it can keep memory in use."
| Root | How it keeps memory in use |
|---|---|
| Global variables | Global and module-level variables last as long as the page. Anything they point to is kept that long too. |
| Running functions | While a function is running, its local variables are roots. |
| Closures and listeners the browser keeps | A stored function keeps every variable it uses. |
| Things the browser keeps | Event targets like window, the timer list, waiting tasks, and the page itself can keep functions and page elements. |
Now some code. Two variables can point to the same object. Removing one arrow leaves the other.
let cart = { items: ["tea"] };
const saved = cart;
cart = null;
console.log(saved.items[0]);- Line 1 makes a cart object.
cartpoints to it. - Line 2 copies the arrow into
saved. Now two variables point to the same cart. - Line 3 sets
carttonull. That removes only the first arrow. - Line 4 still prints
tea, throughsaved.
cart = null empties only the cart box. The saved box still has an arrow to the same object, so the object stays.A property is an arrow too. Here the cart is stored inside a page object.
const page = { cart: { items: ["tea"] } };
let selected = page.cart;
selected = null;
console.log(page.cart.items[0]);
delete page.cart;
console.log("cart" in page);- Line 2 makes
selectedpoint to the same cart aspage.cart. - Line 3 clears only
selected. - Line 4 still prints
tea, throughpage.cart. - Line 5 uses
deleteto remove thecartproperty frompage. Line 6 checks within, so it printsfalse.
Deleting a property removes that one arrow. If some other variable still pointed to the cart, the cart would stay.
One more fact about real engines. Most objects are needed only for a very short time. So engines keep new objects in a separate area and check that area more often. Objects that last longer move to an "old" area that is checked less often. This is called generational collection. Each engine does it a bit differently. You still think about your code the same way: is the object reachable or not?
Mark and sweep, and objects that point at each other
SIMULATIONAn old way to clean up is called reference counting. Each object keeps a count of how many arrows point to it. When the count drops to zero, the object is cleaned up.
Reference counting fails with a cycle: two objects that point at each other. Say the rest of the program loses both of them. Each one still has one arrow pointing to it, from the other. So neither count ever reaches zero, and neither is cleaned up.
Modern engines use mark and sweep instead. It has two steps:
- Mark: start at the roots, follow every arrow, and put a mark on each object you reach.
- Sweep: clean up every object that has no mark.
The example below acts out mark and sweep with plain JavaScript data. Each object has a name and a list of the names it points to. The root is app. Step through it and watch which names get marked.
Step through mark and sweep, written in JavaScript. Watch the root mark what it reaches, and two objects that point at each other get swept.
script
function markAndSweep(heap, roots) { const marked = new Set(); const worklist = [...roots]; while (worklist.length > 0) { const id = worklist.pop(); if (!id || !heap[id] || marked.has(id)) continue; marked.add(id); for (const ref of heap[id].refs) worklist.push(ref); } const swept = Object.keys(heap).filter((id) => !marked.has(id)); return { marked: [...marked], swept };} app: { refs: ["modal"] }, modal: { refs: ["user"] }, user: { refs: ["profile"] }, profile: { refs: [] }, cycleA: { refs: ["cycleB"] }, cycleB: { refs: ["cycleA"] },};const roots = ["app"];const result = markAndSweep(heap, roots);console.log(result.marked.join(", "));console.log(result.swept.join(", "));Lines 19 and 20 make cycleA and cycleB point at each other. Reference counting would find one arrow into each, so it would keep both. Mark and sweep starts from app and never reaches them. So the sweep step removes both.
app and follows the arrows. cycleA and cycleB point at each other, but no arrow from the root reaches them, so they are swept.This is a model, written in JavaScript so you can run it. It shows which objects may be cleaned up. It doesn't show when the browser actually does it.
Leaks from listeners, timers, closures, and caches
PLAYGROUNDA memory leak is memory that stays in use even though the app no longer needs it. An object existing is not a leak. A leak is when something that lives a long time still points to data the app is done with.
In web pages, the long-lived thing is often one of these:
- An event listener on
windowordocument. - A timer made with
setInterval. - A closure that one of those keeps.
- A cache that only ever grows.
The playground below acts out a page part, called a component, being removed. Removing a component is called unmounting. The component had a big list called bigRows. It also added a window listener and a timer, and both use bigRows. Switch clean-up on and off and watch what is still reachable.
const roots = ["app", "window", "timerQueue"]; function createMountedHeap() { return { app: { refs: ["component"] }, window: { refs: ["resizeHandler"] }, timerQueue: { refs: ["intervalCallback"] }, component: { refs: ["bigRows"] }, resizeHandler: { refs: ["bigRows"] }, intervalCallback: { refs: ["bigRows"] }, bigRows: { refs: [] }, };} function reachableIds(heap) { const seen = new Set(); const worklist = [...roots]; while (worklist.length) { const id = worklist.pop(); if (!heap[id] || seen.has(id)) continue; seen.add(id); worklist.push(...heap[id].refs); } return [...seen].filter((id) => !roots.includes(id));} function simulateUnmount(cleanup) { const heap = createMountedHeap(); heap.app.refs = []; if (cleanup) { heap.window.refs = []; heap.timerQueue.refs = []; } return reachableIds(heap);} console.log(simulateUnmount(false).join(", "));console.log(simulateUnmount(true).length);3 objects still reachable
- intervalCallback
kept - bigRows
kept - resizeHandler
kept
The component is gone, but window still holds the resize listener and timerQueue still holds the timer function. Both use bigRows, so all three are still reachable.
Without clean-up, window still holds the resize listener, and the timer list (timerQueue) still holds the timer function. Both functions use bigRows, so all three stay reachable. With clean-up, those two arrows are cut, and the count drops to zero.
window still holds the listener, and the listener holds bigRows. Removing the listener cuts that path.The next example uses an AbortController. It is a small object with an abort() method. You pass its signal when you add a listener. Calling abort() later removes that listener.
AbortControllerconst panel = document.querySelector("#listener-panel");const controller = new AbortController();const rows = ["Ada", "Lin"]; function renderRows() { console.log(rows.join(", "));} panel.addEventListener("click", renderRows, { signal: controller.signal });panel.click();controller.abort();panel.click();console.log("listener removed");- Line 9 adds a click listener and passes the controller's
signal. - Line 10 clicks the panel, so
Ada, Linprints. - Line 11 calls
abort(), which removes the listener. - Line 12 clicks again. Nothing prints, because the listener is gone.
A listener on something that lasts as long as the page, like window, keeps its function. It also keeps everything that function uses. There are three ways to remove a listener:
- Call
removeEventListenerwith the same function. - Add it with
{ once: true }, so it removes itself after it runs once. - Use an
AbortControllersignal. Oneabort()call can remove many listeners.
const button = new EventTarget();
let clicks = 0;
const onClick = () => clicks++;
button.addEventListener("click", onClick);
button.removeEventListener("click", () => clicks++);
button.dispatchEvent(new Event("click"));
console.log(clicks);
button.removeEventListener("click", onClick);
button.dispatchEvent(new Event("click"));
console.log(clicks);- Line 5 writes a new arrow function. It looks the same, but it is a different function. So it removes nothing.
- Line 6 fires a click, so line 7 prints
1. - Line 8 passes the real
onClick, so the listener is removed. - Line 9 fires another click, but nothing runs. Line 10 prints
1again.
So keep the function in a variable. You will need it to remove the listener.
const rows = ["Ada", "Lin", "Grace"];const intervalId = setInterval(() => { console.log(rows.length); clearInterval(intervalId); console.log("interval cleared");}, 0);setInterval runs a function again and again. The browser keeps that function in its timer list until you call clearInterval. Here, line 4 stops the timer the first time it runs, so 3 and then interval cleared print once. If the function used a big array, a page element, or old data, all of that stays reachable until the timer is stopped.
function makeLeakyHandler() { const bigReport = ["sales", "inventory", "returns"]; return () => console.log(bigReport.length);} function makeSmallHandler() { const rowCount = ["sales", "inventory", "returns"].length; return () => console.log(rowCount);} makeLeakyHandler()();makeSmallHandler()();Both functions print 3. The difference is what they keep:
- The first function uses
bigReport, so it keeps the whole array. - The second works out the count first (line 7). Its function keeps only the number
3.
Closures are fine. The fix is to keep big data out of a closure that lives a long time.
let rows = ["tea", "hat"];
const showCount = () => console.log(rows.length);
rows = [];
showCount();Line 2 makes a function that reads rows when it runs, not when it is made. Line 3 puts a new, empty array in rows. So line 4 prints 0. Nothing points to the old array any more, so it can be cleaned up.
A cache is a store of saved results, so you don't have to work them out again. A Map keeps everything you put in it. So a cache needs a size limit.
Map cacheconst cache = new Map(); function remember(key, value) { cache.set(key, value); if (cache.size > 2) { const oldestKey = cache.keys().next().value; cache.delete(oldestKey); }} remember("Ada", { visits: 1 });remember("Lin", { visits: 2 });remember("Grace", { visits: 3 });console.log([...cache.keys()].join(", "));Line 4 adds an entry. If the map now has more than two entries, line 6 finds the oldest key, and line 7 deletes it. After three calls, only Lin, Grace is left.
const recent = [];
for (const item of ["tea", "hat", "bag"]) {
recent.push(item);
if (recent.length > 2) recent.shift();
}
console.log(recent.join(", "));This list drops its oldest item with shift() when it grows past two. After the loop, line 6 prints hat, bag. A search-history list can use the same limit. Neither an array nor a Map gets smaller on its own.
A WeakMap is a map whose keys are objects. It holds its keys weakly: being a key in a WeakMap does not, by itself, keep an object alive.
WeakMap for extra facts about an objectconst metadata = new WeakMap();let card = { id: "promo" };metadata.set(card, { measured: true }); console.log(metadata.has(card));card = null;console.log("WeakMap keys are weakly held");Line 3 stores a fact about card, using the card as the key. Line 5 prints true. After line 6, nothing else points to the card. The WeakMap entry doesn't keep it alive, so the card and its entry can both be cleaned up.
const metadata = new WeakMap();
const cart = {};
metadata.set(cart, "tea");
console.log(metadata.has(cart));
console.log(typeof metadata.size);Line 4 prints true. Line 5 prints undefined, because a WeakMap has no size, and you can't loop over it. To sum up:
- A normal
Mapkeeps its keys and values. Use a size limit,delete, orclear()to let go of them. Use it when you need to list the entries. - A
WeakMapdoesn't keep its keys alive. Use it for extra facts about objects. - Neither one tells you when a key is cleaned up.
`button.addEventListener("click", save, { once: true })`- A
resizelistener onwindowuses page data and is never removed. - A
setIntervalfunction uses a list of rows, and the timer is never stopped. - Extra facts about page elements are stored in a
WeakMap, with the elements as keys. - A normal
Mapcache deletes its oldest entry when it has more than two. - An array keeps a removed page element for debugging and is never emptied.
Put each card in a group: cleaned up already, kept until you clean it up, or held weakly or with a size limit.
Page elements that were removed but are still kept
RUN CONTEXTEach element on a web page is a DOM node: an object that stands for that part of the page. You can take a node off the page with DOM methods like remove().
A removed node is called detached: it is no longer part of the page. But JavaScript can still have an arrow to it. Then it must be kept. This happens when your code stores old nodes in places like these:
- an array or a
Map - a global variable you added for debugging
- a closure or an event listener
The example below runs on a small practice page made for this lesson. The page has a card with the id #leak-card. The code takes the card off the page, then shows that an array still holds it.
const retained = [];let card = document.querySelector("#leak-card"); retained.push(card);card.remove(); console.log(document.contains(card));console.log(retained.length);console.log(retained[0].textContent.trim()); retained.length = 0;card = null;console.log(retained.length);- Line 2 finds the card. Line 4 puts it in the
retainedarray too. - Line 5 takes the card off the page.
- Line 7 asks the page whether it still has the card. It prints
false. - Line 8 prints
1, because the array still holds the card. Line 9 even reads its text:Temporary card. - Line 11 empties the array, and line 12 sets
cardtonull. That removes both arrows this code had. Line 13 prints0.
card.remove(), the card is not in the page. The retained array still has an arrow to it, so JavaScript must keep it.Removing both arrows makes the card eligible for clean-up. It doesn't clean it up right away.
function closeCard() {
const card = document.querySelector("#leak-card");
const onResize = () => console.log(card.textContent.trim());
window.addEventListener("resize", onResize);
card.remove();
window.dispatchEvent(new Event("resize"));
window.removeEventListener("resize", onResize);
}
closeCard();
console.log("closed");- Line 4 adds a resize listener to
window. The listener usescard. - Line 5 takes the card off the page. Line 6 fires a resize event. The listener can still read the card, so it prints
Temporary card. - Line 7 removes the listener.
- When
closeCard()ends, itscardvariable is gone too. The last line printsclosed.
One more case. If a listener is added to the removed node itself, and nothing else points to that node, the listener doesn't keep the node alive. Both can be cleaned up together.
Finding leaks with Chrome DevTools
Your page's code can't reliably ask "was this object cleaned up just now?" To hunt for a leak, use the browser's tools instead. In Chrome DevTools, open the Memory panel.
Then repeat one action several times, for example:
- show a component and remove it,
- go to another page and come back,
- open a pop-up box and close it.
The Memory panel has four tools for this:
- Heap snapshot: a snapshot is a list of every object in memory at one moment. Take one first. Then do the action and its clean-up several times, and take another. Choose the Comparison view to see which kinds of object grew in number. Click a suspect object to see its retaining path: the chain of arrows that still keeps it.
- Detached nodes: type "Detached" in the snapshot's class filter box. Or choose the Detached elements profile type. Both list removed page elements that JavaScript still keeps.
- Allocation instrumentation on timeline: this records new objects over time. Select a stretch of time to see which objects made then are still kept at the end.
- Allocation sampling: this records less detail, so it slows the page down less. Use it for long sessions. It shows roughly which functions made the most objects.
Repeat the same open-and-close steps before you decide anything. Memory going up once can be normal: the page may be caching things, or clean-up may not have happened yet. Better signs of a leak are:
- the same kind of object keeps growing in number, and
- its retaining path goes through an old listener or cache.
The heap snapshots lesson covers retaining paths in detail.
performance.memory works only in Chrome and is not part of the web standard. Treat it as a rough hint.
performance.measureUserAgentSpecificMemory() is the newer way to measure memory. A page can use it only if the server sends special security headers. These turn on cross-origin isolation, which keeps the page apart from other sites.
Neither one lets your code know when clean-up happens.
// Works only in Chrome, and isn't part of the web standard.
console.log(performance.memory);
// Works only when the page is cross-origin isolated.
if (crossOriginIsolated && "measureUserAgentSpecificMemory" in performance) {
const result = await performance.measureUserAgentSpecificMemory();
console.log(result.bytes);
}Common misconceptions
Each row has a wrong idea on the left and what is really true on the right.
| The wrong idea | What is true |
|---|---|
Setting a variable to null frees the memory right away. | It removes one arrow. Other arrows may still point to the object, and the engine decides when to clean up. |
| Objects that point at each other always leak. | Reference counting can't clean them up, but mark and sweep can, when no root reaches them. |
| Closures are leaks. | Closures are normal. A leak is a long-lived function that keeps data you no longer need. |
performance.memory works the same in every browser. | It works only in Chrome and isn't part of the web standard. Don't build app code on it. |
| A removed page element is gone. | It is gone from the page, but if JavaScript still points to it, it is kept. |
"If I set it to null, the memory is gone."
You removed one arrow. Something else may still point to the object. And the engine still decides when to clean up.
"Objects that point at each other always leak."
Mark and sweep cleans up such a cycle when no root can reach it. A cycle leaks only when a root still reaches it.
"Closures are bad for memory."
Closures are normal. The problem is a function that lives a long time and keeps data it doesn't need.
"DevTools numbers prove the exact moment one object was cleaned up."
Use snapshots, comparisons, and recordings to find growth and the arrows that keep objects. Don't write app code that depends on the exact clean-up moment.
Practice: start from the roots
5 EXERCISESType exactly what the program prints.
function openPreview() {
let preview = { title: "Cart", rows: ["hat", "bag"] };
const label = preview.title.toUpperCase();
preview.rows.push("coat");
preview = null;
return label;
}
console.log(openPreview());It prints CART. Line 3 makes the string "CART" and keeps it in label. Line 5 removes only the preview arrow. The function then returns label.
Which object names does the sweep return?
function markAndSweep(heap, roots) {
const marked = new Set();
const worklist = [...roots];
while (worklist.length > 0) {
const id = worklist.pop();
if (!id || !heap[id] || marked.has(id)) continue;
marked.add(id);
for (const ref of heap[id].refs) worklist.push(ref);
}
return Object.keys(heap).filter((id) => !marked.has(id));
}
const heap = {
app: { refs: ["modal"] },
modal: { refs: [] },
cycleA: { refs: ["cycleB"] },
cycleB: { refs: ["cycleA"] },
};
console.log(markAndSweep(heap, ["app"]).join(", "));It prints cycleA, cycleB. app reaches modal, so both get marked. Nothing from app reaches cycleA or cycleB, so they are never marked and get swept.
Name one way to clean up, so the listener stops keeping rows.
function mount(button, rows) {
function render() {
console.log(rows.length);
}
button.addEventListener("click", render);
return () => {
// add cleanup here
};
}button.removeEventListener("click", render);
// or make an AbortController and pass { signal } when addingThe clean-up must remove the listener. Then the button no longer holds render, and render no longer keeps rows.
Which call stops this timer?
const id = setInterval(() => {
console.log("tick");
}, 1000);
// write the cleanup callclearInterval(id);clearInterval(id) stops the timer. The browser drops the function, so the function stops keeping anything it uses.
After card.remove(), what does document.contains(card) print?
const retained = [];
const card = document.querySelector("#leak-card");
retained.push(card);
card.remove();
console.log(document.contains(card));
console.log(retained.length);document.contains(card) prints false, because the card is off the page. The next line prints 1, because the array still holds the card.
Quiz: memory and leaks
8 QUESTIONSQuestion 1 of 8Which sentence best describes memory management in JavaScript?
Choose an answer to see the explanation.
Question 2 of 8What does this function print?
Read the code, then predictfunction openPreview() { let preview = { title: "Cart", rows: ["hat", "bag"] }; const label = preview.title.toUpperCase(); preview.rows.push("coat"); preview = null; return label; } console.log(openPreview());Choose an answer to see the explanation.
Question 3 of 8Which of these are roots, the starting points that are always kept?
Choose an answer to see the explanation.
Question 4 of 8What does the sweep print?
Read the code, then predictfunction markAndSweep(heap, roots) { const marked = new Set(); const worklist = [...roots]; while (worklist.length > 0) { const id = worklist.pop(); if (!id || !heap[id] || marked.has(id)) continue; marked.add(id); for (const ref of heap[id].refs) worklist.push(ref); } return Object.keys(heap).filter((id) => !marked.has(id)); } const heap = { app: { refs: ["modal"] }, modal: { refs: [] }, cycleA: { refs: ["cycleB"] }, cycleB: { refs: ["cycleA"] }, }; console.log(markAndSweep(heap, ["app"]).join(", "));Choose an answer to see the explanation.
Question 5 of 8Why did old reference-counting collectors fail with objects that point at each other?
Choose an answer to see the explanation.
Question 6 of 8Which clean-up stops a
windowlistener from keeping its function and data?Choose an answer to see the explanation.
Question 7 of 8A timer function would print
rows.length. What does this code print?Read the code, then predictconst rows = ["Ada", "Lin", "Grace"]; console.log(rows.length);Choose an answer to see the explanation.
Question 8 of 8A page element was removed from the page, but an array still holds it. What is true?
Choose an answer to see the explanation.
Key takeaways
- JavaScript finds memory for objects as your code runs. Later, it cleans up the ones nothing can reach.
- Reachability starts at roots: global variables, running functions, and things the browser keeps, like listeners, timers, and the page itself.
- Mark and sweep cleans up objects that point at each other, as long as no root reaches them.
- Leaks usually come from things that live a long time: listeners, timers, closures, caches that only grow, and removed page elements you still keep.
- Use the Chrome DevTools Memory panel to look for leaks. Don't write code that depends on when clean-up happens.
One rule to keep:
If a root can still reach an object, JavaScript must keep it. If nothing can reach it, it may be cleaned up later, not right away.
Up next: WeakRef & FinalizationRegistry. There, the same warning matters most: you can't know when clean-up happens.