DEV Community

Cover image for Your SPA is leaking memory. A green Lighthouse score will not tell you
Apogee Watcher
Apogee Watcher

Posted on Originally published at apogeewatcher.hashnode.dev

Your SPA is leaking memory. A green Lighthouse score will not tell you

Run PageSpeed Insights on a fresh URL and you often get a reassuring picture: Largest Contentful Paint in budget, Interaction to Next Paint acceptable, Cumulative Layout Shift quiet. Leave the same single-page application open through a working day, open drawers, filter tables, hop between client-side routes, and the tab feels heavier. Scroll stutters. Clicks queue. A force reload fixes it until the afternoon repeats the cycle. That gap is the argument: a green Lighthouse score on a cold load is one honest answer about one moment. It is not proof that memory stays flat after hundreds of interactions on the same document.

Why a green Lighthouse score can hide a leaking tab

Lighthouse, including the run inside PageSpeed Insights, measures a controlled lab visit. It loads the URL, exercises the page for a bounded window, and reports Core Web Vitals and diagnostics for that snapshot. It does not keep a browser tab alive for six hours while a user repeats the same admin workflow. It does not assert that detached DOM nodes return to zero or that event listener counts stop climbing.

Memory leaks in a JavaScript single-page application are a session problem. They accumulate across route changes, modal open and close cycles, polling timers, and cached query results that never get released. The symptom is often responsiveness, not a red performance category on first paint. Teams search for "lighthouse memory leak" and find forum threads explaining that Lighthouse was never a heap profiler. That is correct, but it is only half the operational story. The other half is that production SPAs are long-lived, and your monitoring habits may still be optimised for the first load.

What changes when the page never reloads

Classic multi-page sites handed memory management to navigation. Follow a link, the old document tears down, listeners go with it, and a new page starts from a clean slate. Single-page applications trade that reset for smoother transitions: the same document stays alive while JavaScript swaps views. Electron shells and embedded web views behave the same way, because the underlying page is not reloaded either.

Den Odell's recent write-up on frontend soak testing quotes a static analysis of five hundred popular React, Vue, and Angular repositories from early 2026: eighty-six percent registered a listener, timer, or subscription somewhere without a matching teardown. The leak does not need to be large. A few detached nodes per drawer open, times two hundred opens in a shift, plus a polling interval that never clears, is enough to push a tab from snappy to miserable. Backend teams learned this decades ago with overnight soak tests on servers. Front-end teams are catching up because the product shape changed.

The force-reload workaround teams admit in private

Some teams schedule a hard reload of their SPA every few hours so users never reach the cliff edge. It works as a band-aid. It also signals that the app leaks badly enough that operations would rather interrupt work than fix the root cause. Support tickets that mention "leave it open all day and it slows down" are often memory pressure, even when no single Lighthouse run ever failed.

That pattern is different from deploy-day regressions. A bad release can spike INP on a cold URL and show up in the next scheduled lab run. A leak might ship green on every pull request and still ruin Thursday afternoon for anyone who keeps the dashboard open. Treating force reload as policy is a clue that session-long behaviour needs its own test lane, not another argument about whether Lighthouse should have caught it.

What PageSpeed Insights and Lighthouse do not run long enough to see

Question Cold Lighthouse / PSI Long-lived SPA session
When does it run? Once per test, fresh profile Hours on the same document
What fails first? LCP, INP, CLS on initial route Growing heap, listener drift, janky main thread
Typical CI hook Budget on one URL after build Rarely covered unless you add a soak job
User report "Homepage is slow" "It gets worse the longer I work"

Scheduled PageSpeed monitoring still matters for agency portfolios: it catches deploy regressions on money URLs, compares mobile and desktop lab runs, and keeps a history when a client asks what changed last Tuesday. It is the wrong tool for proving that route seventeen does not leave ninety listeners attached. Soak tests and heap snapshots answer that class of question. Mixing them up creates blind spots: green dashboards while account managers reboot the tab before a screen share.

Frontend soak tests: one flow, hundreds of loops

Odell's proposal is straightforward. Take a realistic user flow that starts and ends on the same screen, such as open a drawer and close it, or apply a filter and clear it. Run it in a loop inside a single Playwright browser context, hundreds of times, without resetting between iterations. Compare DOM node count and JavaScript event listener count before and after. If listeners monotonically increase or nodes drift upward on a round-trip flow, you have a leak signal worth investigating.

Playwright end-to-end suites usually spin up a fresh context per test, which is correct for functional checks but useless for accumulation. A soak test deliberately reuses one context so each pass leaves residue in memory, the same way a real user's afternoon does. Odell packages the measurement helpers in playwright-soak-test, and the patterns below follow his article for teams planning a nightly job rather than reimplementing CDP reads from scratch.

DOM nodes, listeners, and compressed time with a fake clock

Chromium exposes heap size, DOM node count, and listener count through the Chrome DevTools Protocol. A typical pattern collects garbage twice (Odell found one pass left detached React nodes visible on roughly one reading in six), then reads Performance.getMetrics for JSHeapUsedSize, Nodes, and JSEventListeners. Heap jumps on first load when lazy routes fetch code, so the soak helper warms up a few loops before recording a baseline.

Assertions usually target listeners first when the bug is a addEventListener without removeEventListener, and node count with a fixed tolerance when detached DOM is held by object references. Readings jitter between runs, so this belongs in a nightly workflow more than on every commit. Feeds that are supposed to grow memory are poor soak candidates; round-trip UI chrome is ideal.

Timers are the other large category in that repository scan, especially setTimeout polling. Two hundred fast Playwright loops might only fire a poller a handful of times compared with an hour of real time. Odell's fix is to install Playwright's fake clock before navigation, pause after startup, then advance thirty seconds per loop while mocking network responses so fetches complete before the next tick. Without the mock, real network latency distorts how often the poller runs, and your test under-stresses the leak. Return payloads close to production size, too: a tiny JSON stub can hide a leak that only appears when fifty kilobytes land in cache each poll.

When leaks show up as Interaction to Next Paint pain

Interaction to Next Paint measures responsiveness on real interactions: clicks, taps, and key presses until the next paint. It does not read heap size directly. Memory pressure still shows up there because a bloated tab spends more time in garbage collection and main-thread work, so the same button click waits longer. Event handlers attached to detached subtrees can fire at surprising times. Layout thrash from thousands of hidden nodes makes presentation delay worse.

If INP on money flows is fine in a cold lab run but support hears "after lunch it feels sticky," suspect session accumulation before you chase another image optimisation. Our guide on Understanding INP walks through what INP captures and how field scoring works. Pair that mental model with soak tests on the flows that repeat all day, not only the homepage once.

Soft navigations keep the same document alive

Chrome 151's soft-navigation work makes route changes inside an SPA easier to measure per transition, but it does not reload the document. History updates, views swap, and the same JavaScript heap remains. That is good for user experience and good for performance engineers who finally get route-shaped metrics. It also means cleanup bugs survive across "pages" the user perceives.

When a client-side route change does not tear down the previous view's listeners, soft navigations can look fine on paint timings while memory still climbs. Preparing for those entries is a measurement story; preventing leaks is an implementation story. Our write-up on soft navigations in Chrome 151 covers what to detect and how Interaction Contentful Paint relates to Largest Contentful Paint on route changes. Soak tests still matter because they stress the lifecycle your users actually repeat.

Where scheduled monitoring still belongs

Soak tests belong next to your end-to-end suite: nightly or on main, scoped to flows that round-trip cleanly. Lighthouse in continuous integration belongs on cold URLs and budgets you care about at deploy time. Scheduled PageSpeed monitoring belongs on the portfolio list you would put in a client QBR. Those layers overlap in spirit because each one asks whether the product is fast enough, but none of them replaces the others.

We are not arguing against lab budgets on checkout because you added a drawer soak test. We are arguing against treating a green Lighthouse icon as proof that nothing leaks. Agencies managing dozens of SPAs need both: regression detection on known URLs for INP and LCP, and at least one soak flow per app that mirrors how staff use the admin all day. TanStack Table tree-shaking and opt-in features address a different INP lever (less JavaScript up front). Memory leaks are the slow drip after the feature shipped.

FAQ

Can Lighthouse or PageSpeed Insights detect memory leaks in an SPA?

No. They report lab Core Web Vitals and diagnostics for a short, fresh visit. They do not track heap growth or listener counts across hundreds of client-side navigations. Use Chrome DevTools Memory snapshots or an automated soak test for that class of bug.

What is a frontend soak test?

A scripted user flow repeated many times in one browser context, with DOM node and listener counts compared before and after. It mimics how a long work session stresses the same document, compressed into minutes. Den Odell's soak test article and playwright-soak-test repository are practical starting points.

How is a soak test different from normal Playwright end-to-end tests?

End-to-end tests reset browser state so each case is isolated. Soak tests deliberately do not reset, because the bug only appears when state accumulates. Keep functional tests fast and isolated; run soak tests on a schedule with looser timing tolerances.

Does fixing memory leaks replace monitoring Interaction to Next Paint?

No. Leak fixes help session-long responsiveness, but deploy regressions, third-party scripts, and network changes still move INP on cold loads. Monitoring money URLs on a schedule catches those shifts even when soak tests pass.

A practical split for agency SPAs

A useful default is three lanes. Cold lab budgets on the URLs clients quote in audits. A soak test on one round-trip flow per critical app, failing when listeners or nodes drift. Scheduled field and lab monitoring on the same money routes so you notice when Tuesday's deploy changes INP even if memory was already flat.

If you manage multiple client SPAs and want scheduled PageSpeed runs with alerts when INP or LCP cross budgets, that is the lane we built Apogee Watcher for. It will not replace a soak test, and a soak test will not replace watching production URLs after each release. Start a free trial with the routes you already report, then add the nightly soak job your admin users have been simulating with forced reloads.

References

Top comments (0)