Finding leaks in web apps
Find memory leaks in a single-page app: a round-trip leak test, two heap snapshots compared, detached DOM nodes traced, and an automated check, in Chrome.
- 01Run a leak test on route changesRepeat one round trip, collect garbage, and read how much each trip leaves behind, after a warm-up.
- 02Read two heap snapshots side by sideUse the Comparison view, and spot the rows that grow by the same number of objects on every trip.
- 03Find what holds a detached page, and automate the checkFollow a detached page's retainers back to your code, then let a test or MemLab catch the leak before release.
What does each trip to a tea page leave behind?
Ben is back at the Leaf and Kettle, on the cheap phone from lesson 29.1. Tonight he wants a new tea. He opens Mint tea and reads the reviews. He goes back to the list. Then he opens Green tea, then Earl Grey. In the end he looks at twenty teas, one after another.
The shop is a single-page app. The browser loads one page, and the shop's JavaScript changes what it shows. When Ben taps a tea, no new page loads. The code changes the address with history.pushState(), builds Mint tea's page and puts it on the screen. Each screen inside the app is called a route: the tea list is one, and each tea page is another. This lesson calls one visit to a tea page and back a round trip.
Ravi, who builds the shop, wonders what happens to Mint tea's page when Ben goes back. It's gone from the screen. But is it gone from memory? If not, every round trip leaves a page behind. So Ravi has two questions. What does each round trip leave behind? And how can he catch it before the next release?
The course made Ben's twenty round trips in Chrome, on the shop as it was and on a fixed version. After some trips, it counted the DOM nodes in the tab. A DOM node is one piece of the page: an element, like a heading, or a run of text. Here is what Ravi saw:
The shaded bars climb on every count. The plain bars never move. This code works out the climb:
// DOM nodes in Ben's tab after 0, 1, 5, 10 and 20 round trips, from the recordingconst leaky = [162, 300, 852, 1543, 2922];const fixed = [162, 162, 162, 162, 162]; const perTrip = (nodes) => (nodes[4] - nodes[0]) / 20;console.log(`Leaky: ${perTrip(leaky)} nodes left behind per round trip`);console.log(`Fixed: ${perTrip(fixed)} nodes left behind per round trip`);- Line 1 is a comment. It says where the numbers come from.
- Lines 2 and 3 hold the counts after 0, 1, 5, 10 and 20 round trips.
leakyis the shop as it was.fixedis the shop after Ravi's fix. - Line 5 takes the last count, subtracts the first, and divides by the 20 trips.
- Lines 6 and 7 print
Leaky: 138 nodes left behind per round tripandFixed: 0 nodes left behind per round trip.
Every trip on the leaky shop kept 138 nodes. That's a whole tea page: its heading, its buttons and its 15 reviews. Ben can't see these pages, but they are still in memory. Why it matters: on a phone with little memory, a tab that keeps growing is more likely to be thrown away, like Ben's cart in lesson 29.1, or to crash.
Did Ben feel it? Not yet. On trip 2, the tea page was drawn about 9.4 ms after his tap. On trip 20, it took about 9.1 ms. Meta's engineers, who built a leak finder called MemLab, say a leak "isn't immediately perceivable". It takes a little memory at a time. So a quick check of speed won't find it. You have to look for it.
At a concert hall, each guest hands a coat to the cloakroom. When guests leave, they take their coats, and the hooks are free again. Now say the staff forget to hand the coats back. The guests go home, but the coats stay. The hall looks empty. Yet the rack fills up, one coat per guest.
- In real life: A guest at the concert
- In JavaScript: Ben looking at one tea page
- In real life: The guest's coat on its hook
- In JavaScript: The tea page, in memory
- In real life: Taking the coat on the way out
- In JavaScript: Freeing the page when Ben goes back
- In real life: A rack that fills by one coat per guest
- In JavaScript: Memory that grows by one page per round trip
A round trip should leave nothing behind. Repeat the same trip, clean up memory, and count what's left. If the count climbs by the same amount every trip, something keeps every page. The sections below turn this into steps: a leak test, two snapshots side by side, the pages that stay and what holds them, and a test that runs by itself.
How the course recorded it
- The shop: a single-page app with 20 teas. Each tea page shows 15 of its 200 reviews, an Add to cart button and a free-delivery countdown.
- Three versions: the leaky shop, Ravi's first fix with one leak left, and the fixed shop. Only the code that opens a tea page changes.
- Ben's phone: this computer in Chrome 157, with its processor slowed 4 times.
- Real taps: mouse clicks sent through Chrome's DevTools protocol, the commands DevTools itself uses to control a page. Chrome passes these clicks straight to the page. Each round trip opens a different tea, in the list's order.
- Counting: after 0, 1, 5, 10 and 20 trips, the course cleaned up memory and read the counters. Then it took a heap snapshot: a list of every object the page still holds. MemLab 2.0.6 read the snapshots later.
A leak test for route changes
A leak test repeats one round trip and counts what's left. Three rules keep the numbers fair:
- The same trip, the same place. Start on the list, open a tea, come back. Count only when you're back on the list.
- Clean up first. The browser frees memory when it decides to. So just before you count, ask for a garbage collection: a clean-up that frees everything the page can't reach any more.
- Warm up. Make a few trips before the first count. The lab below shows why.
You want to know if you're putting on weight. So you weigh yourself at the same time each morning, before breakfast, on the same scale. One heavy morning means little. But a kilo more every week, week after week, is worth a look.
- In real life: The same time each morning
- In JavaScript: The same point in the trip: back on the list
- In real life: Before breakfast
- In JavaScript: After a garbage collection
- In real life: One heavy morning
- In JavaScript: One count that was a little high
- In real life: A kilo more every week
- In JavaScript: The same nodes left behind on every trip
You can do this by hand in DevTools. The Memory panel has a Collect garbage button. The Performance monitor shows live counts, among them the JS heap size, the DOM nodes and the JS event listeners. The JS heap is the memory that holds the page's JavaScript objects.
A script can press the same buttons. DevTools talks to the page through the DevTools protocol: a list of commands, like HeapProfiler.collectGarbage. Tools like Playwright let a script send them too. Here is how the course counted:
async function measure(cdp) { await cdp.send("HeapProfiler.collectGarbage"); const { usedSize } = await cdp.send("Runtime.getHeapUsage"); const { nodes, jsEventListeners } = await cdp.send("Memory.getDOMCounters"); return { heap: usedSize, nodes, listeners: jsEventListeners };}- Line 1 starts
measure.cdpis a connection to the page's DevTools protocol. - Line 2 asks for a garbage collection. DevTools sends the same command when you click Collect garbage.
- Line 3 reads
usedSize: the bytes in the JS heap. The Performance monitor's JS heap size is the same number. - Line 4 reads
nodesandjsEventListeners: the DOM nodes alive in the tab, and the event listeners. These are the monitor's DOM nodes and JS event listeners. - Line 5 gives the three numbers back.
The clean-up waits until no JavaScript is running. Then V8, Chrome's JavaScript engine, runs a full garbage collection. So a count never catches the shop halfway through its work. Pick a number of round trips to see the counts:
- JS heap, MB
1.241.260.80- DOM nodes
2,9222,922162- JS event listeners
64444
Each count was read right after a garbage collection, back on the tea list. JS heap is in MB, 1,000,000 bytes, as DevTools writes it.
First, the leaky shop grew on every count. After 20 trips it held 2,922 DOM nodes and 1.24 MB of JS heap. The fixed shop held 162 nodes from start to finish.
Second, look at One left. It's Ravi's first fix. He fixed three of the four things that kept each page. Yet its DOM nodes grew just as fast: 2,922 after 20 trips. The section on detached pages explains why.
Third, the fixed shop's heap grew too, at first. It went from 0.69 MB to 0.80 MB in five trips. Then it stopped: 0.80 MB after 10 trips, and 0.80 MB after 20. The first trips run the shop's code for the first time. V8 compiles that code and keeps it. Why it matters: count from after a warm-up, or a test will blame a healthy page.
Comparing two heap snapshots
The counters say that something grows. They don't say what. For that, you need a heap snapshot: a list of every object the page can still reach, taken right after a garbage collection. The JavaScript course's Finding memory leaks shows how to take one and read it. Here, we put two of Ben's side by side.
A tea shop counts its stock every evening. One week there are four jars of mint tea. A week later there are eleven, and nobody ordered any. Something adds a jar a day. To find it, you don't look at every jar. You look at what changed between the two counts.
- In real life: The evening count
- In JavaScript: A heap snapshot
- In real life: The stock list, item by item
- In JavaScript: The Summary view: one row for each kind of object
- In real life: What changed between two counts
- In JavaScript: The Comparison view
- In real life: One more jar every day
- In JavaScript: One more object of a kind on every trip
In DevTools, take a snapshot after one round trip, and another after 20. With the second one open, pick Comparison in the menu above the table. Each row is one kind of object. # New counts objects that weren't in the first snapshot. # Deleted counts the ones that went. # Delta is new minus deleted. DevTools tells objects apart by their @ number, an ID each object keeps from one snapshot to the next.
The course compared its two snapshots the same way. Read the table across: each row is one kind of object, in each shop.
| Row in the Comparison view | The leaky shop | The fixed shop |
|---|---|---|
{name, stars, text} | Grew by 3,800: 200 per trip. | No row: nothing new, nothing gone. |
Detached <li> | Grew by 285: 15 per trip. | No row: nothing new, nothing gone. |
Detached <article> | Grew by 19: 1 per trip. | No row: nothing new, nothing gone. |
NavigationHistoryEntry | Grew by 38: 2 per trip. | Grew by 38: 2 per trip. |
(compiled code) | Grew by 142: 7.5 per trip. | Grew by 136: 7.2 per trip. |
Most of the leaky shop's deltas are multiples of 19, the number of trips between the two snapshots. Divide, and the pattern shows:
// Rows of the Comparison view: the snapshot after 20 round trips against the one after 1const rows = { "{name, stars, text}": 3800, "Detached <li>": 285, "NavigationHistoryEntry": 38, "(compiled code)": 142 };const trips = 19;for (const [name, delta] of Object.entries(rows)) { console.log(`${name}: ${+(delta / trips).toFixed(1)} per trip`);}- Line 1 is a comment. It says which two snapshots the rows compare.
- Line 2 holds four rows of the leaky shop's Comparison view, each with its delta.
- Line 3 is the number of trips between the two snapshots.
- Lines 4 to 6 divide each delta by the trips and print it. The first two lines are
{name, stars, text}: 200 per tripandDetached <li>: 15 per trip. The last two areNavigationHistoryEntry: 2 per tripand(compiled code): 7.5 per trip.
Each row tells part of the story:
{name, stars, text}is one review. DevTools names plain objects after their properties. Each tea has 200 reviews. So each trip kept all of one tea's reviews.Detached <li>is a list item that's no longer in the page. Each tea page shows 15 reviews. So each trip kept one page's list.NavigationHistoryEntryis the browser's record of one step in the tab's history. Each click added one. The fixed shop grew it the same way, so it's the browser's, not a leak.(compiled code)is V8's code for the shop's functions. It grew by about as much in the fixed shop.
Why it matters: a leak grows by a page's worth of objects on every trip. Look for rows whose delta is the trips times a number from your own page, like 15 reviews shown or 200 loaded. Then ignore rows that grow the same way in the fixed version. Try each shop here:
(concatenated string)+15,200800{name, stars, text}+3,800200Detached Text+1,29268Detached <p>+34218(string)+30315.9Detached <li>+28515Detached <span>+28515Detached <strong>+28515
Delta is new objects minus objects gone, over 19 round trips. Per trip divides it by 19. A whole page left behind shows up as rows that grow by a page's worth on every trip.
Detached tea pages, and what holds them
Many of the growing rows start with "Detached". A detached DOM node is a node that was taken out of the page, but that some JavaScript still points at. Chrome's docs call them "a common cause of memory leaks". When Ben goes back, the shop takes Mint tea's page off the screen. If anything still points at it, it stays in memory, detached.
You tear a page out of your notebook and pin it to the noticeboard with a clip. The cleaner throws away loose paper, but never anything that's clipped. If the page has two clips, taking one off isn't enough. It stays until the last clip is gone.
- In real life: The notebook
- In JavaScript: The page Ben sees: the DOM tree
- In real life: The torn-out page
- In JavaScript: A detached tea page
- In real life: A clip on the board
- In JavaScript: A reference from JavaScript, like a listener or a Map
- In real life: The cleaner
- In JavaScript: The garbage collector
In the Memory panel, type Detached into the Filter by class box above the table. Only the detached rows stay. The leaky shop's snapshots showed this:
- After 1 trip: 1 detached
<article>, the tea page's outer element, and 157 objects in the Detached rows. - After 20 trips: 20 detached
<article>elements, and 3,233 objects. - The fixed shop: no Detached rows at all.
One <article> for every trip: every tea page Ben opened is still there. But which code keeps them? Click one, and DevTools fills the Retainers section below the table. A retainer is an object that points at the one you picked, and so keeps it alive. The course read the retainers of Mint tea's page, the first one Ben opened, after all 20 trips:
Leave out the page's own elements and their listeners, and two things point at it from outside. The first is a Map called pages. The second is a context: a store of variables. When a function uses variables from the function around it, V8 keeps them in a context. Three functions share this one: onResize, tick and onCartChange. And each of them is held by something that lives as long as the tab: the window's listeners, the browser's list of timers, and the cart's list of watchers.
Now read the code that opens a tea page. With the four names from the snapshot, each leak is easy to spot:
const pages = new Map(); // tea pages Ben has opened, to show them faster next timefunction openTea(tea) { const page = pages.get(tea.id) ?? teaPage(tea); pages.set(tea.id, page); window.addEventListener("resize", function onResize() { setColumns(page); }); setInterval(function tick() { showCountdown(page); }, 1000); watchCart(function onCartChange() { showInCart(page, tea); }); app.replaceChildren(page);}- Line 1 makes
pages, a Map that keeps each tea page "to show them faster next time". Nothing ever deletes from it. - Line 3 takes the page from the Map, or builds a new one. Line 4 stores it.
- Line 5 adds
onResizeto the window's resize listeners. It usespage, so now the window holds the page. - Line 6 starts a timer that runs
tickevery second, forever.tickusespagetoo. - Line 7 gives the cart
onCartChange, to update the Add button. The cart keeps it incart.watchers. - Line 8 puts the page on the screen.
Lines 4 to 7 are four clips. Here is the fixed version. It returns a function that removes them:
function openTea(tea) { const page = teaPage(tea); const stop = new AbortController(); window.addEventListener("resize", function onResize() { setColumns(page); }, { signal: stop.signal }); const timer = setInterval(function tick() { showCountdown(page); }, 1000); const unwatch = watchCart(function onCartChange() { showInCart(page, tea); }); app.replaceChildren(page); return function leave() { stop.abort(); clearInterval(timer); unwatch(); };}- Line 2 always builds a new page. There is no Map.
- Line 3 makes an
AbortController. Line 4 passes itssignalwith the listener. When the controller'sabort()runs, the browser removes the listener. - Line 5 keeps the timer's number, so that line 10 can stop it with
clearInterval. - Line 6 keeps the function that removes the watcher. Line 11 calls it.
- Lines 8 to 12 return
leave, which does all three clean-ups.
The shop's router calls leave before it shows the next route:
let leave = null;function route() { leave?.(); const id = new URLSearchParams(location.search).get("tea"); const tea = teas.find((item) => item.id === id); leave = tea ? openTea(tea) : openList();}- Line 1 starts with nothing to clean up.
- Line 3 calls the last page's
leave.?.()calls it only if it's there. - Lines 4 and 5 find the tea in the address. Line 6 shows the next page and keeps its new
leave.
The JavaScript course's Memory in large apps explains this clean-up pattern. The system design course's Memory in long-lived apps shows the same leaks in React effects and in requests still in flight. Here, the question is how you prove that a fix works.
Ravi's first fix left out line 10. He removed the Map, the listener and the watcher, but not the timer. The lab showed the result: 2,922 DOM nodes after 20 trips, just like the leaky shop. The timer alone kept every page, because tick still uses the shared context. Why it matters: a page stays until its last clip is gone, so test again after every fix. In the Retainers section, you can also right-click a retainer and pick Ignore this retainer. Then DevTools shows you whether anything else still holds the page.
A leak test that runs before every release
Ravi could repeat all this by hand before each release. But people forget, and leaks come back: someone adds a listener and never removes it. So Ravi wants a test that runs on every change, like his other tests.
A smoke alarm doesn't know where the fire is. It only knows there's smoke, and it makes a loud noise. It checks all day, even when nobody is watching. When it goes off, you go and find the fire.
- In real life: The alarm checking all day
- In JavaScript: A leak test on every change
- In real life: Smoke
- In JavaScript: Nodes or memory that each round trip leaves behind
- In real life: The alarm going off
- In JavaScript: The test failing
- In real life: Finding the fire
- In JavaScript: Reading the retainers, or MemLab's path
Here is the course's test. It uses Playwright, a tool that drives a browser from code, and measure() from earlier:
async function leakTest(page) { const cdp = await page.context().newCDPSession(page); const roundTrip = async (trip) => { await page.locator(".tea-list a").nth(trip).click(); await page.locator(".back").click(); }; for (let trip = 0; trip < 5; trip++) await roundTrip(trip); // warm up const before = await measure(cdp); for (let trip = 5; trip < 20; trip++) await roundTrip(trip); const after = await measure(cdp); const nodes = (after.nodes - before.nodes) / 15; const kB = (after.heap - before.heap) / 15 / 1000; if (nodes > 1 || kB > 5) { throw new Error(`Each round trip left ${nodes} DOM nodes and ${kB.toFixed(1)} kB behind`); }}- Line 2 connects to the page's DevTools protocol.
- Lines 3 to 6 are one round trip: click the tea at position
tripin the list, then "← All teas". Each trip opens a different tea, as Ben did. - Line 7 warms up with 5 round trips.
- Line 8 counts. Line 9 makes 15 more round trips. Line 10 counts again.
- Lines 11 and 12 work out what each trip left behind: DOM nodes, and kilobytes (kB) of JS heap. A kB is 1,000 bytes.
- Lines 13 to 15 fail the test if a trip left more than one node, or more than 5 kB.
The course's recording took the same counts, after 5 and after 20 trips. On the leaky shop, the test fails with Each round trip left 138 DOM nodes and 22.7 kB behind. The One left shop fails too: Each round trip left 138 DOM nodes and 22.9 kB behind. The fixed shop passes, with 0 nodes and 0.4 kB per trip.
Why those limits? One leaked tea page was 138 nodes and about 22.7 kB. The fixed shop left no nodes and under half a kB. A limit in between catches a page left behind, but not the small changes. Now try a shorter or longer warm-up:
- Leaky: fails
13822.7- One left: fails
13822.9- Fixed: passes
00.4
The test fails when a round trip leaves more than 1 DOM node, or more than 5 kB of JS heap. A kB is 1,000 bytes.
With no warm-up, even the fixed shop fails: the first trips add 5.5 kB each. That's a false alarm. Why it matters: a test that fails for no reason soon gets switched off. Warm up first.
Letting MemLab find the leak
Meta built MemLab to run this kind of test. It finds leaks in three heap snapshots, each taken at one step:
- baseline: the page before the action. Here, the tea list.
- target: after the action. Here, Mint tea's page.
- final: after undoing the action. Here, back on the list.
An object is a suspect if it's new in the target and still there in the final snapshot. Then MemLab keeps only the suspects its rules call leaks, like detached DOM elements.
You tell MemLab the three steps in a small file called a scenario:
// tea-page.js: run it with memlab run --scenario tea-page.js
module.exports = {
url: () => "https://leafandkettle.example/teas",
action: async (page) => await page.click(".tea-list a"),
back: async (page) => await page.click(".back"),
};- Line 1 is a comment with the command that runs the file.
- Line 3,
url, is where the test starts. - Line 4,
action, clicks the first tea. Line 5,back, clicks "← All teas".
With memlab run, MemLab starts its own browser and takes the snapshots. The course kept to one browser for the whole recording instead. It took the three snapshots itself and gave them to memlab find-leaks. MemLab's docs say you can do this: take the snapshots yourself, then hand them over.
On the leaky shop, MemLab found 1 leak: 27 leaked objects with the same kind of path, 36KB in all. It printed one path for them, from the window to the page:
[Window / http://shop-leaky.localhost] (native) @33973 [10.9KB]
--native_context (shortcut)---> [system / NativeContext / http://shop-leaky.localhost] (native) @7799 [59.6KB]
--script_context_table (internal)---> [system / ScriptContextTable] (native) @34303 [376 bytes]
--3 (hidden)---> [system / Context / scope (object) @42633 [3KB]
--pages (variable)---> [Map] (object) @51515 [92 bytes]
--table (internal)---> [<array>] (array) @54113 [76 bytes]
--6 (internal)---> [Detached <article class="tea-page" data-ends="…"> (native) @66279 [36KB]- Line 1 is the window, where the path starts.
- Lines 2 to 4 are how V8 reaches the variables the shop's script made.
- Line 5 is the
pagesvariable: the Map. Line 6 is the Map's storage inside. - Line 7 is Mint tea's page, detached. The course cut the test server's port and the page's countdown time from the lines.
On the One left shop, MemLab's path went another way: from the window, through a DOMTimer, to tick, and on to the page. On the fixed shop, it found 0 leaks. Why it matters: MemLab doesn't only say that something leaks. It points at the code that holds it. Meta runs it "at regular intervals throughout the day". Its team says this work helped cut out-of-memory crashes on Facebook.com by 50 percent in the first half of 2021.
Leak, warm-up, or the browser's own records?
Each line below is something you might see in a leak test or a comparison. Sort it by what it means:
Detached <li>grows by 15 on every round trip{name, stars, text}grows by 200 on every round trip- JS event listeners grow by 3 on every round trip
- The fixed shop's JS heap grows in the first five trips, then stays flat
- The first tea page takes several times as long to show as the later ones
NavigationHistoryEntrygrows by 2 per round trip, in the fixed shop tooPerformanceEventTiminggrows by 2 per round trip in every version
Sort each finding by what it means.
Common mix-ups
- "The clicks aren't slower, so nothing leaks." No. Ben's tea page came as quickly on trip 20 as on trip 2, while the shop kept 2,922 DOM nodes.
- "Removing most of the references frees most of the memory." No. Ravi removed three of the four, and every page stayed. One reference keeps the whole page.
- "Every row that grows in the Comparison view is my leak." No. History entries grew by 2 per trip in the fixed shop too. Look for rows that grow by a page's worth.
- "Count from the very first trip." No. Measured that way, even the fixed shop grew 5.5 kB a trip. Warm up first.
- "A heap snapshot shows what's on the screen." No. It shows everything the page can still reach, including pages Ben left long ago.
- "MemLab reports every object that stayed." No. It drops what the baseline already had, then keeps what its rules flag, like detached DOM elements.
Practice exercises
Answer first, then use the hints.
On the One left shop, how many JS event listeners did each round trip add, after the warm-up? What does this print?
// JS event listeners on the One left shop after 0, 1, 5, 10 and 20 round trips, from the recording
const listeners = [4, 6, 14, 24, 44];
console.log((listeners[4] - listeners[2]) / 15);2. Each kept page has two listeners on its own buttons, Add to cart and Show all reviews. The fix removed the window's resize listener, so that one no longer grows.
This is the leak test's check, on the fixed shop from 10 to 20 round trips. What does it print?
const before = { nodes: 162, heap: 803300 }; // the fixed shop after 10 round trips
const after = { nodes: 162, heap: 803628 }; // and after 20
const nodes = (after.nodes - before.nodes) / 10;
const kB = (after.heap - before.heap) / 10 / 1000;
console.log(nodes > 1 || kB > 5 ? `Each round trip left ${nodes} DOM nodes and ${kB.toFixed(1)} kB behind` : "Pass");Pass. Between 10 and 20 trips, the fixed shop kept no nodes, and its heap grew by only a few dozen bytes a trip.
In the warm-up playground, with 1 warm-up trip, how many kB did each round trip add on the fixed shop?
3.1. One warm-up trip is better than none, but the fixed shop was still settling. After five, it was under half a kB.
Ravi adds a keyboard shortcut: press A to add the tea. Which line fails to clean up, so every tea page stays in memory?
function openTea(tea) {
const page = teaPage(tea);
const onKey = (event) => { if (event.key === "a") addToCart(tea); };
document.addEventListener("keydown", onKey);
const timer = setInterval(() => showCountdown(page), 1000);
app.replaceChildren(page);
return function leave() {
clearInterval(timer);
document.removeEventListener("keydown", (event) => onKey(event));
};
}document.removeEventListener("keydown", onKey);Line 9. It passes a new function, so the browser finds nothing to remove, and onKey stays on the document. onKey shares openTea's context with the timer's function, so it keeps page too, and every tea page stays.
Open the course's copy of the leaky shop in Chrome, with DevTools on the Memory panel. Make three round trips to three different teas. How many Detached <li> objects does the snapshot hold?
45: 15 review items on each of the three tea pages, all still kept by the leaky shop.
MemLab saves its results in a folder. Which file holds the text summary of the memory leaks it found?
leaks.txt, in data/cur: the text summary of the clustered memory leaks. A CI job can read it or attach it to a report.
Check your understanding
For each question, ask which step it's about: counting, comparing, the retainers, or the test.
Question 1 of 7What does this warm-up check print?
Read the code, then predictconst heap = [694256, 745364, 797980, 803300, 803628]; // the fixed shop after 0, 1, 5, 10 and 20 round trips const kB = (from, to, trips) => ((heap[to] - heap[from]) / trips / 1000).toFixed(1); console.log(kB(0, 4, 20), kB(2, 4, 15));Choose an answer to see the explanation.
Question 2 of 7Why does the leak test collect garbage before it reads the counters?
Choose an answer to see the explanation.
Question 3 of 7Ravi removed three of the four things that held each tea page. What happened to the DOM node count?
Choose an answer to see the explanation.
Question 4 of 7In the Comparison view, a row grows by two objects per round trip in every version of the shop, the fixed one too. What is it most likely?
Choose an answer to see the explanation.
Question 5 of 7What does MemLab report as a leak?
Choose an answer to see the explanation.
Question 6 of 7Ben's clicks were no slower after twenty round trips on the leaky shop. Why test for leaks anyway?
Choose an answer to see the explanation.
Question 7 of 7What is a detached DOM node?
Choose an answer to see the explanation.
Key takeaways
- A round trip should leave nothing behind. Repeat it, collect garbage, and count after a warm-up.
- Read the counts per trip. The leaky shop kept a whole tea page on every trip; the fixed shop kept nothing.
- In the Comparison view, a leak grows by the trips times a number from your page. The browser's own rows grow in every version.
- Type Detached in the class filter, then follow the retainers to your code. One retainer is enough to keep a whole page.
- Automate it: a script with limits fails the build, and MemLab's path names what holds the page.
Remember the one-liner.
Repeat the trip, clean up, count: whatever every trip leaves behind is the leak.
Sources
The facts on this page come from these docs and source files, and from the course's recordings:
- Fix memory problems, Chrome DevTools docs.
- MemLab: An open source framework for finding JavaScript memory leaks, Meta Engineering.
- GCActionDelegate.ts: what the Collect garbage button calls, Chrome DevTools source, as Chrome 157 ships it.
- v8-heap-profiler-agent-impl.cc: what HeapProfiler.collectGarbage does, V8 source, as Chrome 157 ships it.
- js_protocol.pdl: the HeapProfiler and Runtime domains, V8 source, as Chrome 157 ships it.
- Memory.pdl: Memory.getDOMCounters, Chromium source.
- inspector_memory_agent.cc: what getDOMCounters counts, Chromium source.
- PerformanceMonitor.ts: the Performance monitor's charts, Chrome DevTools source, as Chrome 157 ships it.
- Record heap snapshots, Chrome DevTools docs.
- HeapSnapshot.ts: how the Memory panel names and compares objects, Chrome DevTools source, as Chrome 157 ships it.
- EventTarget: addEventListener() method, the signal option, MDN.
- How memlab works, MemLab docs, version 2.0.6.
- LeakFilterRuleList.ts: what MemLab counts as a leak, MemLab source, version 2.0.6.
- Command line interface: memlab find-leaks, MemLab docs, version 2.0.6.
- Getting started: a test scenario, MemLab docs, version 2.0.6.
- Set up continuous test, MemLab docs, version 2.0.6.
Coming next: Measuring memory in the field, in Responsiveness: INP. Ravi measures memory on real visitors' devices, and sets a memory budget for the shop.