A frontend testing methodology

Frontend Soak Testing

Find the bugs that only appear after hours, in minutes.

Most frontend bugs show up straight away. A few only show up the four hundredth time someone opens the same panel. Frontend soak testing goes looking for that second kind: the listeners, timers, subscriptions, and detached nodes that build up in a long session until the tab is slow, unresponsive, or dead. It finds them by compressing hours of realistic usage into a test that runs in minutes.

Soak run 1,000 iterations
Two traces over one thousand iterations. One holds flat near 100 megabytes. The other climbs steadily to 350 megabytes.
Baseline
100 MB
After 1,000
350 MB
Slope
+0.25 MB / iteration
Illustrative figures. The slope is the whole point.
The central idea

Time compression

Soak testing normally means leaving something running for hours or days and watching what happens to it. On the frontend that's rarely practical, and it's rarely necessary either. Three techniques together let a test build up the same wear in a fraction of the time.

Traditional soak test

8 hours of real time

  1. 1Start the application
  2. 2Leave it running
  3. 3Observe degradation

Wall clock time, to scale

Real time and application time move together, so usage builds up only as fast as the clock on the wall.

Frontend soak test

Minutes of real time

  • Thousands of interactions The same journey repeated back to back, as fast as the browser runs it.
  • Accelerated application time A virtual clock moves timers, polling, and retries forward without waiting for them.
  • One persistent browser session Nothing is torn down between iterations, so leaked listeners and retained nodes are still there at the end.

Same scale. The application still goes through the full period.

The result: hours of simulated application usage inside a test that finishes in minutes.

Both bars are on the same scale. The application goes through the same amount of use either way.

Rapid iteration

Hundreds or thousands of realistic user journeys or component lifecycles, run back to back as fast as the browser accepts them. That's what builds up the lifecycle pressure: every mount, unmount, subscribe, and teardown is another chance for the app to leave something behind.

Time acceleration

Playwright's clock controls move the application's idea of time forward without waiting for it. A poller on a thirty second interval can fire on every iteration instead. This covers behavior driven by timers and dates. The browser still renders at its own speed, and the network still takes as long as it takes.

Persistent session

The same browser, the same page, and the same application lifecycle stay alive across every iteration. This is the part that's easiest to get wrong. Restart the browser between iterations and the leaked listeners and retained nodes go with it.

Definition

The definition

Frontend soak testing is the practice of repeatedly exercising a real web application over an extended simulated period to detect cumulative client side resource leaks, memory growth, and performance degradation.

The word simulated is doing real work in that sentence. The test doesn't have to run for the period it represents. The application has to go through the same amount of use.

Conventional end to end tests are short lived by design. They start from a clean state, perform a handful of actions, check the result, and exit. That's exactly right for the question they're asking, and it's why they're fast and stable enough to run on every commit.

It also means they never see the kind of failure that only comes from repetition. You can't spot a leaked listener in one mount. You spot it by comparing the first iteration with the four hundredth.

Single page applications made this matter more than it used to. We used to get a full page reload every time someone clicked a link, and that cleared out whatever we'd leaked. A page that once lived for ninety seconds now lives for a whole working day. Dashboards get left open, editors get left open, internal tools get left open across a shift. Anything the app doesn't release has all day to add up.

The gap

End to end tests stop too early

There's nothing wrong with how they're written. They close the browser before anything has had time to build up.

Conventional end to end test

  1. Open application
  2. Perform action
  3. Assert result
  4. Close browser

The process exits before anything has had a chance to build up, and every leak goes with the browser.

Frontend soak test

  1. Open application
  2. Perform action
  3. Measure resources
  4. Repeat, hundreds or thousands of times loop
  5. Analyze resource growth

The session stays alive across every iteration, so anything the app doesn't release is still there to measure.

The difference is where the process ends: before anything builds up, or after.

An app can pass every conventional end to end test in the suite and still leak resources every single time a particular lifecycle runs. The checks all pass. The journey works. The button opens the dialog and the dialog closes. Nothing in that test can notice that closing the dialog left its scroll listener attached, along with the closure that keeps the component's whole subtree in memory.

Running the same journey a thousand times gives you this instead:

Heap size against iteration count for a healthy and a leaking application The healthy application stays near 100 megabytes across all one thousand iterations. The leaking application starts at the same 100 megabytes, reaches 102 megabytes by iteration ten, 125 megabytes by iteration one hundred, and 350 megabytes by iteration one thousand. 400 MB 300 MB 200 MB 100 MB 1 10 100 1,000 Iterations of the same user journey (log scale) Healthy: flat Leaking: 350 MB and climbing A conventional suite has finished by here
Illustrative figures, not measurements from a real application.
Iteration 1
100 MB
Iteration 10
102 MB
Iteration 100
125 MB
Iteration 1,000
350 MB

350 MB on its own doesn't tell you much. A photo editor can legitimately use more than that, and so can a data grid with a large result set loaded.

The number keeps climbing every time the same journey runs, and shows no sign of stopping. That's the part worth investigating. An app that climbs to 140 MB and then stays there has filled a cache. An app that climbs in proportion to how many times you opened a dialog has a leak, and the slope tells you roughly how much memory each open and close leaves behind.

The rest of this page is about that difference: memory that builds up in step with repeated behavior, rather than one big number. The methodology covers how to design a test that gives you that evidence reliably, and the signals cover which measurements are worth trusting.

Where it fits

Compare it to other testing

Frontend soak testing sits alongside the techniques below rather than replacing any of them. It answers a question none of the others ask.

A comparison of five testing approaches by the question they answer, what they exercise, and the failures they typically catch
Approach The question it asks Typical lifespan What it catches
Unit test “Does this piece of code behave correctly?” Milliseconds, no browser Logic errors in isolated units
End to end test “Does this user journey work?” Seconds, one fresh session Broken journeys, integration failures, regressions in behavior
Load test “Does the system handle many users or requests?” Minutes to hours, server side Capacity limits, backend saturation, throughput ceilings
Frontend performance test “How quickly does the system respond?” One page load or interaction Slow loads, layout shift, sluggish interactions
Frontend soak test “Does this frontend remain healthy after prolonged, repeated use?” Many iterations, one continuous session Cumulative leaks, unbounded growth, degradation over a session

A load test and a soak test sound related, and in server side testing they're close neighbors. On the frontend they're doing completely different jobs. A load test asks whether the system copes with many users at once, and mostly doesn't involve a browser at all. A frontend soak test uses one user, in one real browser, for a long simulated time, because the failures it looks for live in the client where HTTP level tooling can't see them.

Methodology

Design a frontend soak test

A soak test is more than an end to end test with a loop around it. The decisions below are what separate a run you can trust from one that returns noise, or that quietly destroys the evidence it was built to collect.

01Use a real browser

Everything this methodology looks for lives in the client: the retained DOM subtree, the listener that was never removed, the interval still ticking after its component was destroyed. None of it is visible from outside the browser.

HTTP level load testing tools send requests and read responses, and they're very good at it. A DOM, a framework reconciler, and a JavaScript heap that can grow are all outside what they do. A frontend soak test needs a real browser engine running the real application bundle, with real rendering and real garbage collection, because that's the only place these failures happen.

It also has to be a browser you can inspect from the outside. The most useful client side counters come from browser instrumentation, which in Chromium means the DevTools Protocol, so the engine you pick decides which signals you can collect at all.

02Keep the same session alive

Of the ten steps here, I think this is the one teams break by accident most often.

The browser, the page, and the application lifecycle all stay alive across every iteration. Reusing the browser between tests isn't enough. It has to be the same page, never reloaded and never navigated away from, from the first iteration to the last.

Restarting the browser hides the leak

Every leak this methodology looks for is stored in memory owned by the page. When the page closes, the operating system reclaims all of it, leaked or not. A test harness that opens a fresh context per iteration measures one iteration, a thousand times over, and reports a perfectly flat line no matter how badly the app leaks.

The same thing happens with a full page reload inside the test, or a journey that navigates to a URL instead of routing client side. If your framework tears down and rebuilds the application root, whatever built up usually goes with it.

03Define a realistic user journey

The journey is the unit of pressure. It wants to be something a real person actually does over and over, and it wants to end where it started.

A journey that finishes in the state it began in gives you a clean expectation: the counts at the end of iteration 500 should look like the counts at the end of iteration 5, and any difference is something the app didn't clean up. A journey that builds up on purpose, like an infinite scroll feed or an append only log view, has no such expectation, so growth there tells you nothing.

  • Navigating between routes and back again
  • Opening and closing dialogs, drawers, and sheets
  • Mounting and unmounting a component
  • Creating a record, then deleting it
  • Switching tabs and returning to the first
  • Updating a chart with new data
  • Opening and closing a document in an editor
  • Applying a table filter, then clearing it
  • Resizing a panel and restoring it
  • Opening a menu and dismissing it
  • Subscribing to a data source and unsubscribing
  • Connecting a real time resource and disconnecting it

Journeys that cross a lifecycle boundary work best. Mounting and unmounting, subscribing and unsubscribing, connecting and disconnecting: these are the moments when cleanup code either runs or doesn't. A journey that only reads state, without creating and destroying anything, won't exercise much worth measuring.

04Repeat the lifecycle

The journey runs hundreds or thousands of times, back to back, in the same session. The point is cumulative lifecycle pressure: enough repetitions that a few kilobytes or a single listener per iteration turns into a trend you can't miss.

There's no universal number. It depends on how much one iteration does, and how small a leak you want to be able to see. One leaked listener per iteration shows up almost immediately. A leak of 2 KB per iteration needs enough repetitions to rise above the noise floor of ordinary heap movement, which usually means hundreds at minimum.

Iterations run as fast as the application accepts them. Waiting for animations, arbitrary sleeps, and real network round trips all slow the run down for nothing. Waiting on conditions instead of durations helps, and so does stubbing the network wherever the response content isn't what you're testing.

05Accelerate time

Rapid iteration compresses the usage that comes from interaction. Usage that comes from time passing is a separate problem. An application that polls every thirty seconds doesn't care how fast you click, because it needs thirty seconds to pass before it does anything.

Playwright's Clock API replaces the browser's time functions with controllable equivalents, so the test decides when time moves.

// Installed before the application loads, so it never sees the real clock.
await page.clock.install();
await page.goto('/dashboard');

// Time now stands still until the test moves it.
await page.clock.pauseAt(new Date('2026-01-01T09:00:00Z'));

// Moves 30 seconds of application time, firing every timer that falls due.
await page.clock.runFor(30_000);

runFor() moves the clock forward through the interval and fires the timers that fall due along the way. fastForward() jumps ahead instead, firing due timers at most once, which is what you want for skipping a long expiry window without replaying everything scheduled inside it. Installation has to happen before the application loads, because code that captures a timer reference at module scope keeps the real one otherwise.

Uses for clock control

Application behavior What acceleration buys you
Polling A widget that refetches every 30 seconds fires once per iteration instead of once per 30 seconds of waiting.
Scheduled updates Dashboards that refresh on a schedule complete a full cycle inside each pass.
Debounced operations A 500 ms debounce settles immediately when the clock is moved past it.
Throttled operations Throttle windows open on demand rather than in real time.
Retries Backoff schedules that would take minutes resolve inside a single iteration.
Timers Any setTimeout or setInterval the application sets can be made to fire.
Expiration Session timeouts, token expiry, and cache TTLs can be crossed deliberately.
Periodic refreshes Long interval background work becomes reachable within a short test.
Scheduled cleanup Deferred teardown runs, so you test the code path that was meant to release the resource.

The limits of a virtual clock

A virtual clock moves application time forward. The browser itself carries on at its normal speed: layout, paint, style recalculation, script execution, and garbage collection all take exactly as long as they take. Network requests take as long as the network takes. Faking timers doesn't make a response arrive sooner, and a request triggered by an advanced timer is still waited on in real time unless you stub it.

The two techniques do different jobs, so a soak test needs both. Clock control covers behavior driven by timers and dates. Rapid repeated interaction covers the lifecycle pressure of mounting, unmounting, and rerendering. Most applications leak through both.

There's a correctness risk too. An application that reads the clock in an unusual way, or that depends on real requestAnimationFrame pacing, can behave differently under a fake clock than it does in production. When results look strange, turning the clock off is a good first thing to try.

06Measure resources

Readings get taken at intervals through the run, not only at the end. The sequence of readings is the evidence. A first and last pair leaves you unable to tell a leak from a single step up.

DOM node count, registered event listener count, and JavaScript heap size cover most applications, plus whatever application specific counters you have. The signals below go through the full set, how precise each one is, and how to read it.

07Establish a baseline

The baseline gets measured after the application has warmed up, but before serious repetition begins. Every later reading is relative to that point.

The first few iterations of any journey pull in lazily loaded chunks, fill caches, compile hot functions, and allocate structures the application reuses for the rest of the session. All of that's real, and all of it's fine. Taking the baseline before it happens leaves a large jump between the baseline and everything after it, which looks exactly like a leak without being one.

A handful of warmup iterations, with five as a reasonable default, is usually enough to get past first run effects without hiding a real per iteration leak, because a real leak keeps growing long after the warmup ends.

08Analyze the trajectory

The shape of the whole run is what tells you something. One odd measurement on its own means very little, because browser memory moves around and any individual reading can land either side of a collection.

Fitting a line across the samples gives you two useful things: the slope, which is how much each iteration adds, and a measure of how well the samples actually fit that line. Growth of 8,000 nodes sitting on a straight line with a near perfect fit is a leak, and you know what one iteration adds. The same 8,000 nodes arriving in one jump at iteration 30 and staying flat afterwards is something else, probably a lazily loaded view, and worth understanding rather than failing on.

Flat No growth. The journey cleans up after itself.
Linear Steady growth per iteration. This is what a real leak looks like.
Step One jump, then flat. Usually lazy loading or first use.
Settled Growth that levels off. Typically a cache filling to capacity.

09Distinguish legitimate growth from leaks

Not all growth is a bug, and a methodology that treats it as one gets switched off in a week. Applications grow for several good reasons:

  • Caches. Query caches, image caches, and memoized results are supposed to consume memory. A bounded cache grows until it's full and then stops.
  • Lazy loading. Route chunks, fonts, and deferred modules arrive the first time a code path is reached, adding a permanent step.
  • Browser behavior. Engines keep their own internal caches and structures, and collection is scheduled at the engine's convenience rather than yours.
  • One time initialization. Long lived singletons, service workers, and connection pools allocate once and then stay put.
  • Real accumulated state. If the journey adds a row to a list and never removes it, memory should grow. That's the application working.

A leak is different because it keeps pace with repetition. Legitimate growth depends on which code paths have run at least once. A leak depends on how many times they've run. When growth tracks the iteration count and shows no sign of a ceiling, that's a leak signature.

Two things sharpen this. Doubling the iteration count leaves legitimate growth roughly where it was and roughly doubles a leak. Running the same soak against a build with the suspect behavior removed confirms the leak and locates it at the same time, if the slope flattens.

10Define a soak budget

A test needs a threshold to fail against. A soak budget is the maximum resource growth you're willing to accept over a defined number of iterations.

Saying it that way keeps the two halves together. “200 MB of heap” means nothing on its own. “No more than 5% heap growth across 500 iterations of opening and closing the report drawer” is a claim a test can check and a reviewer can argue with.

Budgets differ per signal. A leaked listener is nearly always a bug, so zero growth is a defensible budget there. DOM nodes need some tolerance, because engines keep a node or two around elements a flow removes, and a CSS pseudo element with generated content counts toward the total. Heap needs the most tolerance of all, and often works better recorded than checked, until you know what your application's noise floor looks like.

On the vocabulary: soak budget, soak loop, resource slope, and leak signature are terms this methodology uses to describe itself. They're not established industry standards, and a colleague won't recognize them without an explanation. They're useful because the concepts need names, not because anyone has standardized them.

The model, end to end

Put together, the methodology is a loop with an analysis stage attached, and the whole thing runs inside a session that never restarts.

Persistent browser session one browser, one page, one application lifecycle

  1. Application Loaded once, kept alive throughout.
  2. User journey A realistic sequence that ends where it started.
  3. Repeats Repeat lifecycle Mount, unmount, navigate, subscribe, unsubscribe, and move the clock on.
  4. Measure resources Nodes, listeners, heap, and anything else worth counting.
  5. Compare against baseline Every reading is relative to the state after warmup.
  6. Analyze growth A trend across many iterations, rather than one reading.
  7. Detect leak signature Growth that keeps pace with the repeated behavior.
The persistent session wraps everything else. Inside it, every step shares one page, one heap, and one application lifecycle.

Core terminology

Five terms cover most of what you need to describe a soak test precisely.

Soak loop
A repeated user journey or application lifecycle executed against the same application session.
Resource baseline
The resource measurements established before significant repetition, usually taken after a short warmup.
Resource slope
How fast a resource grows as iterations increase, measured per iteration rather than in total.
Leak signature
Resource growth that keeps pace with repeated application behavior, rather than a one off jump or a curve that levels off.
Soak budget
The maximum acceptable resource growth over a defined number of iterations, expressed as a threshold a test can fail against.

Running soak tests in CI

Endurance testing is rare on the frontend because the traditional form of it doesn't fit anywhere. A test that needs eight hours can't run on a pull request, and a check that can't run near the change finds the bug months later, in production, with nothing obvious to blame.

Time compression changes what fits. A few hundred iterations of a journey, with the clock moved on to cover the application's timers, finishes in the time an ordinary end to end suite takes. That's short enough to be a real feedback loop.

Different applications want different tiers. These numbers are examples rather than standards:

Tier Iterations When it runs What it is for
Smoke soak 100 Pull request Catch the obvious: a listener or subscription leaking on every iteration
Regression soak 1,000 Merge to main, or nightly Catch smaller per iteration costs across the main journeys
Deep soak 10,000+ Scheduled, weekly or nightly Catch slow accumulation and behavior that only appears at depth

Set expectations about stability

Resource measurements vary between runs, more so on shared CI hardware where the machine is busy and the browser competes for memory with everything else. Node and listener counts are stable enough to gate a merge on. Heap thresholds are much more likely to produce a flake, and a flaky check gets disabled.

Soak runs want a dedicated job with a single worker and no retries, so each run gets a browser to itself. Recording the numbers without failing on them for a while shows you what normal looks like for your application, and then you can set budgets you can defend. The reference implementation's own guidance is to treat longer runs as scheduled work rather than putting them on every pull request.

Reference

Signals worth measuring

Memory is the signal everyone reaches for first, and on its own it's the weakest one available. The counts that make a leak obvious are usually much more specific than a number of megabytes.

One memory reading proves nothing

One heap reading at the end of a run gives you a number that could mean almost anything. Say it reads 280 MB. That fits a serious leak, and it fits a healthy application that loaded a large dataset, filled three caches, and happens not to have been collected recently.

Several things move that number around, and none of them are bugs:

  • Garbage collection timing. The engine collects when it decides to, not when you measure. Two readings taken seconds apart, with identical application state, can differ by tens of megabytes purely because one landed before a collection and the other after it.
  • Caching. Query caches, image decoding caches, and memoized computations are all supposed to use memory. A cache filling to its bound looks like growth right up until it stops.
  • Lazy loading. The first visit to a route pulls in its chunk. The heap steps up once and stays there, permanently and correctly.
  • Application state. If the user has genuinely opened twelve documents, twelve documents' worth of memory is the right answer.
  • Engine internals. Compiled code, inline caches, and the engine's own structures all count toward what you're measuring, and they grow as more of the application runs at least once.

So you measure repeatedly, across many iterations of the same behavior, and look at how the count relates to the iteration number. And you measure things more specific than bytes, because a listener count is unambiguous in a way that a heap size never is.

The signals at a glance

Signal Precision Best used as
Event listeners Exact A hard assertion. Growth is nearly always a bug.
DOM nodes Near exact An assertion with a small tolerance.
Timers, sockets, observers, workers Exact, if instrumented A hard assertion on the specific resource you instrumented.
Application counters Exact, if instrumented The most targeted assertion available to you.
JavaScript heap Noisy A diagnostic, or a percentage threshold once you know your noise floor.
Detached DOM trees Manual Investigation after a run fails, rather than automated assertion.

JavaScript heap and memory

Heap size is the broadest signal: it catches leaks that never touch the DOM, which no node count can see. A poller that pushes every response into an array leaks steadily while the DOM stays perfectly flat.

Several ways to read it exist, and they differ in what they include:

  • The DevTools Protocol exposes JSHeapUsedSize through its performance metrics. This is what browser automation tools generally use. Chromium only.
  • performance.measureUserAgentSpecificMemory() gives a more complete picture, including memory outside the JavaScript heap, but requires the page to be cross origin isolated, which many applications are not.
  • performance.memory is the old non standard Chromium property. It's quantized for privacy reasons, and coarse enough to hide small leaks.

Whichever you use, a collection forced immediately before the reading matters, and more than one pass helps. One forced collection often leaves objects that a second pass clears, particularly in framework applications where teardown is deferred. In Chromium this needs the browser launched with --js-flags=--expose-gc. Without it you're measuring the collector's schedule as much as the application's behavior.

Heap readings drift between runs even on identical code, so an absolute megabyte threshold is worth being suspicious of. A percentage of the baseline, with a generous tolerance, is much more likely to hold up. Recording heap for a long time before ever failing a build on it is a reasonable way to start.

DOM node count

The number of nodes the renderer is keeping, whether or not they're currently in the document. In practice I find this the most useful signal of the lot, because the most common frontend leak, a listener or closure keeping a removed subtree alive, shows up here immediately and precisely.

This is different from counting elements in the document. A query like document.querySelectorAll('*').length counts what is attached, which is exactly the set of nodes that isn't the problem. Detached nodes that are still referenced never appear in it. The DevTools Protocol's Nodes metric counts what the renderer is keeping, which is the number you want.

Two quirks matter before you set a threshold:

  • Clicking an element that the flow then removes can leave a small, constant number of retained nodes per iteration in Chromium, on subtrees the application is already holding.
  • A ::before or ::after with generated content contributes a pseudo element and its text node to the count, so a component can read a couple of nodes higher than the markup you wrote.

Neither one grows with the iteration count, so the methodology still works. Both are reasons to allow a small non zero tolerance instead of demanding exactly zero growth.

Detached DOM trees

A detached tree is a node that's been removed from the document but is still reachable from JavaScript, so the engine can't collect it. It's the physical form most frontend memory leaks take, and it's where you look once a run has told you something is wrong.

This one is for investigation rather than for checking automatically. Finding detached trees means taking a heap snapshot in DevTools, filtering the class list for Detached, and following the retainer chain of a node back to whatever keeps it alive, which is nearly always a listener, a closure, a module level array, or a framework subscription that was never torn down.

In an automated run, the node count is your proxy: a rising count of retained nodes across iterations is the same thing, counted rather than named.

Event listeners

The count of listeners currently registered in the page. This is the cleanest of the signals, because a well designed journey that ends where it started registers and removes exactly the same listeners every time. A number that goes up by one per iteration is unambiguous, and needs no tolerance.

It also tells you the most. A leaked listener keeps the whole scope its callback closed over alive, which is usually how the detached subtree got retained too. Fixing the listener normally brings the node count down with it.

Chromium exposes JSEventListeners through the DevTools Protocol. (getEventListeners() exists too, but only inside the DevTools console, not in page scripts.)

Instrumented counters

Timers, sockets, observers, workers, and subscriptions have no built in counter, and they leak the same way: something created on setup that is never released on teardown. They also get counted the same way. The constructor gets wrapped before the application loads, incremented on creation, decremented on the call that releases it, and the difference is the count.

// Runs in the page before any application code, on every navigation.
await page.addInitScript(() => {
  const live = new Set<number>();

  const realSetInterval = window.setInterval;
  const realClearInterval = window.clearInterval;

  window.setInterval = ((...args: Parameters<typeof setInterval>) => {
    const id = realSetInterval(...args);
    live.add(id as unknown as number);
    return id;
  }) as typeof setInterval;

  window.clearInterval = ((id?: number) => {
    if (id !== undefined) live.delete(id);
    return realClearInterval(id);
  }) as typeof clearInterval;

  (window as any).__liveIntervals = () => live.size;
});

Reading it back with page.evaluate(() => window.__liveIntervals()) on each sampled iteration gives you the count. The same shape covers the rest.

Resource Wrap Released by Worth knowing
Timers setInterval, setTimeout clearInterval Timeouts that fire drop out on their own, so intervals tell you more. Under an accelerated clock, a poller that needed an hour to cause trouble fires inside the run.
Sockets new WebSocket close() A connection left open keeps receiving, so the same message gets handled once per leaked socket. Socket routing at the automation layer counts and stubs them without touching application code.
Observers ResizeObserver, MutationObserver, IntersectionObserver, PerformanceObserver disconnect() An observer keeps its callback and its observed targets alive. One shared observer for many targets is much harder to leak than one per target.
Workers new Worker terminate() A worker carries its own heap, so a handful of leaked ones shows up. Exclude shared and service workers, which are meant to outlive individual page state.
Subscriptions The store's subscribe The unsubscribe it returns Many state libraries expose the listener set size in a test build. Where they don't, the event listener count usually catches it anyway, because the subscription keeps a closure that references a DOM node.

Network resources

Two different things are worth watching here. The first is requests in flight or repeated: an application that adds another poller on every route change will make two requests per interval, then four, then eight. Counting requests per iteration exposes that immediately, and browser automation tools give you a request event to hook.

The second is the resource timing buffer itself. Every fetch adds a PerformanceResourceTiming entry, and the buffer keeps a few hundred entries by default before it stops accepting more. An application that clears and re-reads it, or that raises the limit, can build up entries of its own accord, which is a real if small source of growth that has nothing to do with your components.

Application caches

Data layer caches are a common source of growth that's real without being a bug. The thing to check is whether the cache has a bound.

A cache with a maximum size or a time to live fills up and then stays there, which shows as a curve that levels off. A cache keyed by something unbounded (a query cache keyed by filter combination, a memo keyed by object identity) grows for as long as the user keeps using the application. That's a leak, even though every entry in it is legitimate.

If the cache exposes its size, counting entries directly gives you much clearer evidence than the heap growth it causes.

Application specific counters

The best signal is nearly always the one you define yourself, because it names the thing that's leaking instead of describing its side effects.

Editors can count live document instances. Charting layers can count chart objects that were never disposed. A map component can count layers, a virtualizer can count rendered row instances, a video player can count media elements. A counter behind a flag adds nothing to production, and you can check it directly.

// In the application, behind a build flag or test hook.
if (import.meta.env.DEV || window.__SOAK__) {
  window.__counters = {
    charts: () => chartRegistry.size,
    editors: () => editorRegistry.size,
    subscriptions: () => store.listenerCount(),
  };
}

The standard for calling it a leak

Whichever signals you collect, the standard stays the same. One reading tells you nothing on its own, and two readings only tell you they differ. Evidence is a sequence of readings that rises in step with how many times a specific behavior has run.

The strongest case has three parts: growth that tracks the iteration count, a slope that stays roughly steady rather than flattening off, and a matching drop in that slope once you remove the suspected cause. Anything less is a lead worth following rather than a bug worth failing a build over.

Examples

The bugs this actually finds

Every pattern below gets through a conventional end to end suite without difficulty. Each one leaks a small, fixed amount every time it runs. You won't see that in a single iteration, and you can't miss it after four hundred. The code is representative rather than copied from any particular codebase.

Event listener leaks

A component attaches a listener when it mounts and never removes it when it unmounts. The listener stays registered, and because its callback closes over the component, everything the component referenced stays in memory too. This is the one I see most often, and the easiest to spot.

The bug leaks per iteration
function StickyToolbar({ items }: Props) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const onScroll = () => {
      // Closes over ref and items, so both stay alive with the listener
      ref.current?.classList.toggle('pinned', window.scrollY > 80);
    };

    window.addEventListener('scroll', onScroll, { passive: true });
    // No cleanup returned: every mount adds another listener
  }, [items]);

  return <div ref={ref}>{/* … */}</div>;
}

The signal

Listener count rises by one or more per iteration, DOM nodes follow.

The journey to repeat

Navigating to the page with the toolbar, then away again.

The fix

A cleanup function that calls removeEventListener with the same reference fixes it. An AbortSignal passed to addEventListener, then aborted on teardown, does the same job for several listeners at once.

Subscription leaks

A component subscribes to a store, an observable, or an event emitter, and the unsubscribe never runs, either because it wasn't returned, because an early return skipped it, or because the subscription was created in a branch the cleanup doesn't know about. The store keeps a reference to a callback belonging to a component that no longer exists, and calls it on every update.

The bug leaks per iteration
function PriceTicker({ symbol }: Props) {
  const [price, setPrice] = useState<number | null>(null);

  useEffect(() => {
    if (!symbol) return; // early return, nothing subscribed yet

    const unsubscribe = priceStore.subscribe(symbol, setPrice);

    // The cleanup is created but never returned to the framework, so the
    // store keeps setPrice, and the component with it, forever
    return undefined;
  }, [symbol]);

  return <span>{price ?? '...'}</span>;
}

The signal

Store listener count rises per iteration. Often the heap rises without the DOM, because the reference lives in the store rather than the page.

The journey to repeat

Mounting the ticker, changing the symbol, then unmounting it.

The fix

Returning the unsubscribe function directly fixes it. Where a store exposes its listener count, checking that as an application counter names the leak far more precisely than heap growth does.

Timer leaks

An interval or a scheduled timeout outlives the component that created it. It keeps firing, keeps its callback in memory, and keeps whatever that callback references alive. Twenty navigations later, twenty copies of the same poller are running against the same endpoint.

The bug leaks per iteration
class SessionWidget {
  private cache = new Map<string, Session>();

  connectedCallback() {
    // Started on connect, never stopped on disconnect
    setInterval(() => this.refresh(), 30_000);
  }

  disconnectedCallback() {
    // Nothing clears the interval, so refresh() keeps running
    // and keeps this.cache reachable
  }
}

The signal

Instrumented live interval count rises per iteration. Request volume per unit of virtual time rises with it.

The journey to repeat

Adding the widget to the page, removing it, and moving the clock past its interval.

The fix

Keeping the id and clearing it on teardown fixes it. This is where the virtual clock does real work: 30 seconds of application time per iteration exposes the poller in seconds rather than hours.

DOM retention and detached trees

A node is removed from the document but something still points at it: a cached element lookup, a module level map, a memoized selector result. The browser can't collect it, so the whole subtree beneath it stays in memory, detached and invisible.

The bug leaks per iteration
// Module scope: lives for the lifetime of the tab
const tooltipCache = new Map<string, HTMLElement>();

export function showTooltip(id: string, content: string) {
  let el = tooltipCache.get(id);

  if (!el) {
    el = document.createElement('div');
    el.className = 'tooltip';
    tooltipCache.set(id, el);   // cached by a key that is never reused
  }

  el.textContent = content;
  document.body.append(el);
}

export function hideTooltip(el: HTMLElement) {
  el.remove();  // out of the document, still in the Map, still in memory
}

The signal

DOM node count rises steadily while the number of nodes actually in the document stays flat. The gap between the two is what you're looking for.

The journey to repeat

Hovering a row to show a tooltip, then moving away to hide it.

The fix

Deleting the cache entry when the element is removed fixes it, and so does a WeakMap keyed by something that goes away. When a run fails, a heap snapshot filtered for Detached, followed back along the retainer chain, points at whatever is keeping the node alive.

Chart and canvas leaks

Charting libraries allocate a great deal per instance: canvases, resize handlers, animation frames, tooltip layers, and often a WebGL context. Recreating a chart when its data changes, without destroying the previous instance, leaves all of it behind. Dashboards get hit hardest, because they can carry a dozen charts that update on a schedule.

The bug leaks per iteration
function renderRevenue(el: HTMLElement, data: Series) {
  // A fresh instance every time the data changes.
  // The previous one still has its resize listener, its rAF loop,
  // and its canvas context.
  const chart = new Chart(el, {
    type: 'line',
    data,
    options: { responsive: true, animation: true },
  });

  return chart;
}

The signal

Heap rises sharply per iteration, listener count rises with it, and node count rises if the library injects tooltip or legend containers.

The journey to repeat

Switching the dashboard date range so every chart rerenders, then switching back.

The fix

The library's own destroy or dispose method, called before a replacement is created, fixes it. Updating the existing instance's data instead of rebuilding it works too. Where the library keeps a registry of live instances, its size makes a good application counter.

WebSocket and real time leaks

A connection opened on mount and never closed on unmount stays open, stays subscribed, and keeps delivering messages that the application still handles. This does more damage than an ordinary memory leak, because it lands on the server and the network as well as in the tab. Duplicated message handling also produces visible bugs that are hard to reproduce.

The bug leaks per iteration
function useLiveOrders(deskId: string) {
  const [orders, setOrders] = useState<Order[]>([]);

  useEffect(() => {
    const socket = new WebSocket(`wss://api.example.com/desks/${deskId}`);

    socket.addEventListener('message', (event) => {
      setOrders(JSON.parse(event.data));
    });

    // No socket.close() on teardown: changing desk opens another
    // connection and leaves the previous one receiving
  }, [deskId]);

  return orders;
}

The signal

Open socket count rises per iteration. Message handling work per iteration rises with it.

The journey to repeat

Switching between two desks, or navigating into and out of the live view.

The fix

Closing the socket in the cleanup fixes it, with a guard against the race where the effect reruns before the connection has opened. In a test, intercepting socket connections at the automation layer lets you count and stub them at once.

Observer leaks

ResizeObserver, MutationObserver, IntersectionObserver, and PerformanceObserver all keep their callback and their observed targets alive until they're explicitly disconnected. Creating one per component instance, or per row of a virtualized list, adds up quickly.

The bug leaks per iteration
export function autoSizePanel(panel: HTMLElement, onResize: Handler) {
  const observer = new ResizeObserver((entries) => {
    for (const entry of entries) onResize(entry.contentRect);
  });

  observer.observe(panel);

  // Nothing returned, so the caller has no way to disconnect.
  // The observer keeps panel, and its subtree, alive after removal.
}

The signal

Instrumented live observer count rises per iteration; heap follows.

The journey to repeat

Opening a resizable panel, dragging it to a new size, then closing it.

The fix

Returning a disposer, and calling disconnect on teardown, fixes it. One shared observer for many targets is usually better than one per target, and much harder to leak.

Route lifecycle leaks

Navigating between routes produces more cumulative leaks than anything else in a single page application, because a route change tears down and rebuilds a lot at once. Route level caches keyed by URL, scroll restoration maps, guards registered per route, and per route data subscriptions all build up, and users navigate constantly.

The bug leaks per iteration
const routeState = new Map<string, RouteSnapshot>();

router.afterEach((to) => {
  // Keyed by full URL including query string, so every filter
  // combination the user tries adds a permanent entry holding
  // component instances and scroll positions
  routeState.set(to.fullPath, {
    scroll: window.scrollY,
    instances: to.matched.map((r) => r.instances),
  });
});

The signal

Every counter rises together: nodes, listeners, and heap, all in proportion to navigations.

The journey to repeat

Navigating from a list to a detail page and back, varying the filters.

The fix

Bounding the map, keying it by route name rather than full path, and dropping entries the router has left behind all fix it. Route journeys are the ones I would write first, because they exercise more teardown code per iteration than anything else.

Editor and document leaks

Document editors, design tools, and code editors take the most punishment from cumulative leaks, because they're left open for hours, and because each document carries an unusually large object graph: undo history, decorations, collaborative document state, language services, and worker instances.

The bug leaks per iteration
export function openDocument(container: HTMLElement, doc: Doc) {
  const editor = createEditor({ container, doc });

  // Undo history grows without a bound for the lifetime of the tab
  editor.history = new UndoStack();

  // A language worker per document, never terminated
  editor.worker = new Worker(new URL('./lang.worker.ts', import.meta.url));

  return editor;
}

export function closeDocument(editor: Editor) {
  editor.container.replaceChildren(); // visually gone, entirely still in memory
}

The signal

Live editor instance count rises per iteration, live worker count rises with it, and heap growth per iteration is large.

The journey to repeat

Opening a document, making an edit, then closing it.

The fix

The editor's own dispose method, a terminate() on the worker, and a cap on the undo stack together fix it. Editors nearly always expose an instance registry, which makes an exact application counter easy to add.

All found the same way

Only the journey changes. Every pattern above runs through the same loop, inside a session that never restarts, and the Playwright implementation runs it.

Resources

Documentation and references

Official documentation wherever it exists, for the browser APIs, tooling, and profiling techniques this methodology depends on.

Reference implementation

The open source toolkit that implements the methodology on top of Playwright.

Playwright documentation

Official documentation for the browser automation the methodology relies on.

Memory and profiling

Official documentation for diagnosing a leak once a soak run has told you one exists.

Browser performance

The platform APIs behind the counters a soak test collects, and the resources it watches.

About

About this site

A practical methodology and reference implementation for detecting cumulative client side resource leaks in long lived web applications.

The purpose

Soak testing is an established idea in software testing, and we've been chasing frontend memory leaks for as long as there have been long lived pages to leak them. The missing piece has been a practical way to do both at once: to work a real application hard enough, for long enough, that cumulative failures show up, without the test taking as long as the failure does.

This site documents one approach to that problem. It defines the methodology, explains the reasoning behind each decision, and points at an open source implementation you can run today. Nobody here is claiming to have invented soak testing, or to have discovered that single page applications leak. This is a description of a technique that works, written down properly so it can be adopted, argued with, and improved.

The author

Den Odell is a frontend engineer, performance enthusiast, and author. He developed this methodology and built its reference implementation, playwright-soak-test, after writing about the technique in Your SPA Is Leaking Memory. Soak Test It.

More of his writing on frontend performance is at denodell.com.

Corrections and contributions

The methodology is only as good as its accuracy. If something here is wrong, imprecise, or missing an important caveat, particularly around browser behavior, measurement, or the limits of what a virtual clock can do, the issue tracker is the right place to raise it. Results from your own application are welcome there too.

Using this material

The prose on this site is published under CC BY 4.0, so you're free to quote it, translate it, or build on it with attribution. The code samples, and the reference implementation, are MIT licensed.