Skip to content
Core Web Vitals specialists
Front-end optimisation

Frontend Memory Leaks: When Long Sessions Slow Down

The interface starts fast and turns sluggish after twenty minutes: how front-end memory leaks arise, how to measure them over time and how to get rid of them.

12 min read FrontendJavaScriptINPSaaSSpeicher

The first impression is right: the application is there in under two seconds, the view is up, clicks land instantly. Two hours later the same person sits in front of the same interface and waits a heartbeat too long after every click. Nothing changed in the code, nothing in the network, nothing on the server -- the only thing that changed is how long the session has been running. This decay over time is the blind spot of the usual speed measurement: a test run opens the page, measures the first few seconds and closes it again. It sees the state in which the application is at its briskest -- not the state in which your users spend most of their working day. Around 23 percent (Web Almanac 2025) of mobile pages miss the INP threshold of 200 milliseconds, and some of them only fail as the session goes on. Behind it lies the same pattern time and again: the page stops releasing memory it has long since ceased to need. This article shows how such leaks arise in the front end, why they hit response time of all things, how to measure them across a session rather than in a snapshot -- and how to get rid of them. In our front-end optimization work this is a recurring finding in interfaces with long working sessions.

Key takeaways

  • A loss of speed does not have to originate at first load. An interface can start fast and still respond noticeably sluggishly after twenty minutes of work -- the trigger is memory that is not released over the course of the session.
  • Five causes cover the bulk of cases: event listeners that are not removed, detached DOM trees left behind by view changes, timers and observers that keep running without disconnect, unbounded in-memory caches, and image buffers in tables with infinite scrolling.
  • What matters is the trend, not the single value: three heap snapshots across a long session, a count of detached nodes and the curve of event listeners reveal the defect that a single-run test typically fails to show.
  • In the field, performance.measureUserAgentSpecificMemory() has provided an estimate of a page's memory use since Chrome 89 (MDN); the interface works asynchronously and requires cross-origin isolation (MDN).
  • A value below 200 milliseconds (web.dev) counts as a good Interaction to Next Paint. Longer garbage collection pauses run on the same main thread as input handling and push every response further back.

When the interface only turns sluggish after twenty minutes

A browser does not release memory on request. The JavaScript runtime's garbage collection clears away exactly what is no longer reachable from anywhere in the program -- everything else stays put, even when it has long become pointless in business terms. A front-end memory leak is therefore rarely a runtime fault; it is usually a forgotten reference: a listener still attached to the document, a timer still running, an object in a list nobody ever empties. Every view change adds something, and the baseline climbs step by step. This does not show up as a crash but as sluggishness: collection pauses grow longer and more frequent, and because they run on the same main thread that processes clicks and key presses, every response is pushed back by a few milliseconds. A value below 200 milliseconds (web.dev) counts as a good Interaction to Next Paint, measured at the 75th percentile (web.dev) of interactions; above that, the delay becomes noticeable.

The tricky part is the measurement gap. A single-run test loads the page in a fresh profile, measures the first few seconds and ends the session again -- it only ever sees the application at its most favourable. That is precisely why a leak stays inconspicuous in such reports while, in everyday use, it hits every case worker who keeps the interface open for eight hours. It is true that 77 percent (Web Almanac 2025) of mobile pages achieve a good Interaction to Next Paint, but that distribution comes from short visits. In a line-of-business application with long sessions the picture shifts considerably as soon as you look at the fortieth minute instead of the first. How to keep lab and field values properly apart is described in the article on RUM, CrUX and field data.

A memory leak is not the same as memory usage

An application that occupies a lot of memory does not therefore have a leak. What counts is the trend: if the baseline sits higher after every collection pause than after the previous one, something is being held that ought to be released. If the floor stays constant, the application is simply working with a large but stable set of data. So the question is not 'how much memory' but 'how does the floor develop across the session'.

Five causes we find regularly

In projects involving line-of-business interfaces and portals, the same five patterns typically show up at the core. First, event listeners that are not removed: a component attaches itself to window, document or a global event bus, is taken out of the document on a view change and does not deregister -- the listener keeps the entire component and its data alive. Second, detached DOM trees: a subtree disappears from the document, but a variable, a cache or a closure scope still holds a reference to it; the node is invisible and still occupies memory. Third, timers and observers that keep running: setInterval without clearInterval, a MutationObserver, ResizeObserver or IntersectionObserver without disconnect -- they carry on cheerfully after the view change and burn processing time for a view nobody is looking at any more. Fourth, unbounded in-memory caches: a Map that collects server responses, a log buffer, an undo history without an upper limit. Fifth, image buffers in tables with infinite scrolling: every page loaded appends further rows and further decoded images while the ones scrolled out of sight long ago simply stay.

These five patterns differ in their effect. Listeners and observers additionally cost processing time because they run again on every matching event -- so they drive not only memory but response time directly. Detached nodes and unbounded caches act more slowly, but steadily. It is worth drawing a line to three related topics: reducing DOM size is about the static node count of a page -- a median mobile page comes to 594 HTML elements, and 1,716 at the 90th percentile (Web Almanac 2024). Breaking up long tasks with scheduler.yield() is about the length of individual work packages, and moving work into web workers is about where the computing happens. Here, by contrast, the subject is decay: all three metrics can look impeccable at the start of a session and still get out of hand an hour later.

Deregistering listeners, timers and observers

The most effective lever is also the simplest: every registration needs a deregistration, and both belong at the same point in a component's life cycle. In practice this rarely fails for lack of knowledge but for lack of bookkeeping -- with a dozen listeners per view you lose track of which one has to be released where. One AbortController instance per view takes that bookkeeping off your hands: all listeners are given the same signal, and a single call to abort() releases them together (MDN). Observers have their own deregistration in disconnect(), and timers are stopped with clearInterval or clearTimeout.

view-lifecycle.js
function startView(container) {
  const controller = new AbortController();
  const { signal } = controller;

  window.addEventListener('resize', recalculate, { signal });
  document.addEventListener('keydown', shortcut, { signal });

  const observer = new ResizeObserver(recalculate);
  observer.observe(container);

  const ticker = setInterval(fetchData, 30000);

  // Call exactly once on every view change
  return function stop() {
    controller.abort();
    observer.disconnect();
    clearInterval(ticker);
    container.replaceChildren();
  };
}

The return value is the decisive part here: the function that opens a view immediately supplies the function that tears it down again. The router or component framework then only has to make sure this one function is called on every view change -- instead of thirteen scattered deregistrations in thirteen places. For data that should outlive a component without keeping it alive, WeakMap and WeakSet are a good fit: they hold their keys only weakly, so garbage collection may collect an object as soon as nothing else references it (MDN). In addition, WeakRef and FinalizationRegistry from ECMAScript 2021 allow deliberately weak references (MDN) -- a tool for special cases, not a substitute for clean teardown.

What gets left behind on a view change

View changes are the moment when most things go wrong -- particularly in applications that jump between areas without a full page load. The old subtree disappears from the document, but not necessarily from memory. How such route changes can be captured properly in the first place is covered in the article on soft navigations in single-page applications. Three places tend to be the most rewarding:

Detached nodes

A subtree removed from the document stays in memory in full as long as any variable, cache or closure scope still points to it. In the developer tools heap snapshot such nodes carry the label 'Detached' and can be counted right there (Chrome for Developers).

Unbounded caches

A Map that keeps every server response, an undo history without an upper limit, a log buffer that only grows: each of these stores stays harmless as long as a session is short. Over hours it becomes the single largest item in the heap. The remedy is a fixed upper limit with eviction of the oldest entries.

Image buffers in endless lists

Tables and tile views with infinite scrolling append further rows and further decoded images with every page. Decoded image data weighs a multiple of the transferred file, and the median mobile page already brings 2,362 KB (Web Almanac 2025) with it at load time. The remedy is virtualization, as described in the article on content-visibility and skipped rendering work.

ObservationSnapshot (single run)Trend across the session
Heap sizeonce, shortly after loadfloor after every collection pause
Detached nodesusually zerogrow with every view change
Event listenersinitial countincrease per view opened
Response timestate right after loaddegrades step by step
Typical findinginconspicuousbaseline climbs step by step

Measure the trend, not the snapshot

Anyone hunting a leak needs a second axis: time. In the lab the route runs through a deliberately long, scripted session -- twenty to forty view changes, the same steps in the same order -- and through three heap snapshots: one right after load, one halfway through the changes, one at the end, each taken after a forced collection. Comparing the third with the first, the growth in retained objects shows very precisely what was not released; counting detached nodes is the quickest way in (Chrome for Developers). In addition, the performance panel records the heap, the number of documents, DOM nodes and event listeners as a curve over time -- a listener count that rises by the same amount with every view change is an unambiguous finding. In the field, performance.measureUserAgentSpecificMemory() has provided an estimate of a page's total memory use since Chrome 89 (MDN); the interface works asynchronously and requires the page to be served cross-origin isolated (MDN). The older performance.memory property, by contrast, is non-standard and available in only some browsers (MDN) -- usable as a rough trend indicator, not as a dependable metric. Anyone who wants to see the link to response time directly places the memory curve next to the long animation frames, which have shown since Chrome 123 (Chrome for Developers) which script caused a long frame.

The core in one sentence

You do not spot a memory leak by a high number but by a rising line: when the floor sits higher after every collection pause than after the previous one, the application is holding on to something it should long since have let go.

Limits, misdiagnoses and special cases

Not every rising curve is a leak. Garbage collection only cleans up when it is worth doing and deliberately leaves rubbish lying around until then -- a curve that grows over a few minutes may simply mean that no collection has been necessary yet. Meaningful, therefore, is solely the floor immediately after a collection, not the peak in between. Nor is a comparison across different devices any use: a runtime's memory budget follows the available RAM, and the same code behaves differently on a notebook than on a three-year-old company phone. The estimates from the memory interfaces are deliberately imprecise as well, because an exact figure would allow conclusions about foreign content in the same process (MDN). And finally: if memory grows because the application really is holding more data -- an open table with ten thousand rows, say -- that is not a defect but a question of presentation, as covered by reducing DOM size.

Four false conclusions that cost time

First: a high memory figure on its own is not yet a finding -- what counts is how the floor develops. Second: a single-run test can hardly show this defect because it ends the session before the interesting part. Third: a forced collection in the developer tools distorts the measurement if it is only performed before one of the snapshots -- it belongs before each of them. Fourth: reloading the page as a supposed fix merely postpones the problem to the next session while simultaneously hiding it in the field data.

How to proceed in your own application

Getting started does not require a large project. What matters above all is the order: reproduce first, then measure, then fix, then follow up in the field. Five steps are enough for a first pass:

  1. Recreate your users' typical working session: which views are switched in which order and how often, and how long does the interface stay open?
  2. Repeat that sequence twenty to forty times under script control and take three heap snapshots, each after a forced collection
  3. Determine the growth in retained objects, detached nodes and event listeners between the first and the last snapshot
  4. Resolve the references you found at their source: one teardown point per view, upper limits for caches, virtualization for long lists
  5. Then collect memory and response time in the field -- only there does it become clear whether the long session really stays stable

That is exactly how a performance analysis runs at our end when decay over time is suspected: we recreate the real working session, measure the trend rather than a single run, name the retaining references explicitly and put the teardown points where they belong. For SaaS interfaces and B2B portals with long working sessions we add long-term measurement, because a single-run tool typically fails to make this particular defect visible. That front-end memory and server load are two separate building sites which nevertheless like to appear together is shown by the articles on PHP runtime tuning with OPcache, JIT and FPM workers and on load testing before peak season.

The difference between a fast application and a pleasant one rarely lies in the first second. It lies in the fortieth minute -- and that is where it is decided whether someone enjoys working with it or reloads it once every afternoon.

Project experience from performance work on line-of-business interfaces

The effort for the fix usually turns out smaller than feared, because the causes concentrate in a few places: one teardown point per view, one upper limit per cache, virtualization for that one long list. The diagnosis is the more demanding part -- and that is precisely where a systematic approach pays off instead of guesswork. Anyone who wants to understand response time in a wider context will find it explained in the article on optimizing Interaction to Next Paint. So that a leak once fixed does not return with the next release, the measurement belongs in the build pipeline, as described in the article on performance budgets in CI. Which services belong to a project like this is set out in our overview of performance services.

Sources and Studies

This article is based on data from web.dev, MDN Web Docs, Chrome for Developers and the 2024 and 2025 editions of the Web Almanac by the HTTP Archive. The figures quoted refer to the state of each publication.

Related Articles