cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Memory in large apps

Keep single-page apps from growing forever by cleaning route resources, bounding caches, setting budgets, and testing repeated flows.

By the end, you can
  • 01
    Own route resourcesFind listeners, timers, and retained page state that survive a route change, then release them.
  • 02
    Choose safe cachesBuild a small LRU cache and explain why WeakRef is only an optional recompute optimization.
  • 03
    Test 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.

Definition

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.

A page that keeps adding listenersPop out in the code editor (opens in a new tab)JavaScript
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.

Real-life analogyA fan left on after leaving a room

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.

Step through three leaky route visits
Step 0 of 11Ready
Your turn: follow the blue line

This is a replay of instrumented route-model code. It counts resources; it does not inspect browser memory.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
 for (let visit = 0; visit < 3; visit += 1) {  page.mount();  page.unmount();} console.log(page.counts());
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Return a cleanup function from a page mountPop out in the code editor (opens in a new tab)JavaScript
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.

Playground: repeat a route visit
Route visit modelJavaScript
const page = createRouteModel(false); for (let visit = 0; visit < 3; visit += 1) {  page.mount();  page.unmount();} console.log(page.counts());
Remaining after unmountleaky
listeners3

Resize handlers still registered by old routes.

timers3

Intervals still scheduled by old routes.

saved pages3

Global references that still keep page data reachable.

Try it yourself

After 3 visits, every model count grows to 3.

This is an app-owned count model. It shows why an accidental host reference can keep page state alive; it is not a heap inspector.

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.

A two-item LRU sequencePop out in the code editor (opens in a new tab)JavaScript
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.

Real-life analogyRecent apps on your phone

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.

Step through an LRU hit and eviction
Step 0 of 7Ready
Your turn: follow the blue line

This replay calls the real LruCache methods used by the playground.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
cache.set("tea", "tea result");cache.set("milk", "milk result");cache.get("tea");cache.set("bread", "bread result");console.log(cache.keys().join(","));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Playground: inspect one LRU run
LRU sequenceJavaScript
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(","));
Cache resultrun 1, limit 2
after teatea
after hitmilk,tea
milk lookupundefined

Milk was evicted, so it must be recomputed.

Try it yourself

The hit returns tea result; then bread evicts milk. The final order is tea, bread.

The visible keys are ordered oldest to newest. A cache miss is normal: code should compute or fetch the value again.

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.

A cache with a recompute fallbackPop out in the code editor (opens in a new tab)JavaScript
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.

Cache choices that are easy to confuse
KindWhat it retainsWhen it fits
Unbounded MapKeeps every key until code deletes itAvoid for data that can grow with visits
LRU MapKeeps only the most recently used fixed numberUse when recomputing or refetching is acceptable
WeakRef cacheMay lose a value at any collectionUse only with a clear recompute fallback
WeakMap metadataDoes not keep its object key aliveUse 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.

Check a simple route budgetPop out in the code editor (opens in a new tab)JavaScript
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.

Budget wording that teams can use

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.

A small steady-growth modelPop out in the code editor (opens in a new tab)JavaScript
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.

What to measure and where
SignalToolUseful assertion
Listener and timer countsA cheap model or app-owned counterThe count returns to its baseline after unmount
Heap snapshotsDevTools Memory panelA retaining path explains why a detached page remains
User-agent memorymeasureUserAgentSpecificMemory() in an isolated pageA whole-page number after a repeatable user flow
Chrome legacy heapperformance.memoryChrome-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.

A CI rule for steady growthPop out in the code editor (opens in a new tab)JavaScript
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.

Node-only cleanup reachability probeJavaScript
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.

What this proves

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.
What should happen to each item?
  • A route's resize listener
  • 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
Try it yourself
0 of 6 correct

Sort each card by the action that makes the memory behavior understandable.

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

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.
Memory claims and their useful correction
ClaimMore accurate viewWhy it matters
Garbage collection existsA route can still leak through a listener, timer, cache, or globalThe engine knows reachability, not your page ownership
An LRU is boundedAn LRU can still be too large for a mobile memory budgetChoose a limit from measured cost and real devices
WeakRef is weakIts value can disappear before the next readAlways recompute when deref() returns undefined
One heap number is flatWarm-up and collection timing add noiseRepeat flows and compare steady growth, not one run

Practice exercises

Exercise 1 · Warm-upPredict the listener count

Read the model and type the listener count after three mounts.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const listeners = [];

function mountPage() {
  window.addEventListener("resize", () => {});
  listeners.push("resize");
}

mountPage();
mountPage();
mountPage();
console.log(listeners.length);

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

    Exercise 2 · Warm-upStop a route timer

    A product route starts a polling interval. What should its cleanup call?

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

      Exercise 3 · PracticeRead an LRU eviction

      Predict the final oldest-to-newest key order.

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

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

        Exercise 4 · PracticeHandle a weak-cache miss

        Your preview WeakRef returns undefined. What should normal code do next?

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

          Exercise 5 · ChallengeCheck a budget

          Does the budget report pass?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const beforeMB = 40;
          const afterMB = 46.5;
          const report = checkBudget(beforeMB, afterMB, 8);
          console.log(report.passes, report.growthMB);

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

            Exercise 6 · ChallengeApply this to a real app

            Your settings route is tested ten times in CI. What should the test compare after warm-up?

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

              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.

              Memory in large apps quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the first route-leak model print after three mounts?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const listeners = [];
                function mountPage() { listeners.push("resize"); }
                mountPage();
                mountPage();
                mountPage();
                console.log(listeners.length);

                Choose an answer to see the explanation.

              2. Question 2 of 7What is the purpose of route cleanup?

                Choose an answer to see the explanation.

              3. Question 3 of 7What does this LRU sequence print?

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

              4. Question 4 of 7What must a WeakRef cache do after deref() returns undefined?

                Choose an answer to see the explanation.

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

              6. Question 6 of 7Why does a CI memory check warm up before repeating a user flow?

                Choose an answer to see the explanation.

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

              CompleteFrontend Clear concepts. Working examples.