Article · Root Causes of JavaScript Test Flakiness

Debugging Async State Leaks in React E2E Tests

When an end-to-end suite fails non-deterministically across sequential runs — test 7 passes alone but fails after test 6 — the culprit is usually a React async state leak: a pending promise, an uncancelled fetch, or a live subscription that survives an unmount and bleeds into the next test. This page expands Async State Management in E2E Tests under the Root Causes of JavaScript Test Flakiness track, with a systematic way to spot, isolate, and seal these leaks.

10 sections URL: /root-causes-of-javascript-test-flakiness/async-state-management-in-e2e-tests/debugging-async-state-leaks-in-react-e2e-tests/
A leaked subscription bleeding across tests An interval started in test 1 without cleanup keeps firing during test 2, polluting its state. test 1 setInterval(fetch) no cleanup return test 2 stray fetch fires state polluted
Without a cleanup return, the interval from test 1 keeps firing into test 2 and corrupts its baseline.

Identifying State Leak Signatures #

The tell-tale signs are DOM elements holding stale data and network requests firing at the start of a test that never requested them. Capture the evidence: Playwright’s --trace records network and DOM activity per test, while Cypress’s command log surfaces requests that fire outside any intercept scope. Compare heap allocations before and after each spec to spot retention spikes.

Heap growth across specs signals a leak A clean suite returns to baseline heap each test; a leaking suite grows monotonically. clean leaking heap (MB) across successive specs
A monotonically rising heap across specs is the clearest fingerprint of a retained subscription.

Isolating the Leak Source #

Run sequentially with --retries=0 and bisect to the failing spec. Add beforeEach/afterEach hooks that clear React state stores and reset mocked timers, then correlate the leak with a specific useEffect dependency array. Narrowing to one effect is faster than auditing the whole component tree.

Bisect to the leaking spec Halving the spec range each run isolates the single spec whose effect lacks cleanup. specs 1–16 fail intermittently 9–16 spec 13 leaks
Bisection turns "somewhere in the suite" into a single spec and effect to fix.

Implementing Deterministic Cleanup #

Every useEffect that starts async work must return a teardown that aborts fetches and clears intervals. Pair that with fresh browser contexts per spec so nothing survives the boundary.

Effect lifecycle with a cleanup return Mount starts the effect, unmount runs the cleanup that clears the interval, so nothing leaks. mount → effect unmount clearInterval() the return function is the seal on the leak
The cleanup return is what stops the interval from outliving the component.
// Playwright: a fresh context per test guarantees no carry-over.
import { test } from '@playwright/test';
test.use({ storageState: undefined }); // no auth cookies/localStorage bleed between tests
// React: the return runs on unmount or dep change — without it the interval outlives the test.
useEffect(() => {
  const id = setInterval(fetchData, 5000);
  return () => clearInterval(id); // trade-off: none — omitting this is the leak
}, [fetchData]);

Validation & Regression Prevention #

Lock the fix in place so it cannot regress: monitor heap deltas and unhandled rejections in CI, snapshot global stores after each test, and lint for missing cleanup returns.

Three guards against regression Heap-delta monitoring, post-test store snapshots, and exhaustive-deps lint keep leaks from returning. heap deltaCI alert on growth store snapshotpost-test assert exhaustive-depslint rule
Automated guards catch a re-introduced leak at commit time, not three sprints later.
// cypress.config.ts — isolation + retries surface any regression fast.
import { defineConfig } from 'cypress';
export default defineConfig({
  e2e: {
    testIsolation: true, // clears cookies/localStorage/sessionStorage between tests
    retries: { runMode: 2, openMode: 0 }, // a leak that survives isolation still shows as a retry-pass
  },
});

Where React State Actually Escapes a Test #

“State leak” covers several distinct mechanisms in a React application, and knowing which one you are looking at determines the fix.

Module-scope caches are the most common and the least visible. A query client, a store instance, a memoised selector cache or a context default created at module level is shared by every test in the worker, so data fetched in one test is served from cache in the next. The symptom is a component rendering data it never requested — frequently the previous test’s data, which makes the failure look like a mixed-up fixture.

Subscriptions that outlive the component keep firing after unmount: an event listener on window, a store subscription, an interval started in an effect with no cleanup. These produce errors attributed to the next test, often referencing a component that is no longer mounted.

In-flight requests land after teardown. The response arrives, a setter runs against an unmounted tree, and either a warning or an error appears somewhere unrelated. The distinguishing feature is that the failure moves when test order or timing changes.

Portal and overlay nodes attached to document.body are not removed by unmounting a container, so a query for a dialog in the next test can match the previous one’s markup — which reads as a duplicate-element error rather than as leakage.

Each has a different remedy, and the diagnosis is quick: a solo re-run separates leakage from everything else, and the error’s reference to an unmounted component or to a stale value usually names which of the four is involved.

// Reset the shared surfaces React applications typically create at module scope.
// Trade-off: recreating the client per test costs a little set-up time and is
// what stops one test's fetched data being served to the next.
beforeEach(() => {
  queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
});
afterEach(() => {
  queryClient.clear();
  cleanup();                    // unmount, run effect cleanups, remove portals
});

Proving the Leak Is Gone #

A fix here is easy to believe and hard to verify, because the failure was intermittent to begin with. Three checks together make the verification defensible.

Re-run the previously failing test in the suite, repeatedly. The rate before the fix is the baseline; the same count after it is the evidence. A single green run proves almost nothing when the original rate was a few percent.

Run the suite under several shuffle seeds. Leakage is order-sensitive by nature, so a fix that survives twenty seeds is materially stronger than one that survives the order that happened to be used today.

Add a probe. A two-test pair — one that deliberately writes the state in question, one that asserts it is absent — fails immediately if the cleanup is ever removed, which is what turns a fix into a guarantee rather than a moment in time.

The probe is the underrated one. Cleanup code is regularly deleted during refactors because nothing depends on it visibly, and a probe makes that dependency explicit at the cost of a few lines.

Common Pitfalls #

Leak sources ranked by frequency Missing cleanup returns are the most common leak source, ahead of shared contexts, un-aborted fetches, and StrictMode surprises. missing cleanup shared context un-aborted fetch StrictMode double
Fix the missing-cleanup returns first — they account for most cross-test pollution.
  • Relying on setTimeout/cy.wait() instead of explicit state assertions.
  • Missing cleanup returns in useEffect for async fetches.
  • Sharing browser contexts or localStorage across E2E files.
  • Ignoring React StrictMode’s intentional double-invoke — it surfaces missing cleanup; give every effect teardown.
  • Leaving pending XHR/fetch calls un-aborted before unmount.

FAQ #

How do I distinguish a state leak from a network race condition? A leak persists across test boundaries and shifts DOM or memory baselines; a network race is transient within one test. Disable network intercepts — if the failure persists, it is leaked state, not timing.

Does React StrictMode cause false-positive flakiness in E2E tests? StrictMode double-invokes effects in development to expose missing cleanup. Give every async effect a teardown return. Most E2E setups run the production build, where StrictMode does not double-invoke.

What is the most reliable way to force React unmounting between tests? Navigate to a blank route or use a fresh browser context, then assert document.querySelector('#root') has no children before mounting the next component.

Is an act warning worth chasing? Yes. It is the framework reporting that state updated outside the window the test controlled, which is the same late-work problem in a more diagnosable form. Silencing it hides an ordering issue that will resurface as an intermittent failure elsewhere; resolving it — by awaiting the settled state or cancelling the work in teardown — removes the cause.

Why does the leak only appear in CI? Because timing decides whether the late work lands inside the test that started it or the one after. On a fast machine the response arrives before teardown and nothing escapes; on a slower, contended runner it arrives afterwards. That is why a leak can be genuinely invisible locally and consistent in CI, and why reproducing under CI-like CPU limits is more productive than repeating on an idle laptop.

Does a fresh browser context per test remove the problem? It removes the browser-side half — storage, cookies, in-page state — and none of the process-side half in component or unit tests, where the module registry, caches and timers live in the test runner rather than in a browser. Both halves need their own reset, and assuming the runner’s isolation covers everything is a common source of surprise.

Cleanup That Cannot Be Forgotten #

The most durable version of every fix in this guide is one that a future contributor cannot omit. Cleanup written into an individual spec protects that spec; cleanup expressed in shared setup protects the file somebody adds next quarter.

Three placements are worth using together: a global teardown that unmounts, clears timers and resets mocks; a shared fixture that constructs per-test instances of anything that would otherwise be module-scoped; and a lint rule forbidding module-level mutable exports in application code, which removes the surface entirely rather than resetting it.

Each is small on its own, and together they change the default from “isolated if remembered” to “isolated unless deliberately broken”.

When a leak resists identification, narrowing the suite is faster than reasoning about it: run half the files, keep the half that reproduces, and repeat. Six rounds is usually enough to name the file, and the procedure needs no understanding of what the leak actually is.

Cleanup expressed in shared setup outlives the person who wrote it.