completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

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.

By the end, you can
  • 01
    Run a leak test on route changesRepeat one round trip, collect garbage, and read how much each trip leaves behind, after a warm-up.
  • 02
    Read two heap snapshots side by sideUse the Comparison view, and spot the rows that grow by the same number of objects on every trip.
  • 03
    Find 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:

One round trip on the Leaf and Kettle shop, in real frames: the tea list, then Mint tea's page, then back to the list. DOM nodes after 0, 1, 5, 10, 20 round trips: the leaky shop 162, 300, 852, 1,543, 2,922; the fixed shop 162, 162, 162, 162, 162.the lista tea pageThe Leaf and Kettle's list of 20 teasMint tea's page, with its reviewsone round tripDOM nodesleakyfixed1620300185251,543102,92220round trips
Left: one round trip, in real frames from the recording. Right: the DOM nodes in Ben's tab after each checkpoint, counted after a clean-up. Shaded bars: the leaky shop. Plain bars: the fixed shop.

The shaded bars climb on every count. The plain bars never move. This code works out the climb:

Nodes left behind by each round tripPop out in the code editor (opens in a new tab)JavaScript
// 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. leaky is the shop as it was. fixed is 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 trip and Fixed: 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.

Real-life analogyA cloakroom that never gives coats back

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
The main idea

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.
Real-life analogyWeighing yourself every morning

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:

Clean up, then countJavaScript
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. cdp is 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 nodes and jsEventListeners: 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:

Playground: the counters after each round trip
20 round tripsLeaky · One left · Fixed
JS heap, MB
1.241.260.80
DOM nodes
2,9222,922162
JS event listeners
64444
Try it yourself
After 20 round trips: 2,922 DOM nodes on the leaky shop, 162 on the fixed one

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.

From the course's recording in Chrome 157, with the CPU slowed 4 times.

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.

Real-life analogyCounting the stock twice

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.

Snapshot after 20 round trips, against the one after 1
Row in the Comparison viewThe leaky shopThe 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.
NavigationHistoryEntryGrew 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:

What each round trip addedPop out in the code editor (opens in a new tab)JavaScript
// 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 trip and Detached <li>: 15 per trip. The last two are NavigationHistoryEntry: 2 per trip and (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.
  • NavigationHistoryEntry is 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:

Playground: what grew between the two snapshots
ClassDelta · per trip
(concatenated string)
+15,200800
{name, stars, text}
+3,800200
Detached Text
+1,29268
Detached <p>
+34218
(string)
+30315.9
Detached <li>
+28515
Detached <span>
+28515
Detached <strong>
+28515
Try it yourself
Leaky: the rows that grew the most

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.

From the course's recording in Chrome 157, with the CPU slowed 4 times.

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.

Real-life analogyA torn-out page, still clipped to the board

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:

What kept Mint tea's page in memory after 20 round trips on the leaky shop, from the heap snapshot. The pages Map holds it directly. openTea's variables hold it too, and three functions share those variables: onResize, held by the window's listeners; tick, held by the browser's list of timers; and onCartChange, held by cart.watchers.lives as long as the tabfunctionsopenTea'svariablesMint tea's page,detachedwindow listenersonResizethe timer listtickcart.watchersonCartChangepageMint tea's page, detached: no longer on the screen, still in memorypages (a Map)one entry per tea page
From the snapshot after 20 trips. Each arrow points from what holds to what is held. Any one of the four paths is enough to keep the whole page.

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:

openTea, the leaky versionJavaScript
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 onResize to the window's resize listeners. It uses page, so now the window holds the page.
  • Line 6 starts a timer that runs tick every second, forever. tick uses page too.
  • Line 7 gives the cart onCartChange, to update the Add button. The cart keeps it in cart.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:

openTea, the fixed versionJavaScript
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 its signal with the listener. When the controller's abort() 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:

The router lets the last page clean upJavaScript
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.

Real-life analogyA smoke alarm

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:

A leak test for the tea pagesJavaScript
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 trip in 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:

Playground: the leak test with a longer or shorter warm-up
ShopNodes · kB per trip
Leaky: fails
13822.7
One left: fails
13822.9
Fixed: passes
00.4
Try it yourself
Warm up with 5, then measure 15 round trips: 2 of 3 shops fail

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.

From the course's recording in Chrome 157, with the CPU slowed 4 times.

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.

MemLab's three heap snapshots, in real frames: the baseline on the tea list, the target on Mint tea's page, and the final one back on the list. A leak is an object new in the target and still there in the final snapshot. MemLab found 1 leak on the leaky shop, 27 objects, and 0 leaks on the fixed shop.baselinethe listThe baseline snapshot: the listtargetMint tea's pageThe target snapshot: Mint tea's pagefinalback on the listThe final snapshot: back on the listopen a teago backa leak: new in the target, still there in the final snapshotleaky: 1 leak, 27 objectsfixed: 0 leaks
The course's first three snapshots of each shop, as MemLab read them. It keeps what is new in the target and still there in the final snapshot, then only what its rules flag, like detached DOM elements.

You tell MemLab the three steps in a small file called a scenario:

A MemLab scenario for one round tripJavaScript
// 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:

MemLab's path to Mint tea's page, shortenedText
[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 pages variable: 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:

Leak, warm-up, or the browser's own records?
  • 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
  • NavigationHistoryEntry grows by 2 per round trip, in the fixed shop too
  • PerformanceEventTiming grows by 2 per round trip in every version
Try it yourself
0 of 7 correct

Sort each finding by what it means.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Exercise 1 · Warm-upListeners per trip

On the One left shop, how many JS event listeners did each round trip add, after the warm-up? What does this print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// 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);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upThe verdict on the fixed shop

    This is the leak test's check, on the fixed shop from 10 to 20 round trips. What does it print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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");

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeOne warm-up trip

      In the warm-up playground, with 1 warm-up trip, how many kB did each round trip add on the fixed shop?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeA shortcut that stays

        Ravi adds a keyboard shortcut: press A to add the tea. Which line fails to clean up, so every tea page stays in memory?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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));
          };
        }

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeCount the detached list items

          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?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWhere MemLab writes its summary

            MemLab saves its results in a folder. Which file holds the text summary of the memory leaks it found?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which step it's about: counting, comparing, the retainers, or the test.

              The finding leaks quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does this warm-up check print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.

              2. Question 2 of 7Why does the leak test collect garbage before it reads the counters?

                Choose an answer to see the explanation.

              3. 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.

              4. 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.

              5. Question 5 of 7What does MemLab report as a leak?

                Choose an answer to see the explanation.

              6. 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.

              7. 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:

              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.

              CompleteFrontend Clear concepts. Working examples.