completefrontend
Open lab My progress
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
    Clean up after a routeFind listeners, timers, and saved page data that stay after a page leaves, and remove them.
  • 02
    Choose a safe cacheBuild a small cache with a size limit (LRU), and explain why a WeakRef cache must be able to make its value again.
  • 03
    Test for leaksSet a memory budget, repeat the same steps, and catch memory that grows on every run in an automatic test.

Memory that should leave

A single-page app is a website that loads once and then swaps pages without reloading. Gmail and most online shops work like this. Each of these inner pages is called a route, such as /products or /settings.

The app can stay open for hours. When you move to a new route, the JavaScript keeps running; nothing starts fresh. So anything a route starts must also be stopped by that route. That includes:

  • listeners: functions that run when something happens, such as the window being resized,
  • timers: functions that run again every few seconds,
  • saved data: lists and caches that hold the route's values.
Definition

A memory leak is memory your app still points to after it no longer needs it. The garbage collector is the part of JavaScript that frees memory nothing points to. It cannot free a listener, timer, or list that your code still points to.

This lesson comes after Allocation profiling. That lesson showed how to find code that makes many short-lived objects. This lesson asks a different question: when a page leaves the screen, does its memory leave too?

We start with small models. Then we build a routine you can use on a real app:

  1. Clean up each route when it leaves.
  2. Give each cache a size limit.
  3. Write down how much memory may grow.
  4. Repeat a user's steps in an automatic test.

Leaks across route changes

Start with one tiny bug. The function mountPage stands for showing a page. To mount a page means to put it on the screen; to unmount it means to take it off.

Each time mountPage runs, it adds a resize listener. It never removes one. 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 by line:

  1. Line 1 makes an empty array, listeners. We use it to count the listeners we add.
  2. Line 4 asks the browser to call a function each time the window is resized.
  3. Line 5 writes "resize" into the array, so we can count it.
  4. Lines 8 to 10 show the page three times.
  5. Line 11 prints 3: three listeners, and none were removed.

In a real browser, window keeps every listener you add until you remove it. A listener is a function, and a function can still use the variables around it where it was written. This is called a closure. So the listener can keep the old page's data alive.

The product page has left the screen. The window card points to a listener card that was never removed. The listener points to the old page card, so the old page is kept in memory.the product page has left the screenwindowalways therelistenernever removedold pagekept in memory
The page is gone from the screen, but window still holds its listener. The listener still holds the old page, so the old page stays in memory.

The fix starts by writing down who owns each thing: who started it, and so who must stop it. If the product route adds a resize listener, the product route must remove it. A listener the whole app shares belongs to the whole app instead. Then a route never removes something it did not start.

Real-life analogyA fan left on after leaving a room

When you leave a room, you should switch off its fan. If you forget, the fan keeps running with nobody there. Visit the room three times and forget three times, and now three fans run.

In real life: You leave a room
In JavaScript: The page leaves the screen
In real life: The fan keeps running
In JavaScript: A listener or timer is still active
In real life: Each visit starts another fan
In JavaScript: Each visit adds one more listener and timer
In real life: Switching it off ends the waste
In JavaScript: Cleanup removes the listener and stops the timer
Step through three visits without cleanup
Step 0 of 11Ready
Your turn: follow the blue line

This step-through runs the lesson's route model. It only counts listeners, timers, and saved pages; it does not look at real browser memory.

Running in
  1. script
Next: line 1
 for (let visit = 0; visit < 3; visit += 1) {  page.mount();  page.unmount();} console.log(page.counts());
CallStoreChangeResultBlue line: runs next. 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 undo what it started. Usually that means:

  • removeEventListener for each listener,
  • clearInterval for each repeating timer,
  • emptying any app list that still holds the old page's data.

The code below uses an AbortController. It is a small object with a signal and an abort() method. When you add a listener with that signal, calling abort() removes the listener for you.

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 by line:

  1. Line 2 makes one AbortController.
  2. Line 4 adds the resize listener and ties it to the controller's signal.
  3. Line 5 starts a timer that runs every second. setInterval gives back an id, saved in timer.
  4. Lines 7 to 10 give back a cleanup function. The app calls it when the page leaves.
  5. Line 8, controller.abort(), removes the listener.
  6. Line 9, clearInterval(timer), stops the timer.
The cleanup function returned by mountPage does two jobs. controller.abort() removes the listener. clearInterval(timer) stops the timer.cleanup()returned by mountPagecontroller.abort()removes the listenerclearInterval(timer)stops the timer
mountPage gives back one cleanup function. Calling it removes the listener and stops the timer.

One controller helps when a page adds several listeners: one abort() removes them all. But it does not know about the timer. So the cleanup still calls clearInterval. Keep the cleanup code right next to the code that started things.

Calling cleanup twice should be safe. Routes can change quickly, an error screen can replace a page, or a test can take a page off early. In our route model, a second unmount does nothing. That is a good habit for cleanup code: after the first call, there is nothing left to remove.

Playground: visit a page again and again
Visit the page three timesJavaScript
const page = createRouteModel(false); for (let visit = 0; visit < 3; visit += 1) {  page.mount();  page.unmount();} console.log(page.counts());
Left after the page leavesno cleanup
listeners3

Resize listeners that old visits never removed.

timers3

Timers that old visits never stopped.

saved pages3

Old pages still saved in a global list, so they stay in memory.

Try it yourself

After 3 visits without cleanup, every count has grown to 3.

This model only counts what each visit adds and removes. It shows how a forgotten listener keeps an old page in memory; it does not look at real browser memory.

First, change only the number of visits. With cleanup off, all three counts grow together. Then turn cleanup on. Now every visit removes its listener, timer, and saved page, so each count goes back to zero.

Caches with a size limit (LRU)

A cache saves a result so the next request for it is faster. A cache is not a leak by itself. It is a trade: you spend memory to save time.

The important part is a limit. A cache must have a largest size, or another rule that removes old entries. Removing an entry to make room is called evicting it.

An LRU cache ("least recently used") evicts the entry that nobody has used for the longest time. The lesson's LruCache keeps its entries in a Map. A Map remembers the order keys were added: the first key is the oldest, the last key is the newest.

A two-item cache drops the oldest keyPop 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 by line:

  1. Line 1 makes a cache that holds at most two items.
  2. Lines 2 and 3 add tea, then milk. Now the cache is full.
  3. Line 4 reads tea. A read moves the key to the newest place, so milk is now the oldest.
  4. Line 5 adds bread. That makes three, so the cache evicts the oldest key, milk.
  5. Line 6 prints the keys, oldest first: tea,bread.
The two-item cache in three steps. After line 3 it holds tea, then milk. Line 4 reads tea, so the order becomes milk, then tea. Line 5 adds bread, so milk, the oldest, is removed and the cache holds tea, then bread.oldestnewestafter line 3teamilkcache is fullline 4 reads teamilkteatea is newestline 5 adds breadteabreadmilk removed
The top card is the oldest, the bottom card the newest. Reading tea makes it the newest, so milk is the one removed when bread arrives.

Do not pick a limit at random. Work it out in three steps:

  1. Guess how much memory one entry uses.
  2. Choose how many recent entries fit on the slowest phones you support.
  3. Measure the real route.

A limit of 500 can still be too big. For example, each saved preview might hold a large image.

Real-life analogyRecent apps on your phone

Think of the list of recent apps on a phone. When you use an app, it moves to the front. When the list is full, the app you have not used for the longest time drops off.

In real life: An app you open moves to the front
In JavaScript: Reading a key makes it the newest
In real life: The oldest app drops off when the list is full
In JavaScript: The first Map key is evicted
In real life: Opening a dropped app takes longer
In JavaScript: A missing key must be worked out or fetched again
In real life: The list only has room for a few apps
In JavaScript: The cache has a fixed limit
Step through the cache: read tea, then drop milk
Step 0 of 7Ready
Your turn: follow the blue line

This step-through calls the same LruCache code the playground uses.

Running in
  1. script
Next: line 1
cache.set("tea", "tea result");cache.set("milk", "milk result");cache.get("tea");cache.set("bread", "bread result");console.log(cache.keys().join(","));
CallStoreChangeResultBlue line: runs next. 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: run the two-item cache
A two-item cache drops the oldest keyJavaScript
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(","));
Keys in the cacherun 1, limit 2
after adding teatea
after reading teamilk,tea
looking up milkundefined

Milk was dropped, so the app must make it again.

Try it yourself

Reading tea gives back "tea result". Then adding bread drops milk. The keys left, oldest first: tea, bread.

Keys are listed oldest first. A missing key is normal: the app makes or fetches the value again.

A WeakRef cache can lose its value

A WeakRef points to an object without keeping it alive. The garbage collector may still free the object. Its deref() method gives back the object, or undefined if the object is already gone.

A WeakRef can help with a value that is cheap to make again, such as a small preview. It does not decide who owns a value. And it never promises the value is still there.

The code below also uses a WeakMap: a Map whose keys are objects and does not keep those keys alive.

A cache that makes the value again when it is gonePop 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 by line:

  1. Line 1 makes the cache: a WeakMap from each product to a WeakRef of its preview.
  2. Line 4 looks up the product's WeakRef and calls deref(). The ?. gives undefined if there is no WeakRef yet.
  3. Line 5 returns the saved preview if it still exists.
  4. Lines 6 and 7 make a fresh preview and save a new WeakRef to it.
  5. Line 8 returns the fresh preview.

This works differently from an LRU cache. An LRU cache keeps its entries until it evicts them. A WeakRef cache can lose a value at any time after the garbage collector runs. So the code that makes the value again (lines 6 to 8) is what your app really depends on. The WeakRef only saves time when it gets lucky.

The table compares four ways to keep values. Read one row at a time.

Four ways to keep values, and when each one fits
Kind of cacheWhat it keepsWhen to use it
A plain Map with no limitIt keeps every key until your code deletes it.Avoid it for data that grows with every visit.
An LRU cacheIt keeps only a fixed number of the most recently used keys.Use it when working out or fetching a value again is fine.
A WeakRef cacheIt may lose a value whenever the garbage collector runs.Use it only with code that makes the value again.
A WeakMap of extra dataIt does not keep its object keys alive.Use it for extra data that should go away with its object.

For more on WeakRef and WeakMap, see WeakRef and finalization. For data the user can see, such as their shopping cart, do not use a weak cache. Give it a clear owner and remove it on purpose.

Set a memory budget

A memory budget turns "memory seems to grow" into a check that passes or fails. For example: after ten visits to the product route, memory may grow by less than 8 MB (megabytes). Your team picks the number, based on the route, its data, and the devices you support.

The memory we measure here is the heap: the area where JavaScript keeps objects, arrays, and functions.

Check how much memory one flow addedPop 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 by line:

  1. Line 1 saves the memory before the flow: 40 MB.
  2. Line 2 saves the memory after the flow: 46.5 MB.
  3. Line 3 works out the growth, 46.5 − 40 = 6.5 MB, and checks if it is less than 8 MB.
  4. Line 4 prints true 6.5: the check passes, and the growth was 6.5 MB.
Memory goes from 40 MB before the flow to 46.5 MB after it, a growth of 6.5 MB. 6.5 is less than the 8 MB limit, so the budget passes.40 MBbefore+6.5 MB46.5 MBafter the flowgrowth vs limit6.5 < 8passes
The budget checks how much memory grew, not the total. 6.5 MB of growth is under the 8 MB limit, so it passes.

Do not choose a budget from one run on your own laptop. Instead:

  1. Warm up: visit the route once first, so code and expected caches are loaded.
  2. Run the same steps several times.
  3. Pick a limit with room for expected caches, but low enough to catch memory that grows on every run.

Keep two questions apart. A route can stay inside its budget while one cache is still too big for a phone with little memory. The budget checks a whole flow. The cache limit checks one cache. You need both, because they catch different problems.

A budget your team can write down

After one warm-up visit, visit the product route ten times. Memory must grow by less than 8 MB. The number of listeners must go back to where it started. And no old page object may grow by one on every visit.

Measure the right thing

Browser DevTools is the usual place to start. It gives you two useful tools:

  • A heap snapshot lists every object in memory. For each one it shows the retaining path: the chain of references that keeps it alive.
  • Allocation profiling shows code that makes many short-lived objects.

Before you open a big snapshot, a simple count can help. Count things your own app adds, such as listeners, and check the count after each visit.

The helper detectSteadyGrowth takes a list of memory numbers, one per run. It says leaking: true only if memory grew on every single run.

Memory that stays the same is not growingPop 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 by line:

  1. Line 1 gives four runs, each at 12 MB.
  2. Line 2 checks if every run grew from the one before.
  3. Line 3 prints false. Memory stayed flat, so it did not grow on every run.

Browsers also have memory numbers you can read from code. Be careful with them:

  • performance.memory works only in Chrome and is deprecated (old, and may be removed). Do not build your product checks on it.
  • measureUserAgentSpecificMemory() is the newer one. It works only on HTTPS pages that are cross-origin isolated (a special security mode set by response headers), and only in some browsers.

Each browser measures memory in its own way. So compare the trend over many runs, not one number.

The table lists four memory signals. For each one, it says which tool gives it and what a healthy result looks like.

What to measure, with which tool, and what to check
What you measureWhich toolWhat to check
How many listeners and timers there areA simple counter in your own app code.The count goes back to where it started after the page leaves.
Which objects are still in memoryA heap snapshot in the DevTools Memory panel.The retaining path shows why a removed page is still in memory.
Memory used by the whole pagemeasureUserAgentSpecificMemory(), on an HTTPS page that is cross-origin isolated.The number does not grow on every repeat of the same steps.
Chrome's old heap numberperformance.memory, which works only in Chrome.It is deprecated, so do not build product checks on it.

Catch leaks in automatic tests

CI (continuous integration) is a server that runs your tests every time someone changes the code. A CI test can act like a user, again and again:

  1. Open a route.
  2. Click around.
  3. Leave the route.
  4. Repeat.

Then it compares a number that should stay steady, such as the number of listeners your app added, the number of page objects, or a count from the test browser.

Two tools help with this:

  • MemLab, from Meta, repeats clicks in a browser and looks for objects that should have been freed.
  • Puppeteer controls a test browser from code. Its page.queryObjects finds every object made from one prototype, such as every object made by a Page class, so the test can count them.

Both tools give you evidence. You still need to find the retaining path to know what to fix.

A test rule: memory must not grow on every runPop 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);

This is the same check as before. In CI, run the flow once to warm up, so code is loaded and expected caches are built. Then repeat the exact same flow a set number of times and pass the numbers to the check.

Some test browsers let a test force the garbage collector to run. Do that only inside the test. Your app's own code must never depend on it.

When the test fails, the message should say what grew and during which steps. "Product page listener count grew from 2 to 12 after 10 visits" tells a developer where to look. "Memory changed" does not.

Keep the steps small and the same on every run. For example: open the products route, open one item, go back to the list, and repeat. If the data, network speed, or steps change on every run, you cannot tell whether memory changed because of the code or because the test changed.

A test that proves cleanup frees the page

We can also test one small fact directly: after cleanup, can the old page be freed? This test does not check memory sizes or timing.

It runs in Node.js, in a separate child process (a second Node program started just for the test). The child starts with the --expose-gc flag. That flag adds a gc() function that runs the garbage collector on demand.

The test shows two pages and then takes both off: one without cleanup, one with cleanup. It keeps a WeakRef to each page, so it can check later if the page is gone.

Node only: does cleanup let the old page be freed?JavaScript
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));

The important lines:

  • Line 5 makes the page's data: a title and 1,000 items.
  • Line 9 adds the page's listener to the shared livePages list. The listener uses the page data, so it keeps the page alive.
  • Line 11 removes that listener, but only for the page with cleanup.
  • Line 12 sets the local page variable to null.
  • Line 35 prints leaky alive true: the page without cleanup is still in memory.
  • Line 36 prints clean collected true: after a few rounds of gc(), the cleaned-up page is gone.
What this test shows

The test checks only one thing: which page can still be reached, and which can be freed. It does not check exactly when the garbage collector runs, or how many bytes are used. The WeakRef is only there so the test can look. It is not what cleans up the page.

A routine for checking a real app

Check one route at a time. Write down three things: what it adds (listeners, timers), what it saves (caches), and who cleans up each one. A cleanup function next to the code is easier to check than a rule nobody wrote down.

  • Give every listener, observer, and timer a matching cleanup step.
  • Give every cache that lives a long time a size limit, an expiry time, or a clear owner.
  • Use a heap snapshot to find the retaining path before you change code.
  • Warm up, then repeat the real steps, before you call something a leak.
  • Keep the memory budget next to the route's test, not in a document nobody reads.
What should you do with each item?
  • A route's resize listener
  • A setInterval timer that checks for news
  • Search results saved by URL
  • An image preview made from a product
  • Ten visits to the same route
  • An old page object still in a heap snapshot
Try it yourself
0 of 6 correct

Put each card under the step that keeps its memory under control.

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

Stopping a request is cleanup too. If a route starts a network request that has not finished, use AbortController cancellation to stop it. Then its code does not keep running after the user has left.

Common misconceptions

  • "JavaScript frees memory for me, so routes cannot leak." The garbage collector frees only what nothing points to. A listener, timer, or global cache can still point to the old page.
  • "A WeakRef means I don't need cleanup." Cleanup removes your own references. A WeakRef only lets one cached value be freed.
  • "An LRU limit is always safe." The limit must still be small enough for the route and the phones you support.
  • "One before-and-after memory number proves a leak." Warm-up and garbage collector timing change the numbers. Look for growth on every run, and find the retaining path.

The table puts each wrong idea next to a better one. Read across each row.

Wrong ideas about memory, and what is really true
What people thinkWhat is really trueWhy it matters
JavaScript frees memory for me, so there are no leaks.A route can still leak through a listener, timer, cache, or global variable.The garbage collector only knows what is pointed to, not which page owns it.
An LRU cache has a limit, so it is safe.An LRU cache can still be too big for a phone with little memory.Pick the limit from measured entry sizes and real devices.
A WeakRef keeps my value for me.Its value can be gone the next time you read it.Always make the value again when deref() gives undefined.
One memory number shows if there is a leak.Warm-up and garbage collector timing change single numbers.Repeat the same steps and look for growth on every run.

Practice exercises

Exercise 1 · Warm-upPredict the listener count

Read the code. How many listeners are in the list after three calls?

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 timer with setInterval to check for new prices. What should its cleanup call?

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

      Exercise 3 · PracticeWhich key does the cache drop?

      Predict the keys that remain, oldest first.

      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 · PracticeThe WeakRef value is gone

        Your preview's WeakRef deref() returns undefined. What should your code do next?

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

          Exercise 5 · ChallengeCheck a budget

          Does this budget check 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

            A CI test visits your settings route ten times. After the warm-up, what should the test compare?

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

              Check your understanding

              Answer in the same order you would check a real app:

              1. Who started this thing?
              2. Does cleanup undo it?
              3. Does the cache have a limit?
              4. Does memory grow on every repeat?
              Memory in large apps quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does this code print after three calls to mountPage?

                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 7Why does a route need cleanup?

                Choose an answer to see the explanation.

              3. Question 3 of 7This code copies what the LRU cache does, using a plain array (oldest first). What does it 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 when deref() gives undefined?

                Choose an answer to see the explanation.

              5. Question 5 of 7Memory is 40 MB before and 46.5 MB after. The limit is 8 MB of growth. Does the budget pass?

                Choose an answer to see the explanation.

              6. Question 6 of 7Why does a CI memory test do a warm-up visit before it repeats the steps?

                Choose an answer to see the explanation.

              7. Question 7 of 7In a Puppeteer browser test, which tool finds every object made from one prototype?

                Choose an answer to see the explanation.

              Key takeaways

              • Changing routes does not restart JavaScript. So each route must undo what it started.
              • A listener, timer, or global list can keep an old page in memory by mistake.
              • An LRU cache needs a real size limit. A WeakRef cache needs code that makes the value again when it is gone.
              • A memory budget checks growth over repeated runs after a warm-up, not one memory number.
              • A CI test can repeat a user's steps and check that counts or memory do not grow on every run.

              Remember the one-liner.
              A large app stays healthy when everything that lives a long time has an owner, a limit, or a cleanup step.

              Coming next: Memory in Node.js. On a server, you meet new limits: the heap size limit, memory outside the heap, Buffers, and what happens when memory runs out.

              CompleteFrontend Clear concepts. Working examples.