Skip to content

    Frontend System Design

    Memory Management and Leaks

    Learn what keeps JavaScript objects alive, the common leak sources, and how to find a leak with heap snapshots, fix it and guard against it in long sessions.

    By Published 14 min read

    • frontend-system-design
    • memory
    • memory-leaks
    • devtools
    • weakmap
    Share

    Standard lessonadvancedTypeScript labFrontend System Design

    Before this: The JavaScript Engine: Parsing, JIT and Garbage Collection

    What you'll learn: By the end of this lesson you will be able to explain why an object stays in memory (it is reachable from a root, not "still needed"), name the usual sources of leaks in a long-lived page (listeners, timers, detached DOM nodes, caches, subscriptions), find one by doing an action and its undo, comparing heap snapshots and reading the retainer chain, choose a fix (explicit cleanup, an abort signal, a weak-keyed map, a bounded cache), and read a measurement series without mistaking a one-off cost for a leak. The lab models the idea on an object graph; it does not model V8.

    A coat-check desk gives you a ticket. The staff hang the coat on a rail and keep a note of the ticket, and when the ticket comes back the hook is free. Now suppose one ticket is pinned to the notice board by someone who forgot about it. The board is never taken down, so the coat can never be cleared, however long ago the guest left. The coat is not "in use". It is simply still pointed at from something that never goes away.

    JavaScript memory behaves like that desk. The engine frees an object when nothing reachable still points at it. A memory leak in a page is a reference you meant to drop and did not, which connects a finished piece of the app to something that lives for the whole session.

    The running example is a support console: agents open ticket panels in tabs and close them all shift long. Each panel is a DOM subtree with a chart, plus the data it draws. After a few hours the tab is sluggish. The JavaScript engine lesson explains how collectors work; this lesson is about what you do to keep them able to work.

    The core idea

    Three statements carry the lesson.

    1. Memory is freed when an object is unreachable, not when you are done with it. MDN reduces "no longer needed" to "unreachable": the collector starts from the roots (in JavaScript, the global object), follows references, and frees what it did not reach. Modern engines use mark-and-sweep, so two objects that point at each other are still freed once nothing reachable points at either.
    2. A leak is a chain from a root to something finished. Closing a panel removes it from the page, but if a window listener, a timer or a module-level cache still refers to it, it stays. The element is then detached: out of the document but alive. Chrome's DevTools documentation defines it that way and calls detached nodes "a common cause of memory leaks".
    3. You find a leak by measuring what survives an action and its undo, then reading what holds it. Guessing from code is slow; snapshots show the chain.

    MDN adds a limit: you cannot trigger collection from JavaScript, and engines may expose that only behind flags. So never write code whose correctness depends on when memory is freed.

    Figure 1. What keeps a closed panel alive

    flowchart LR accTitle: A closed panel still reachable from the window accDescr: The window root holds a resize listener, whose closure holds the ticket panel. The document no longer holds the panel, but the listener chain does, so the panel and the data under it stay alive. W["window
    (a root)"] -->|"resize listener"| L["handler function"] L -->|"closure scope"| S["captured variables"] S -->|"panel"| P["Ticket panel
    (detached)"] P --> C["chart canvas"] C --> D["ticket data"] DOC["document"] -. "removed child" .-> P

    How to read this: the dotted line is the reference you removed when you closed the panel. The solid chain from the root is the one you forgot. As long as it exists the panel, the chart and the data are all reachable.

    Where leaks come from

    Every common leak is the same shape: something long-lived holds a callback or a reference to something short-lived.

    Source Why it holds the object The fix
    Listener on a long-lived target (window, document, a shared emitter) The target's listener list refers to your function, and the function's scope refers to the panel Remove it when the panel closes, or add it with an AbortSignal
    Repeating timer setInterval keeps running until clearInterval, so the callback and what it captured must stay Clear it on close
    Detached DOM node held in a variable, array or map A JavaScript reference counts, even when the element is not in the document Drop the reference when you remove the node
    Module-level Map, array or cache that only grows The collection is reachable for the whole session Bound it, evict, or key it weakly
    Subscription, observer or socket callback The source (a store, a connection) holds your callback Unsubscribe or disconnect in the cleanup path
    Late async completion A response arrives after close and writes into the old panel Cancel the request, or check that the panel is still current

    Two details are worth knowing. First, MDN's page on addEventListener notes that an anonymous function in a loop is a new function each time, so you cannot remove it later without a saved reference; keep a named function or use the signal option. Second, WeakMap (below) helps only for the "keyed by an object" case, and does not replace cleanup.

    Cleanup that scales: an abort signal

    The signal option of addEventListener removes the listener when the AbortController that owns the signal is aborted. One controller per panel can own many listeners:

    function openPanel(ticket) {
      const panel = renderPanel(ticket);
      const lifetime = new AbortController();
      const { signal } = lifetime;
    
      window.addEventListener("resize", () => panel.fit(), { signal });
      document.addEventListener("keydown", (e) => panel.onKey(e), { signal });
      const timer = setInterval(() => panel.refresh(), 5000);
      signal.addEventListener("abort", () => clearInterval(timer));
    
      return function closePanel() {
        lifetime.abort();   // removes both listeners and clears the timer
        panel.remove();
      };
    }

    The panel's lifetime now has one owner and one exit. AbortController is Baseline widely available (MDN, since March 2019). The same signal also cancels a fetch, which matters for the late-completion row above.

    Weak references: what they promise

    MDN states the rules. A WeakMap holds its keys weakly: a key being in a WeakMap does not stop it being collected, and the value is then a candidate for collection too, if nothing else holds it. Keys must be objects or non-registered symbols, and a WeakMap cannot be enumerated, because that would make results depend on collection. A value that refers to its own key does not keep the key alive: the entry is an ephemeron, not a pair of references.

    const layoutCache = new WeakMap();   // keyed by the panel element
    
    function layoutOf(panel) {
      let layout = layoutCache.get(panel);
      if (!layout) {
        layout = measure(panel);
        layoutCache.set(panel, layout);
      }
      return layout;
    }

    When the panel becomes unreachable, the cached layout becomes collectable. MDN notes two limits: a WeakMap cache works only for object inputs, and the value stays as long as the key is alive. WeakRef and FinalizationRegistry are weaker still: MDN says to avoid them where possible because their semantics are "almost completely unguaranteed", and a finalizer may never run. Use weak keys for caches, and explicit cleanup for anything that must happen.

    Finding a leak: do, undo, compare

    Chrome's DevTools documentation describes a two-snapshot method: take a snapshot, perform an operation, undo it by repeating the opposite interaction a few times, take a second snapshot, and compare.

    Figure 2. The measurement loop

    flowchart TD accTitle: Measuring a leak with heap snapshots accDescr: Reproduce with an action and its undo, force a collection and take a first snapshot, repeat the action and undo a few times, take a second snapshot, compare, filter for detached nodes, read the retainers, fix, and repeat the loop. A["Warm up: do the action and its undo once"] --> B["Take snapshot 1
    (starts with a collection)"] B --> C["Repeat action and undo
    several times"] C --> D["Take snapshot 2"] D --> E["Comparison view:
    objects allocated between 1 and 2"] E --> F["Filter: objects retained
    by detached nodes"] F --> G["Retainers pane:
    read the chain to a root"] G --> H["Fix, then run the loop again"]

    How to read this: the undo matters. If closing the panel is a true inverse of opening it, nothing from the cycle should remain, so whatever the comparison shows between snapshots is a suspect.

    What the tools show, from the DevTools pages:

    • Task Manager and Performance recordings show whether memory keeps growing. Enable the Memory checkbox, start and end with a forced collection, and compare: a JS heap that ends higher than it began after a forced collection can indicate a leak.
    • Heap snapshots show only objects reachable from the global object, and "taking a snapshot always starts with garbage collection". The Summary view groups by constructor; typing Detached in the Class filter lists detached elements; the Comparison view shows what was allocated between two snapshots.
    • Shallow size is the memory held by the object itself. Retained size is the memory you would free by deleting it, because its dependents would go too. A small detached div that holds a chart and a dataset has a large retained size.
    • The Retainers pane lists what points at the selected object. Reading it from the object up to a Window or a registry is the whole diagnosis. Allocation sampling and the Detached elements profile help find where objects are created and which DOM nodes are retained.

    Frequent short collections look different: memory repeatedly rises and falls. That is allocation churn, not a leak.

    Build it: a leak detector on an object graph

    Goal. Watch the difference between "removed from the page" and "unreachable" on a small graph, find what keeps a view alive with a retainer path and a retained size, and see a fix turn growth into a flat line.

    Setup. The lab is examples/frontend/labs/leak-detector-model. It needs Node.js (captured on Node 26.12.1).

    cd examples/frontend
    pnpm install
    cd labs/leak-detector-model
    pnpm test            # 38 tests
    node src/demo.ts

    Steps.

    1. Read section 1: a and b point at each other and are freed, because nothing reachable reaches them. held stays.
    2. In section 2, compare the two lines. As written, the live size grows by 33 abstract units per cycle and three detached DOM nodes pile up each time. With the fix, it is 9 every cycle.
    3. In section 3, read the retainer path for view-0. Match it to Figure 1. The shallow size of view-0 is 2, but its retained size is 31, because the canvas, toolbar and data live only through it.
    4. In section 4, the comparison between two snapshots lists exactly the six objects one open-and-close cycle left behind.
    5. In section 5, the timer and the strong Map produce the same kind of growth; the fix is clearInterval and a weak-keyed entry. Section 7 shows how the series is classified.

    Expected output, captured from a real run (Node 26.12.1, 2026-11-08):

    === 1. Reachability: a cycle is freed, a held object is not
      graph: window -> held; a <-> b (a and b point at each other, nothing points at them)
      reachable from the roots: held, window
      collect: freed a, b (size 4); live size 4
    
    === 2. resize-listener: open and close one view, five times
      as written  : live size after each forced collection = 42, 75, 108, 141, 174
                    nodes = 11, 17, 23, 29, 35; detached DOM nodes = 3, 6, 9, 12, 15
                    verdict: growing
      with the fix: live size after each forced collection = 9, 9, 9, 9, 9
                    nodes = 5, 5, 5, 5, 5; detached DOM nodes = 0, 0, 0, 0, 0
                    verdict: stable
    
    === 3. What keeps the closed view alive? (resize-listener, as written, after 2 cycles)
      detached subtree view-0: shallow size 2, retained size 31
        retainer path: window --resize listener--> onResize-0 --closure scope--> scope-0 --captures view--> view-0
      detached subtree view-1: shallow size 2, retained size 31
        retainer path: window --resize listener--> onResize-1 --closure scope--> scope-1 --captures view--> view-1
    
    === 4. Snapshot comparison: cycle 3 against cycle 4 (resize-listener, as written)
      snapshot 3: 23 objects, size 108; snapshot 4: 29 objects, size 141
      added between them (6): canvas-3, data-3, onResize-3, scope-3, toolbar-3, view-3
      removed: none; size delta 33
    
    === 5. All three scenarios, eight cycles each
      scenario          as written (final size, verdict)   with the fix (final size, verdict)
      resize-listener    273, growing                         9, stable
      interval-timer     513, growing                         9, stable
      element-cache      290, growing                        10, stable
    
    === 6. Weak-keyed entries: what they do and do not promise
      entry (key view-0, value layout-0) with nothing else holding either: live = false
      same entry, but app still references view-0: key live = true, value live = true
    
    === 7. Reading a series of measurements
      100, 100, 100, 100   -> stable
      100, 133, 166, 199   -> growing
      100, 133, 120, 150   -> noisy
      100, 133             -> too-few-cycles

    This models the idea, not V8. The sizes are abstract units, the collector is a plain mark and sweep with no generations, and whether a real closure keeps a particular variable alive depends on the engine. The lab cannot say how much memory a real page uses, or how long a real collection takes. It shows reachability, retainer paths and the shape of a leak.

    Break it. In src/heap.ts, in reachable, drop the live.has(eph.key) condition so weak entries behave like strong ones. Four tests fail, among them the weak-key tests and the element-cache scenario. Revert.

    Stretch. Model an AbortController that removes every listener added with its signal, and show one abort() replacing three removeEventListener calls.

    Fixes compared

    Approach Prevents Cost Weakness
    A cleanup function returned by open Listeners, timers, subscriptions One exit path per feature A forgotten registration still leaks
    AbortController owning the lifetime Many listeners and requests at once Pass a signal everywhere Needs APIs that accept a signal
    WeakMap or weak-keyed cache Caches keyed by an object No enumeration, values live while the key does Does not stop a strong reference elsewhere
    Bounded cache (size limit, eviction) Unbounded growth A policy to tune Wrong size either wastes memory or misses
    Framework lifecycle (effect cleanup) The above, through the framework Learn its rules Easy to bypass with a global
    Scripted leak test in CI Regressions Test time, flakiness Detects only the cycles you script

    Figure 3. Who outlives whom?

    flowchart TD accTitle: Choosing a leak fix by lifetime accDescr: If the holder lives for the whole session and the held object is short-lived, remove the reference at close, use an abort signal, or key weakly. If both live together, nothing to fix. If the collection only grows, bound it. Q1{"Does the holder outlive
    the thing it refers to?"} -->|"no"| OK["Same lifetime: no leak"] Q1 -->|"yes"| Q2{"A collection that
    only grows?"} Q2 -->|"yes"| B["Bound it or evict"] Q2 -->|"no"| Q3{"Keyed by the object
    itself?"} Q3 -->|"yes"| W["WeakMap key"] Q3 -->|"no"| C["Release in a cleanup path:
    remove, clear, abort"]

    How to read this: the question is always about relative lifetimes. A leak is a longer-lived holder pointing at a shorter-lived thing.

    Illustration 1: the minimal case. A panel adds one anonymous resize handler to window that calls panel.fit(). Closing the panel calls panel.remove(). The handler is still in window's list, and its scope still refers to the panel. One panel is a few kilobytes, which nobody notices.

    Illustration 2: the realistic case. An agent handles 200 tickets in a shift (illustrative). Each opened panel leaves behind its DOM, its dataset and a timer that keeps polling the server for a ticket nobody is looking at. After the shift the tab holds 200 dead panels, and every one of them is still running network requests. Growth is linear per panel, so the heap line is a staircase.

    Illustration 3: when growth is not a leak. A tab's heap rises after the first panel opens, because code and lookup tables load once, then stays flat. That is warm-up, so do the action once before the baseline: the lab treats cycle 0 as the warm-up and compares later cycles with it. Skip the warm-up and the series "100, 133, 133, 133" is classed as noisy, not as growth, and "100, 133" is too short to judge. A cache that fills to a fixed cap also levels off; a leak keeps climbing.

    In practice: a console that slowed down all shift

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number is an illustrative assumption.

    The situation

    A support console is open for an eight-hour shift on shared laptops. Agents report that by the afternoon typing lags, charts redraw slowly, and sometimes the browser reloads the tab. Three engineers own the front end, and the first fix attempt (a smaller chart library) changed nothing.

    Starting architecture

    Figure 4. What a panel registers when it opens

    Each ticket panel registers a listener, a timer and a cache entry on long-lived objectsTab router to Ticket panel (create, remove); Ticket panel to window (registers); Ticket panel to Timer list (registers); Ticket panel to Layout cache (stores)WHOLE-SESSION OBJECTS (ROOTS)Tab router: opens and closes panelsTab routeropens and closespanelsTicket panel: DOM, chart, dataTicket panelDOM, chart, datawindow: resize listenerswindowresize listenersTimer list: 5 s refresh intervalTimer list5 s refreshintervalLayout cache: module-level MapLayout cachemodule-level Mapcreate, removeregistersregistersstores

    How to read this: the panel is created and removed by the router, but it leaves three registrations in objects that live for the whole session. Each is a separate path that keeps a closed panel alive.

    Options considered

    Option What it fixes What it costs Risk
    Remove each registration by hand in close The three known leaks Edits in three files The next feature forgets
    One AbortController per panel, plus a WeakMap for the cache The class of leak A convention, a small helper Code that ignores the helper
    Restart the tab on a timer Hides the symptom Lost agent state Does not fix anything

    The decision

    Field Decision record
    Context Memory grows linearly with panels opened; each closed panel is retained.
    Decision Every panel gets one AbortController; all listeners and requests take its signal; timers are cleared on abort; the layout cache becomes a WeakMap.
    Consequences, good One exit path, and the leak class is closed for new features.
    Consequences, bad Everything that registers must accept a signal.
    Revisit when A scripted open-and-close test still shows growth.

    Rollout and verification

    1. Reproduce with 50 open-and-close cycles (illustrative), forced collection, snapshot before and after, filter for detached.
    2. Fix and compare with the Comparison view: the six-object pattern per cycle should disappear.
    3. Add a regression test in an end-to-end run that opens and closes a panel repeatedly and fails if the node count after collection keeps growing.
    4. Check on a low-memory laptop, where growth shows first.

    The 2 a.m. failure

    A week later one agent still reports slowdown. A heap snapshot shows detached panels again, retained by a fetch callback. The panel's data request is slow; the agent closes the panel; the cleanup runs; then the response arrives, the .then handler calls panel.render(data) and re-registers a resize listener on the closed panel with no signal to remove it. First three checks: (1) read the Retainers pane for a detached panel; (2) look for registrations that happen in asynchronous callbacks; (3) compare timing of close and response in the logs. Mitigation: pass the panel's signal to fetch so the request is cancelled on close, and have callbacks return early if signal.aborted is true. Follow-up: a lint rule against addEventListener without signal inside panel code.

    Architect's notes

    • Decide object lifetimes in the design. For each feature say what owns it and when it ends. A cleanup function that returns from open is the cheapest version of that decision.
    • Prefer making leaks hard to write. One helper that owns listeners, timers and requests beats a review comment.
    • Bound every cache. Either a size limit, an eviction rule or a weak key. "It will not get big" is a guess.
    • Test the loop, not the function. A scripted open-close test with a collection and a node count catches regressions; a unit test of one cleanup does not.
    • Mind the platform you run on. Memory pressure on a low-end phone arrives long before a developer laptop notices.

    Common mistakes

    • Treating "removed from the page" as "freed".
    • Adding an anonymous listener with no way to remove it.
    • Using WeakRef or FinalizationRegistry for cleanup that must happen.
    • Taking one snapshot and declaring a leak, without an undo, a collection or a second snapshot.
    • Counting a warm-up cost, or a cache that levels off, as a leak.
    • Fixing the leak you found and not the one behind it: asynchronous callbacks that run after cleanup.

    Key takeaways

    • An object is freed when it is unreachable from the roots; "no longer needed" has no meaning to the engine.
    • A leak is a longer-lived holder (window, timer list, module cache) keeping a shorter-lived object reachable.
    • Detached DOM nodes, uncleared timers, unremoved listeners, unbounded caches and uncancelled async work are the usual sources.
    • Find leaks by action, undo, forced collection, two snapshots, the Comparison view and the Retainers pane; retained size shows what is at stake.
    • Give each feature one owner of its lifetime, such as an AbortController, and key caches weakly or bound them.
    • WeakMap is for object-keyed caches; WeakRef and FinalizationRegistry guarantee nothing you can depend on.
    • A rising line after warm-up and a forced collection is evidence; a single bump is not.

    Practice exercises

    Exercise 1 (45 min): Write a page that opens and closes a panel with a window resize listener. Take a heap snapshot, repeat open and close ten times, take a second, and use the Comparison view to find what remained. What does the Retainers pane say?

    Exercise 2 (1 hour): Fix Exercise 1 with an AbortController signal. Repeat the measurement. What does the second snapshot show now?

    Exercise 3 (2 hours): In the lab, add a fourth scenario: an observer that registers a callback on a long-lived store and is never unsubscribed. Show it grows, and that an unsubscribe fixes it.

    Exercise 4 (half day): Script an end-to-end test that opens and closes a view 20 times, forces a collection through your test tooling, and fails if the node count keeps rising. What cases would it miss?

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments