Memory in large apps
Keep single-page apps from growing forever by cleaning route resources, bounding caches, setting budgets, and testing repeated flows.
- 01Own route resourcesFind listeners, timers, and retained page state that survive a route change, then release them.
- 02Choose safe cachesBuild a small LRU cache and explain why WeakRef is only an optional recompute optimization.
- 03Test a memory promiseSet a budget, measure a repeatable flow, and flag steady growth in continuous integration.
Memory that should leave
A single-page app can stay open for hours. It changes routes without restarting the JavaScript process, so route-owned listeners, timers, and cached data need an owner and a stopping point.
A memory leak is memory that stays reachable after your app no longer needs it. Garbage collection can reclaim unreachable objects, but it cannot remove a listener, timer, or global cache that your code still holds.
This lesson follows Allocation profiling. That lesson helps you find allocation churn. Here, the question is longer-lived: after a page leaves, did its resources and state leave too?
We will use small models first. Then we will turn them into a practical routine: clean up routes, bound caches, write a budget, and repeat a user flow in continuous integration.
Leaks across route changes
Start with one tiny bug. The function below models a page that registers a resize listener but never removes it. Calling it three times prints 3.
const listeners = []; function mountPage() { window.addEventListener("resize", () => {}); listeners.push("resize");} mountPage();mountPage();mountPage();console.log(listeners.length);Line 1 makes an array so the model can show registrations. Line 4 records one resize listener. Lines 7 through 9 mount the page three times, and line 10 prints three registered listeners.
In a browser, window keeps a registered listener. The listener can keep its closure, and the closure can keep the old page state. A page can disappear from the screen while its data remains reachable.
The fix starts by writing down ownership. If the product route registers a resize listener, the product route owns its removal. A shared application listener belongs to the shared application shell instead. That small distinction prevents a route from deleting work it did not create.
Leaving a room should end your use of its fan. If you forget to switch it off, it keeps running even though nobody is there. Revisit the room three times and forget three times, and now three fans run.
- In real life: You leave a room
- In JavaScript: The route unmounts
- In real life: The fan keeps running
- In JavaScript: A listener or timer remains active
- In real life: Each visit starts another fan
- In JavaScript: Each mount adds another retained resource
- In real life: Switching it off ends the waste
- In JavaScript: Cleanup releases the route resource
Where the analogy stops: A fan does not hold JavaScript objects. The analogy only explains work that continues after the page is gone.
This is a replay of instrumented route-model code. It counts resources; it does not inspect browser memory.
script
for (let visit = 0; visit < 3; visit += 1) { page.mount(); page.unmount();} console.log(page.counts());Give each page cleanup
A route should remove what it registered. That normally means removeEventListener for listeners, clearInterval for intervals, and dropping app-owned arrays that store old page state.
function mountPage(window) { const controller = new AbortController(); const onResize = () => console.log("resize"); window.addEventListener("resize", onResize, { signal: controller.signal }); const timer = setInterval(() => console.log("refresh"), 1000); return () => { controller.abort(); clearInterval(timer); };}Line 2 creates one AbortController. Line 4 attaches a listener to its signal. Line 5 starts an interval. Lines 7 through 10 return cleanup: abort removes signal-based listeners and clearInterval stops the timer.
One controller is useful when a page creates several event listeners. It does not clear an interval for you, so the cleanup still calls clearInterval. Keep the returned cleanup close to the code that mounted the route.
Cleanup should be safe to call more than once. A route can change quickly, an error boundary can replace a view, or a test can unmount a component early. The route model ignores a second unmount, which is a useful shape for cleanup code: after the first call, there is nothing left for the second call to remove.
const page = createRouteModel(false); for (let visit = 0; visit < 3; visit += 1) { page.mount(); page.unmount();} console.log(page.counts());3Resize handlers still registered by old routes.
3Intervals still scheduled by old routes.
3Global references that still keep page data reachable.
After 3 visits, every model count grows to 3.
Change only the visit count first. With cleanup off, all three counts grow together. Turn cleanup on and each mount/unmount pair returns its listener, timer, and saved page reference to zero.
Bounded caches with LRU
A cache is not automatically a leak. It is a deliberate trade: keep a result now so a later request can be faster. The important word is bounded: the cache must have a maximum size or another eviction rule.
const cache = new LruCache(2);cache.set("tea", "tea result");cache.set("milk", "milk result");cache.get("tea");cache.set("bread", "bread result");console.log(cache.keys().join(","));Line 1 creates a two-item cache. Lines 2 and 3 add tea and milk. Line 4 reads tea, which refreshes its recent position. Line 5 adds bread and evicts milk, the least recently used key. Line 6 prints tea,bread.
A limit is not a random small number. Estimate the cost of a typical entry, choose how many useful recent results fit the devices you support, and then measure the real route. A limit of 500 can still be too large when each cached preview holds a large image or response.
Think of the recent-apps list on a phone. Use one app and it moves near the front. When the list is full, the app you have not used for the longest time drops off.
- In real life: A recently opened app moves forward
- In JavaScript: A cache hit refreshes a key
- In real life: The oldest app drops off when full
- In JavaScript: The first Map key is evicted
- In real life: Opening it again takes work
- In JavaScript: A cache miss recomputes or refetches
- In real life: The list has a fixed capacity
- In JavaScript: The cache has a fixed limit
Where the analogy stops: Phone systems use their own rules. An LRU cache is only a simple ordering policy for values your app owns.
This replay calls the real LruCache methods used by the playground.
script
cache.set("tea", "tea result");cache.set("milk", "milk result");cache.get("tea");cache.set("bread", "bread result");console.log(cache.keys().join(","));const cache = new LruCache(2);cache.set("tea", "tea result");cache.set("milk", "milk result");cache.get("tea");cache.set("bread", "bread result");console.log(cache.keys().join(","));teamilk,teaundefinedMilk was evicted, so it must be recomputed.
The hit returns tea result; then bread evicts milk. The final order is tea, bread.
WeakRef as an optional cache
A WeakRef is a reference that does not keep its target alive. It can help with a derived value that is cheap enough to recreate, but it is not a normal ownership tool and it is never a promise that a value stays present.
const cache = new WeakMap(); function getPreview(product) { const saved = cache.get(product)?.deref(); if (saved) return saved; const preview = makePreview(product); cache.set(product, new WeakRef(preview)); return preview;}Line 5 asks the weak cache for a saved preview. Line 6 returns it only when it still exists. Lines 7 through 8 make and store a fresh preview when the weak value has already disappeared.
This is deliberately different from an LRU. An LRU promises its bounded entries until eviction. A weak cache may miss at any time after collection. That is why the fallback is the real feature, and the weak cache is only an optimization.
| Kind | What it retains | When it fits |
|---|---|---|
| Unbounded Map | Keeps every key until code deletes it | Avoid for data that can grow with visits |
| LRU Map | Keeps only the most recently used fixed number | Use when recomputing or refetching is acceptable |
| WeakRef cache | May lose a value at any collection | Use only with a clear recompute fallback |
| WeakMap metadata | Does not keep its object key alive | Use metadata tied to another object's lifetime |
For everyday weak-key metadata, revisit WeakRef and finalization. For user-visible data such as an active cart, use a clear owner and explicit lifetime instead.
Set a memory budget
A memory budget turns “it seems to grow” into a check. For example: after ten visits to a product route, heap growth should be less than 8 MB. The number is a team decision based on the route, data, and devices you support.
const beforeMB = 40;const afterMB = 46.5;const report = checkBudget(beforeMB, afterMB, 8);console.log(report.passes, report.growthMB);Line 1 records a before value of 40 MB. Line 2 records 46.5 MB after the flow. Line 3 compares growth against 8 MB. Line 4 prints true 6.5, because 6.5 MB is below the budget.
Do not choose a budget from one laptop run. Warm up the route, run the same flow several times, and set a threshold that leaves room for expected caches while catching steady growth.
Keep two questions separate. A route can have a healthy growth budget while a cache is still too large for a low-memory device. The budget checks the chosen flow; the cache limit checks one owner. Both are useful because they fail for different reasons.
After warm-up, repeat the product route ten times. Heap growth must stay below 8 MB, listener count must return to baseline, and no old page-state object may keep growing by one per visit.
Measure the right thing
DevTools is the usual first stop. Heap snapshots show retained objects and their retaining paths. Allocation profiling shows churn. Counts owned by your app can make a small route test very clear before you inspect a large heap.
const samples = [12, 12, 12, 12];const report = detectSteadyGrowth(samples);console.log(report.leaking);Line 1 supplies four equal samples. Line 2 checks whether every later run grows. Line 3 prints false, because a flat sequence is not steady growth in this model.
performance.memory is Chrome-only and deprecated, so do not build a product promise around it. In isolated pages, prefer measureUserAgentSpecificMemory() when available. It reports a browser-specific measurement, so compare repeatable trends instead of treating one number as universal truth.
| Signal | Tool | Useful assertion |
|---|---|---|
| Listener and timer counts | A cheap model or app-owned counter | The count returns to its baseline after unmount |
| Heap snapshots | DevTools Memory panel | A retaining path explains why a detached page remains |
| User-agent memory | measureUserAgentSpecificMemory() in an isolated page | A whole-page number after a repeatable user flow |
| Chrome legacy heap | performance.memory | Chrome-only and deprecated; do not make it your product metric |
Detect leaks in CI
Continuous integration can repeat a user flow after warm-up. Navigate to a route, interact, navigate away, and repeat. Then compare a stable count: app-owned listeners, route objects, or a browser query result.
Meta's MemLab is built for finding browser memory leaks across interactions. Puppeteer also has page.queryObjects, which can count objects for a prototype in a test browser. Both are tools for evidence, not shortcuts around understanding the retaining path.
const samples = [12, 12, 12, 12];const report = detectSteadyGrowth(samples);console.log(report.leaking);Use one warm-up run to load code and build expected caches. Then repeat the exact flow N times. Force garbage collection only in a controlled test browser when its test setup allows it; application code must not depend on that force.
A good failure message names the growing thing and the flow. “Product page listener count grew from 2 to 12 after ten visits” gives a developer a starting point. “Heap changed” does not.
Run the flow in a stable browser and keep the action small. For example, visit the products route, open one item, return to the list, and repeat. A test that changes its own data, network timing, and route sequence on every run cannot tell you whether memory changed because of the code or because the experiment changed.
A stable collection check
Runtime evidence can test a small reachability claim without asserting a heap size or timing. In a Node child process started with --expose-gc, the model mounts then unmounts two pages and keeps WeakRefs to their state.
const later = () => new Promise((resolve) => setTimeout(resolve, 0));const livePages = []; function mount(cleanup) { let page = { title: "products", items: new Array(1000).fill("tea") }; const weak = new WeakRef(page); const retainedPage = page; const listener = () => retainedPage.items.length; livePages.push(listener); const unmount = () => { if (cleanup) livePages.pop(); page = null; }; return { weak, unmount };} async function collect(weak) { for (let attempt = 0; attempt < 10; attempt += 1) { await later(); globalThis.gc(); await later(); if (weak.deref() === undefined) return true; } return false;} let leaky = mount(false);const leakyWeak = leaky.weak;leaky.unmount();leaky = null;let clean = mount(true);const cleanWeak = clean.weak;clean.unmount();clean = null;console.log("leaky alive", leakyWeak.deref() !== undefined);console.log("clean collected", await collect(cleanWeak));Line 7 stores a listener in a global model list. Line 9 removes it only in the cleaned-up case. Line 11 clears the local page reference. The final lines show the leaky page remains alive while the cleaned-up page can be collected after later tasks.
The test checks stable words and reachability, not exact collection timing or heap bytes. WeakRef is used here as test evidence; it is not the cleanup mechanism.
A practical review routine
Review one route at a time. Write down what it registers, what it caches, and who cleans each thing. A local cleanup function is easier to verify than an undocumented global convention.
- Pair every event listener, observer, and interval with route cleanup.
- Give every long-lived cache a size limit, expiry rule, or explicit owner.
- Use heap snapshots to find the retaining path before changing code.
- Warm up and repeat a real flow before declaring a leak.
- Put the memory budget beside the route test, not in a forgotten document.
- A route's
resizelistener - A polling
setInterval - Search results cached by URL
- A derived image preview
- Ten visits to the same route
- A page state object still in a heap snapshot
Sort each card by the action that makes the memory behavior understandable.
Cancellation is often cleanup too. When a route owns an in-flight request, use AbortController cancellation so work and callbacks do not continue after the route changed.
Common misconceptions
- “GC means routes cannot leak.” A host listener, timer, or global cache can still make old state reachable.
- “WeakRef removes cleanup work.” Cleanup removes your references. WeakRef only permits collection of a cache value.
- “An LRU limit is always safe.” The limit must still fit the route and the devices you support.
- “One before-and-after heap number proves a leak.” Warm-up and collection timing add noise; look for repeated growth and retaining paths.
| Claim | More accurate view | Why it matters |
|---|---|---|
| Garbage collection exists | A route can still leak through a listener, timer, cache, or global | The engine knows reachability, not your page ownership |
| An LRU is bounded | An LRU can still be too large for a mobile memory budget | Choose a limit from measured cost and real devices |
| WeakRef is weak | Its value can disappear before the next read | Always recompute when deref() returns undefined |
| One heap number is flat | Warm-up and collection timing add noise | Repeat flows and compare steady growth, not one run |
Practice exercises
Read the model and type the listener count after three mounts.
const listeners = [];
function mountPage() {
window.addEventListener("resize", () => {});
listeners.push("resize");
}
mountPage();
mountPage();
mountPage();
console.log(listeners.length);It prints 3. Each route mount records one listener and the model never removes one.
A product route starts a polling interval. What should its cleanup call?
clearInterval(timer);Call clearInterval during unmount so the old route stops scheduling work.
Predict the final oldest-to-newest key order.
const cache = new LruCache(2);
cache.set("tea", 1);
cache.set("milk", 2);
cache.get("tea");
cache.set("bread", 3);
console.log(cache.keys().join(","));It prints tea,bread. Tea was refreshed, so adding bread removes milk.
Your preview WeakRef returns undefined. What should normal code do next?
Recompute or refetch the value. Code must work whether the weak value is present or gone.
Does the budget report pass?
const beforeMB = 40;
const afterMB = 46.5;
const report = checkBudget(beforeMB, afterMB, 8);
console.log(report.passes, report.growthMB);The answer is true. Growth is 6.5 MB, below the 8 MB limit.
Your settings route is tested ten times in CI. What should the test compare after warm-up?
Compare steady growth in app-owned listener or object counts, or a repeatable memory signal. Then inspect the retaining path for anything that grows.
Check your understanding
Use the same order as a real investigation: identify the owner, check cleanup, check the cache bound, then look for a repeatable growth signal.
Question 1 of 7What does the first route-leak model print after three mounts?
Read the code, then predictconst listeners = []; function mountPage() { listeners.push("resize"); } mountPage(); mountPage(); mountPage(); console.log(listeners.length);Choose an answer to see the explanation.
Question 2 of 7What is the purpose of route cleanup?
Choose an answer to see the explanation.
Question 3 of 7What does this LRU sequence print?
Read the code, then predictconst keys = ["milk", "tea"]; keys.splice(keys.indexOf("tea"), 1); keys.push("tea"); keys.shift(); keys.push("bread"); console.log(keys.join(","));Choose an answer to see the explanation.
Question 4 of 7What must a WeakRef cache do after
deref()returns undefined?Choose an answer to see the explanation.
Question 5 of 7A budget starts at 40 MB and ends at 46.5 MB with an 8 MB limit. Does it pass?
Choose an answer to see the explanation.
Question 6 of 7Why does a CI memory check warm up before repeating a user flow?
Choose an answer to see the explanation.
Question 7 of 7Which tool can inspect objects of a constructor in a Puppeteer browser test?
Choose an answer to see the explanation.
Key takeaways
- Route changes do not restart the JavaScript process, so routes must release what they register.
- Listeners, intervals, and global arrays can retain an old page through an accidental reference path.
- LRU caches need a real bound; WeakRef caches need a real fallback.
- Memory budgets compare repeatable growth after warm-up, not one noisy heap number.
- CI can repeat user flows and compare stable counts or browser memory evidence.
Remember the one-liner.
A large app stays healthy when every long-lived reference has an owner, a limit, or a cleanup point.
Coming next: Memory in Node.js, where heap limits, external memory, Buffers, and out-of-memory failures matter on the server.