A frontend testing methodology

Frontend Soak Testing

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

Most frontend bugs show up straight away, but some only show up the four hundredth time someone opens the same panel. Bugs like these come from listeners, timers, subscriptions, and detached nodes that build up over a long session until the tab gets slow, appears to have locked up, or crashes. With frontend soak testing, also called frontend endurance testing, you find them by running hours of realistic use in a test that takes minutes.

Soak run 1,000 iterations
Two lines over one thousand iterations. One stays near 100 megabytes the whole time. The other climbs steadily to 350 megabytes.
Baseline
100 MB
After 1,000
350 MB
Slope
+0.25 MB / iteration
Illustrative figures. A leak shows up as the slope: a little more memory after each iteration.
The central idea

Time compression

Soak testing usually involves leaving something running for hours or days and watching what happens to it. On the frontend that's rarely practical, and you rarely need to. When you combine three techniques, a test can build up the same amount of use in a fraction of the time.

Traditional soak test

8 hours of real time

  1. 1Start the application
  2. 2Leave it running
  3. 3Watch for it to get worse

Wall clock time, to scale

The app runs on the real clock, so the only way to get eight hours of usage is to wait eight hours.

Frontend soak test

Minutes of real time

  • Thousands of interactions The same user journey runs over and over, back to back, as fast as the browser can run it.
  • Accelerated application time A virtual clock advances, so timers, polling, and retries run straight away instead of waiting for real time to pass.
  • One persistent browser session The test keeps the same page open between iterations, so any listeners or DOM nodes the app didn't clean up are still there at the end.

Drawn to the same scale as the other bar.

The result: the app goes through hours of simulated use, and the test finishes in a few minutes.

Rapid iteration

The test runs a realistic user journey or a component's lifecycle hundreds or thousands of times, back to back, as fast as the browser can handle them. Each mount, unmount, subscription, and teardown is another chance for the app to leave something behind, so more repetitions give a leak more time to grow into something you can measure.

Time acceleration

Playwright's clock controls move the app's time forward without the test having to wait. A poller on a thirty second interval can then run on every iteration instead. This only speeds up behavior that depends on timers and dates. The browser still renders at its normal speed, and network requests still take as long as they normally would.

Persistent session

The same browser, the same page, and the same application lifecycle stay open across every iteration. This is the part that's easiest to get wrong. If the test starts a fresh browser between iterations, the leaked listeners and retained nodes are thrown away with the old one, and there's nothing left to measure.

Definition

The definition

Frontend soak testing, also known as frontend endurance testing, is the practice of repeating the same realistic use of a web application many times in one real browser session to find the memory leaks, leftover resources, and performance problems that build up in the browser over hours of use.

A test that represents a working day doesn't have to run for a working day. The app just has to go through the same amount of use it would get in that time.

Single page applications made this a bigger problem than it used to be. 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 was once open for ninety seconds can now stay open for a whole working day. People leave dashboards, editors, and internal tools open for a full shift, so anything the app doesn't release has all day to add up.

Naming

Also called endurance testing

Soak testing and endurance testing are two names for the same practice. The one you hear depends on who you learned it from.

Server side teams tend to say soak test, QA and performance teams often say endurance test, and older testing literature calls it longevity testing or stability testing. In the browser it's also called frontend endurance testing, browser endurance testing, and SPA soak testing. Everything on this page applies whichever name your team uses. The main difference is in search results: a search for endurance testing mostly returns server side material, where the tests just run for the full number of hours, so those guides don't cover time compression.

Stopping too soon

End to end tests stop too early

The tests are written fine, but 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 test closes the browser before anything has had time to build up, so any leak disappears along with it.

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 same browser session stays open for all the iterations, so anything the app doesn't clean up is still there to measure.

The difference is whether 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 each time a particular lifecycle runs. Say a test opens a dialog and closes it again. It passes, because the dialog does open and it does close, but the test doesn't measure anything that would show the dialog's scroll listener is still attached after it closes, along with a 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 for all one thousand iterations. The leaking application starts at the same 100 megabytes, is at 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 test suite has finished by this point
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

Seeing 350 MB at the end of a run doesn't tell you much, because a photo editor can legitimately use more than that. The thing to look for is memory that keeps climbing each time the same journey runs. An app whose memory climbs to 140 MB and then stays there has filled a cache. An app whose memory keeps climbing the more times you open a dialog has a leak, and the slope of the line tells you roughly how much memory each open and close leaves behind.

Compare it to other testing

Frontend soak testing is used alongside the techniques below, not instead of any of them.

Five testing approaches compared by the question behind each one, how long it usually runs, and the problems it typically catches
Approach The question behind it Typical duration Problems 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, and changes in behavior
Load test “Does the system handle many users or requests?” Minutes to hours, server side Capacity limits, overloaded backends, and the most traffic the system can take
Frontend performance test “How quickly does the system respond?” One page load or interaction Slow loads, layout shift, and slow interactions
Frontend soak test “Does this frontend stay healthy after hours of repeated use?” Many iterations, one continuous session Leaks that build up, caches with no size limit, and pages that get slower as the session goes on

Load testing and soak testing sound related, but on the frontend they do completely different jobs. A load test checks whether the system copes with many users at once, and usually doesn't involve a browser at all. A frontend soak test simulates one user in one real browser over a long stretch of time, because the problems it's looking for happen in the client.

Examples

The bugs this actually finds

Every pattern below passes a conventional end to end suite without any trouble. Each one leaks a small, fixed amount every time it runs. In a single iteration the leak is too small to notice, but after four hundred iterations it's obvious. The code examples are representative, and they aren't copied from any particular codebase.

Event listener leaks

A component adds an event listener when it mounts and doesn't remove 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 leak I see most often, and it's also the easiest one 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

The listener count goes up by at least one on each iteration, and the DOM node count goes up with it.

The journey to repeat

Navigating to the page with the toolbar, and then away from it again.

The fix

Returning a cleanup function that calls removeEventListener with the same function reference removes the listener. You can also pass an AbortSignal to addEventListener and abort it on teardown, which removes several listeners at once.

Subscription leaks

A component subscribes to a store, an observable, or an event emitter, and the unsubscribe function never runs. The store keeps a reference to a callback from a component that no longer exists, and it calls that callback 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

The store's listener count climbs with each iteration. The heap often grows while the DOM node count stays the same, because it's the store that keeps the reference, and not the page.

The journey to repeat

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

The fix

Returning unsubscribe directly from the effect, instead of undefined, stops the leak.

Timer leaks

An interval or a scheduled timeout keeps running after the component that created it has gone. The timer keeps its callback in memory, along with anything that callback references. After twenty navigations, there are twenty copies of the same poller sending requests to 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

With instrumentation counting live intervals, that count goes up on each iteration, and so does the number of requests sent per unit of virtual time.

The journey to repeat

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

The fix

Saving the interval id and passing it to clearInterval on teardown stops the poller. Timer leaks are where the virtual clock really helps, because when it advances 30 seconds of application time on each iteration, the extra pollers become obvious in seconds rather than hours.

DOM retention and detached trees

A node is removed from the document, but something still references it, such as a cached element lookup, a module level map, or a memoized selector result. The browser can't garbage collect the node, so the whole subtree under it stays in memory, detached from the document 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

The DOM node count keeps going up while the number of nodes actually in the document stays the same.

The journey to repeat

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

The fix

Deleting the cache entry when the element is removed lets the browser free the node. A WeakMap keyed by an object that goes away works too. When a run fails, you can take a heap snapshot, filter it for Detached, and follow the retainer chain back to find what's keeping the node alive.

Chart and canvas leaks

Charting libraries allocate a lot for each chart instance: canvases, resize handlers, animation frames, tooltip layers, and often a WebGL context. If the code creates a new chart whenever the data changes and doesn't destroy the old one, all of that stays behind. Dashboards are hit hardest, because a single dashboard can have 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

The heap grows sharply on each iteration, and the listener count grows with it. The DOM node count goes up too if the library injects tooltip or legend containers into the page.

The journey to repeat

Changing the dashboard's date range so every chart rerenders, and then changing it back.

The fix

Calling the library's own destroy or dispose method before creating the new chart cleans up the old one. Another option is to update the existing chart's data instead of building a new chart.

WebSocket and real time leaks

If a component opens a connection when it mounts and doesn't close it when it unmounts, the connection stays open and subscribed, and the application keeps handling the messages it delivers. This does more damage than an ordinary memory leak, because the extra connections use resources on the server and the network as well as in the browser tab.

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

The number of open sockets goes up on each iteration, and so does the amount of message handling work done in each iteration.

The journey to repeat

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

The fix

The effect needs a cleanup that closes the socket, with a guard for the race where the effect runs again before the connection has opened.

Observer leaks

ResizeObserver, MutationObserver, IntersectionObserver, and PerformanceObserver all keep their callback and the targets they observe alive until your code explicitly disconnects them. If you create one for each component instance, or for each row of a virtualized list, the leaked observers add 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

If you've added instrumentation to count live observers, that count rises on each iteration, and the heap grows along with it.

The journey to repeat

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

The fix

If autoSizePanel returns a disposer that calls disconnect, and the caller runs it on teardown, the panel can be freed. A single observer shared across many targets is usually better than one observer for each target, and it's much harder to leak.

Route lifecycle leaks

In a single page application, I find more leaks around route changes than anywhere else, because a route change tears down and rebuilds a lot of the page at once. Route level caches keyed by URL, scroll restoration maps, guards registered for each route, and data subscriptions for each route all build up, and users change routes all the time.

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

All of the counters go up together: DOM nodes, listeners, and heap size, each in proportion to the number of navigations.

The journey to repeat

Navigating from a list to a detail page and back, with different filters each time.

The fix

There are a few ways to stop the map growing: giving it a size limit, keying it by route name instead of the full path, or removing entries for routes the router has already left. If I were starting from scratch, I'd write route journeys first, because they run more teardown code per iteration than any other kind of journey.

Editor and document leaks

Leaks that build up over a session do the most damage in document editors, design tools, and code editors. People leave these apps open for hours, and each open document comes with a very large object graph, including 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

The counts of live editor instances and live workers both go up with each iteration, and the heap grows by a large amount each time.

The journey to repeat

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

The fix

Stopping this one takes three changes: calling the editor's own dispose method, calling terminate() on the worker, and putting a cap on the undo stack.

All found the same way

Each pattern above is found with the same loop, in a browser session that's never restarted, and the only thing that changes from one pattern to the next is the journey. The methodology below covers how to design that loop, and the Playwright implementation runs it.

Methodology

Design a frontend soak test

A soak test is more than an end to end test with a loop around it. If you get the decisions below wrong, the run either produces noise or throws away the very leaks it was meant to measure, and it won't tell you that's happened.

01Use a real browser

Everything this methodology looks for is in the client: the DOM subtree that's still retained, the listener that was never removed, and the interval that's still ticking after its component was destroyed. HTTP level load testing tools don't run a DOM or a JavaScript heap, so they can't measure any of it. A frontend soak test needs a real browser engine running the real application bundle.

It also has to be a browser that the test can inspect from the outside. The most useful counters come from the DevTools Protocol, which is only available in Chromium, so which signals you can collect depends on the engine you pick.

02Keep the same session alive

Of the ten steps here, I think this is the one teams break by accident most often. Reusing the browser between tests isn't enough. It has to be the same page from the first iteration to the last, and the test never reloads it or navigates away from it.

Restarting the browser destroys the evidence

When the page closes, all of its memory is freed, leaked or not. A test harness that opens a fresh context for each iteration is really measuring one iteration a thousand times over, and it reports no growth no matter how badly the app leaks. A full page reload, or a journey that navigates to a URL instead of routing client side, throws the evidence away in the same way.

03Define a realistic user journey

The journey is the thing the test repeats to put pressure on the app. It should be something a real person actually does over and over, and it should end where it started.

When a journey finishes in the same state it started in, 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's meant to build up, like an infinite scroll feed, doesn't give you that.

  • 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

The journeys that work best create something and then destroy it, such as mounting and unmounting, or connecting and disconnecting, because that's when cleanup code either runs or doesn't.

04Repeat the lifecycle

The journey runs hundreds or thousands of times, back to back, in the same session, so that a few kilobytes or a single listener leaked in each iteration adds up to a trend you can't miss. One leaked listener per iteration is obvious almost straight away. A leak of 2 KB per iteration needs hundreds of repetitions at least before it stands out from the normal ups and downs of the heap.

Each iteration runs as fast as the application can handle it, so it helps to wait on conditions instead of fixed sleeps, and to stub the network wherever the response content isn't what you're testing.

05Accelerate time

An application that polls every thirty seconds does the same amount of work however fast the test clicks. Playwright's Clock API replaces the browser's time functions with versions the test can control, so time only moves forward when the test moves it.

// 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 runs each timer that comes due along the way. fastForward() jumps straight ahead instead, and runs each due timer at most once, which is what you want when you're skipping a long expiry window. The clock has to be installed before the application loads, or code that captures a timer reference at module scope keeps the real one.

The limits of a virtual clock

A virtual clock only moves application time forward. Layout, paint, script execution, and garbage collection all take as long as they normally would, and if an advanced timer triggers a request, the test still waits for that response in real time unless you stub it.

A fake clock can also change how the app behaves. An application that reads the clock in an unusual way, or that depends on the real timing of requestAnimationFrame, can behave differently under a fake clock than it does in production. When results look strange, I'd try turning the clock off first.

06Measure resources

The test takes readings at regular points through the run, not only at the end, because with only a first and a last reading, you can't tell a steady leak from a single jump. Memory is usually the first thing people measure, but a heap size in megabytes is also the noisiest signal you can collect. Counts of specific things, such as event listeners and DOM nodes, make a leak much more obvious.

Signals worth measuring

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 An assertion on exactly the objects your application creates.
JavaScript heap Noisy A diagnostic, or a percentage threshold once you know how much it varies between runs.
Detached DOM trees Manual Investigating a run that failed, rather than an automated assertion.

Event listeners

This is the cleanest signal, because a journey that ends where it started adds and removes exactly the same listeners each time it runs. If the count goes up by one each iteration, a listener isn't being removed. A leaked listener keeps alive everything its callback closed over, so fixing it normally brings the node count down as well. Chromium exposes the count as JSEventListeners through the DevTools Protocol.

DOM nodes and detached trees

The DevTools Protocol's Nodes metric counts every node the renderer is keeping in memory, whether or not it's still in the document. In practice I find it the most useful signal, because one of the most common frontend leaks is a listener or closure keeping a removed subtree alive. A query like document.querySelectorAll('*').length only counts the nodes that are attached, so it misses exactly the ones causing the problem.

The count needs a small tolerance. In Chromium, clicking an element that the flow then removes can leave a few retained nodes behind, and a ::before or ::after with generated content adds a pseudo element and its text node. Neither of these grows as the test repeats.

Once a run has failed, a heap snapshot in DevTools tells you which nodes are leaking. Filtering the class list for Detached and following a node's retainer chain leads back to whatever is keeping it alive, which is nearly always a listener, a closure, a module level array, or a subscription that was never torn down.

JavaScript heap

Heap size includes leaks that never touch the DOM. A poller that pushes each response into an array leaks a little more each time it runs, while the node count doesn't change at all. Browser automation tools usually read JSHeapUsedSize from the DevTools Protocol, which is only available in Chromium. The readings are much more reliable if you force a garbage collection, ideally more than one, immediately before each reading, and in Chromium that needs the browser launched with --js-flags=--expose-gc.

Even then, heap readings vary from one run to the next when the code hasn't changed, so I'd be wary of a fixed threshold in megabytes. A percentage of the baseline, with a generous tolerance, is much less likely to fail a build when nothing is wrong.

Timers, sockets, observers, and workers

These don't have a built in counter. Before the application loads, you wrap the function that creates each one so it records the new object, and you wrap the call that releases it so it removes the record again. Whatever is still recorded is the count of live ones.

// 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;
});

Calling page.evaluate(() => window.__liveIntervals()) on each sampled iteration returns the count. The same wrapping approach works for the other resources too.

Resource Wrap Released by Worth knowing
Timers setInterval, setTimeout clearInterval A timeout is finished once it has run, so counting intervals tells you more.
Sockets new WebSocket close() Playwright's WebSocket routing can count and stub them without any change to the application code.
Observers ResizeObserver, MutationObserver, IntersectionObserver, PerformanceObserver disconnect() An observer keeps its callback and its observed targets alive.
Workers new Worker terminate() Each worker has its own heap. Shared workers and service workers are meant to outlive the page, so they're left out of the count.
Subscriptions The store's subscribe The unsubscribe it returns Many state libraries expose the size of their listener set in a test build.

Application counters

The most useful signal is nearly always one you define yourself, because it counts the exact thing that's leaking. In an editor, you could count the document instances that are still open, and in a charting layer, the chart objects that were never disposed. If the counter is behind a flag, it adds nothing to production, and the soak test can check it directly. Counting a cache's entries works the same way, and a cache keyed by something with no limit, such as each combination of filters, is a leak even though every entry in it is legitimate.

// 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(),
  };
}

07Establish a baseline

The baseline gets measured after the application has warmed up, but before the test starts repeating the journey in earnest. Every later reading gets compared against it.

The first few iterations of any journey pull in lazily loaded chunks, fill caches, get hot functions compiled, and allocate structures that the application reuses for the rest of the session. That's all normal. If the baseline is taken before any of it happens, there's a big jump between the baseline and every reading after it, and that looks exactly like a leak even though it isn't one.

Five warmup iterations is a reasonable default. That won't hide a real leak, because a real leak keeps growing long after the warmup ends.

08Analyze the trajectory

You look at how the readings change across the whole run, rather than at any single measurement, because any one reading might have been taken just before or just after a garbage collection.

Fitting a straight line through the samples gives you the slope, which is how much each iteration adds, and a measure of how closely the samples follow that line. If the node count grows by 8,000 and the samples follow the line almost perfectly, that's a leak. If the same 8,000 nodes all arrive in one jump at iteration 30 and the count doesn't change after that, it's probably a lazily loaded view, which isn't a reason to fail the test.

Flat There's no growth, so the journey is cleaning up after itself.
Linear Each iteration adds about the same amount, which is what a real leak looks like.
Step One jump and then no more growth, which is usually lazy loading or something being used for the first time.
Settled Growth that levels off, which is typically a cache filling up to its limit.

09Distinguish legitimate growth from leaks

Not all growth is a bug, and if a soak test fails on every bit of growth, the team soon switches it off. Applications grow for several good reasons:

  • Caches. A cache with a size limit grows until it's full and then stops.
  • Lazy loading. Route chunks, fonts, and deferred modules add a one time jump the first time a code path runs.
  • Engine internals. Compiled code and the engine's own caches grow as more of the application's code runs for the first time.
  • One time initialization. Singletons, service workers, and connection pools allocate memory once and then keep it.
  • Real accumulated state. If the journey adds a row to a list and never removes it, memory should grow.

Legitimate growth depends on which code paths have run at least once, but a leak depends on how many times they've run. Two checks make the difference clearer. If you double the iteration count, legitimate growth stays roughly where it was, but a leak roughly doubles. And if you run the same soak test against a build with the suspect behavior removed, and the slope drops to near zero, that confirms the leak and tells you where it is at the same time. If you only have some of that evidence, it's worth investigating, but I wouldn't fail a build over it.

10Define a soak budget

A test needs a threshold that it can fail against. A soak budget is the most resource growth you're willing to accept over a set number of iterations.

A limit of “200 MB of heap” doesn't tell you much, because it doesn't say over how many iterations, or of which journey. “No more than 5% heap growth across 500 iterations of opening and closing the report drawer” is something a test can check and a reviewer can disagree with.

Each signal needs its own budget. A leaked listener is nearly always a bug, so a budget of zero growth is reasonable for listeners. DOM nodes need a small tolerance. The heap needs the most tolerance of the three, and it's often better to record it without failing the test on it until you know how much it normally moves around.

The model, end to end

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

Persistent browser session the same browser and page for the whole run

  1. Application Loaded once and kept running for the whole test.
  2. User journey A realistic set of steps that ends back where it started.
  3. Repeats Repeat lifecycle Mount, unmount, navigate, subscribe, unsubscribe, and advance the clock.
  4. Measure resources DOM nodes, event listeners, heap size, and anything else worth counting.
  5. Compare against baseline Each reading is compared with the one taken after warmup.
  6. Analyze growth Growth is judged on the trend over many iterations, not on a single reading.
  7. Detect the leak Counts that go up a little every time the journey repeats.
Everything else runs inside the persistent session, so every step shares one page, one heap, and one application lifecycle.

Core terminology

These five terms are enough to describe most of a soak test. They're names this methodology uses for its own ideas, not established industry terms, so a colleague probably won't recognize them unless you explain them first.

Soak loop
A user journey or application lifecycle that runs over and over in the same application session.
Resource baseline
The resource measurements taken before the test starts repeating the journey in earnest, usually after a short warmup.
Resource slope
How much a resource grows with each iteration, rather than the total growth over the whole run.
Leak signature
Resource growth that keeps going up with each repetition, rather than jumping once or leveling off.
Soak budget
The most resource growth you're willing to accept over a set number of iterations, written as a threshold that the test fails if growth goes past it.

Running soak tests in CI

A test that takes eight hours can't run on a pull request, and I think that's why endurance testing is rare on the frontend. A few hundred iterations with the clock moved forward finishes in about the same amount of time as an ordinary end to end suite, which is quick enough to run on a pull request. A short run of about 100 iterations can catch obvious leaks on each pull request, and longer runs of thousands of iterations can run nightly or weekly to catch slower growth.

Set expectations about stability

Resource measurements vary between runs, especially on shared CI hardware. Node and listener counts are stable enough to block a merge on, but heap thresholds are much more likely to fail at random. Soak runs work best in their own job, with a single worker and no retries, and recording the measurements for a while before failing on them shows you what normal looks like for your application.

Resources

Documentation and references

Links to the official documentation, wherever there is some, for the browser APIs, tools, and profiling techniques that this methodology depends on.

Reference implementation

The open source library that runs the methodology on top of Playwright.

Playwright documentation

The official documentation for Playwright, the browser automation tool that the methodology is built on.

Memory and profiling

Official documentation for tracking down a leak once a soak test has shown that there is one.

Browser performance

Documentation for the browser APIs behind the counters that a soak test collects, and for the resources it tracks.

Questions

Common questions

These are the questions people ask most often about soak testing on the frontend, including how it relates to endurance testing and what it doesn't cover.
Is frontend soak testing the same as endurance testing?
Yes. Soak testing and endurance testing are two names for the same practice, which involves running a system under realistic use for a long time and watching what builds up. Some testing literature calls it longevity testing or stability testing. Frontend soak testing and frontend endurance testing both describe the technique on this site, and there's no reason to switch from whichever one your team already uses.
How is a soak test different from an end to end test?
An end to end test runs a journey once in a fresh browser session and checks that it worked. A soak test runs the same journey hundreds or thousands of times in one session that never restarts, and checks whether DOM nodes, event listeners, and heap size keep climbing while it does. The first tells you the feature works, and the second tells you whether it still works after hours of use.
How long does a frontend soak test take to run?
A default run of 200 iterations finishes in seconds. It's that quick because of three techniques. The iterations run back to back as fast as the browser can handle them, a virtual clock moves the app's time forward so timers and polling run without waiting, and the whole run uses one session, so whatever leaks in one iteration is still there in the next. A test that represents a working day doesn't have to run for a working day, as long as the app goes through the same amount of use.
How is soak testing different from load testing?
A load test checks whether a system copes with many users at once, and it usually runs against HTTP endpoints without a browser. A frontend soak test simulates one user in one real browser over a long stretch of time, because the problems it's looking for happen in the client, and request level tooling only measures requests and responses. The two check different things, so neither one replaces the other.
What does a frontend soak test measure?
The four counters worth trusting most are event listeners, DOM nodes, JavaScript heap size, and document count, all measured after forcing garbage collection, so memory the browser was about to free isn't counted. Listener and node counts are the most reliable, because they're whole numbers that either go back to where they started or don't. Heap size is noisier, so it's usually reported instead of asserted on.
What separates a real leak from normal memory growth?
You can tell from how memory changes over the whole run, not from any single reading. A cache climbs while it fills up and then stops growing, and a lazily loaded chunk of code adds memory once, in a single step. A leak keeps climbing the more times the journey runs, so if you fit a line across many samples, its slope tells you how much each iteration leaves behind.
Can a frontend soak test run in continuous integration?
Yes. A short run of a few hundred iterations is quick enough to run alongside an ordinary end to end suite. Readings can vary from run to run, so the soak test works best in its own job, with one worker and no retries. Longer runs of several thousand iterations suit a nightly schedule better than a pull request.
Which browsers can run a frontend soak test?
Only Chromium, for now. The counters come from the Chrome DevTools Protocol, so the reference implementation skips the test on Firefox and WebKit instead of reporting measurements it can't collect. A leak found in Chromium is almost always in the other engines too, because it's caused by the application code, not the browser engine.
Do I need to write new tests to start soak testing?
Usually not. If you already have an end to end test that clicks through the journey you care about, you can turn it into a soak test by changing an import and wrapping the part you want repeated. The journey does need to end in the same state it started in, so the counts at the end can be compared with the counts at the beginning.
Who created the frontend soak testing methodology?
Den Odell, a frontend engineer and author, came up with the methodology and built its reference implementation, playwright-soak-test, after writing about the technique in the article Your SPA Is Leaking Memory. Soak Test It. He's also the author of Fast by Default: Practical Performance Engineering, from Manning, and writes about frontend performance at denodell.com.
About

About this site

A practical methodology and a reference implementation for finding the client side resource leaks that build up over time in long lived web applications.

The purpose

Soak testing is an established idea, and we've been chasing frontend memory leaks for as long as pages have stayed open long enough to leak. The part that's been missing is a practical way to combine the two, without the test taking as long as the problems take to appear for real users. This site describes one way to do that. I'm not claiming to have invented soak testing, or to have discovered that single page applications leak memory. I've written down a technique that works, in enough detail that other teams can adopt it, argue with it, and improve it.

The author

Den Odell is a staff frontend engineer who writes about web performance. 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.

He wrote Fast by Default: Practical Performance Engineering for Manning Publications, and Pro JavaScript Development for Apress before that. You'll find more of his writing on frontend performance at denodell.com, and links to his other profiles are on this page too.

Corrections and contributions

I want this methodology to be accurate, especially where it talks about browser behavior, measurement, or the limits of what a virtual clock can do. If you find something here that's wrong, imprecise, or missing an important caveat, the issue tracker is the place to raise it. I'd be glad to see results from your own application there too.

Using this material

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