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.
Illustrative figures. The slope is the whole point.
A conventional end to end test asks
“Does this user journey work?”
A frontend soak test asks
“Does this user journey keep working after being repeated hundreds
or thousands of times?”
“Does the application remain healthy as the same session accumulates
usage over time?”
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
1Start the application
2Leave it running
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.
vs
Frontend soak test
Minutes of real time
Thousands of interactionsThe same journey repeated back to back, as fast as the browser
runs it.
+Accelerated application timeA virtual clock moves timers, polling, and retries forward without
waiting for them.
+One persistent browser sessionNothing 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
Open application
Perform action
Assert result
Close browser
The process exits before anything has had a chance to build up, and every
leak goes with the browser.
Frontend soak test
Open application
Perform action
Measure resources
Repeat, hundreds or thousands of times
loop
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:
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
“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.
FlatNo growth. The journey cleans up after itself.
LinearSteady growth per iteration. This is what a real leak looks like.
StepOne jump, then flat. Usually lazy loading or first use.
SettledGrowth 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
ApplicationLoaded once, kept alive throughout.
User journeyA realistic sequence that ends where it started.
RepeatsRepeat lifecycleMount, unmount, navigate, subscribe, unsubscribe, and move the clock on.
Measure resourcesNodes, listeners, heap, and anything else worth counting.
Compare against baselineEvery reading is relative to the state after warmup.
Analyze growthA trend across many iterations, rather than one reading.
Detect leak signatureGrowth 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.
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 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
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 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
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 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
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 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
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 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
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 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
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 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
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 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
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 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
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.
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.
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.