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.
Illustrative figures. A leak shows up as the slope: a little more memory after each iteration.
A conventional end to end test checks
“Does this user journey work?”
A frontend soak test checks
“Does this user journey keep working after it's been repeated
hundreds or thousands of times?”
“Is the app still healthy after the same session has been in use
for hours?”
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
1Start the application
2Leave it running
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.
vs
Frontend soak test
Minutes of real time
Thousands of interactionsThe same user journey runs over and over, back to back, as fast
as the browser can run it.
+Accelerated application timeA virtual clock advances, so timers, polling, and retries run
straight away instead of waiting for real time to pass.
+One persistent browser sessionThe 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
Open application
Perform action
Assert result
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
Open application
Perform action
Measure resources
Repeat, hundreds or thousands of times
loop
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:
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 bugleaks 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 bugleaks 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 bugleaks 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 bugleaks per iteration
// Module scope: lives for the lifetime of the tabconst 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 bugleaks 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 bugleaks 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 bugleaks 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 bugleaks 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 bugleaks 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.
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.
FlatThere's no growth, so the journey is cleaning up after itself.
LinearEach iteration adds about the same amount, which is what a real leak looks like.
StepOne jump and then no more growth, which is usually lazy loading or something being used for the first time.
SettledGrowth 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
ApplicationLoaded once and kept running for the whole test.
User journeyA realistic set of steps that ends back where it started.
RepeatsRepeat lifecycleMount, unmount, navigate, subscribe, unsubscribe, and advance the clock.
Compare against baselineEach reading is compared with the one taken after warmup.
Analyze growthGrowth is judged on the trend over many iterations, not on a single reading.
Detect the leakCounts 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.
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.
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.