Preventing React context leaks across parallel Vitest workers
This page is part of the Isolation Principles section, which covers every technique for cutting a React component free from its environment before you assert on it. Where the guide to isolating a single React component render focuses on decoupling one render from its providers, this page tackles a different failure mode: state that escapes a test entirely and surfaces in a different test running on a parallel worker.
The specific problem: a value stored at module scope — an exported singleton, a cached client, or the default passed to createContext — is written by one test and read by another. When Vitest runs test files in parallel, the order in which workers execute is nondeterministic, so the suite passes on your machine and fails in CI, or passes today and fails after you add an unrelated test file.
Problem statement
Vitest parallelises by running test files across a pool of workers. Each worker executes one file at a time, but a single worker runs many files over the life of a run, and multiple workers run concurrently. Any state that lives outside a test — at module scope, on globalThis, or inside a native singleton — can therefore be written by one test and observed by a later, unrelated one.
The classic offenders in a React codebase are a module-level mutable singleton (an analytics client, a feature-flag registry, an event bus) and a Context whose default value is an object that some code mutates in place. Because these are created once when the module is first imported, and Vitest caches modules per worker, the mutation persists across every test that shares that worker.
The symptom is order-dependent flakiness: expect(flags.betaCheckout).toBe(false) passes when its file runs first and fails when another file flipped the flag earlier. Re-running the single file “fixes” it, which is exactly what makes the bug so expensive to chase.
Root cause
The root cause is mutable state held at module scope combined with a shared module registry. The isolation principles that keep one render deterministic do nothing here, because the leak happens between files, not inside a render. When two test files import the same module, and the runner reuses one evaluated copy of that module across the worker, a write from file A is a read for file B.
Vitest’s isolate option controls whether modules are re-evaluated per file, and the pool option controls whether workers are threads (shared process, shared native singletons) or forks (separate processes). A mutated Context default is a special case of the same disease — the argument to createContext is a single process-wide object, so writing to it seeds state into every consumer that renders without a provider.
Minimal reproduction
Two test files, one shared singleton, one mutated Context default. Neither file is wrong in isolation; together they fail depending on order.
// src/featureFlags.ts — mutable module-level singleton
export const flags = {
betaCheckout: false,
};
export function enableBetaCheckout() {
flags.betaCheckout = true; // mutates the shared object in place
}
// src/context/ThemeContext.tsx — mutable default value (anti-pattern)
import { createContext } from 'react';
// The object passed to createContext is a single process-wide instance.
export const themeDefault = { name: 'light', overrides: {} as Record<string, string> };
export const ThemeContext = createContext(themeDefault);
// checkout.test.tsx — writes to shared state
import { enableBetaCheckout } from '@/featureFlags';
import { themeDefault } from '@/context/ThemeContext';
test('enables beta checkout and records a theme override', () => {
enableBetaCheckout();
themeDefault.overrides.accent = '#c00'; // mutates the shared default
expect(flagsAreOn()).toBe(true);
});
// header.test.tsx — reads state it never set
import { flags } from '@/featureFlags';
import { themeDefault } from '@/context/ThemeContext';
test('beta checkout is off by default', () => {
expect(flags.betaCheckout).toBe(false); // fails if checkout.test ran first
expect(themeDefault.overrides.accent).toBeUndefined(); // also leaked
});
Run the two files together and header.test.tsx fails intermittently: expected true to be false. Run it alone and it passes. That gap between “passes alone” and “fails in the suite” is the signature of a worker leak.
Step-by-step fix
1. Give each file its own module registry
Set the forks pool and turn isolate on. This is the configuration-level guarantee that no evaluated module — and therefore no module-level singleton — is shared between files.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
pool: 'forks', // separate process per worker
isolate: true, // re-evaluate modules for every test file
setupFiles: ['./src/test/setupTests.ts'],
sequence: {
shuffle: true, // randomise file order so leaks fail loudly, not silently
},
},
});
What this does: pool: 'forks' runs each worker in its own OS process, so even native singletons that survive module re-import cannot cross between concurrently running files. isolate: true re-evaluates the module graph per file, so a mutation in checkout.test.tsx never reaches header.test.tsx. shuffle: true turns any residual leak into a deterministic failure instead of a rare one.
2. Remove mutable state from module scope
Configuration prevents leaks between files, but two tests inside the same file still share module state. The durable fix is to stop holding mutable state at module scope at all — expose a factory that returns a fresh instance.
// src/featureFlags.ts — factory instead of a shared singleton
export interface FeatureFlags {
betaCheckout: boolean;
}
export function createFeatureFlags(overrides: Partial<FeatureFlags> = {}): FeatureFlags {
return { betaCheckout: false, ...overrides };
}
// src/context/ThemeContext.tsx — no mutable default
import { createContext, useContext } from 'react';
export interface Theme {
name: 'light' | 'dark';
overrides: Record<string, string>;
}
// Default is null; consumers must be wrapped in a provider.
export const ThemeContext = createContext<Theme | null>(null);
export function useTheme(): Theme {
const value = useContext(ThemeContext);
if (!value) throw new Error('useTheme must be used within <ThemeProvider>');
return value;
}
What this does: nothing is created once and mutated. Every call to createFeatureFlags returns a new object, and the Context default is null, so a missing provider throws immediately instead of silently sharing one mutable object across every consumer.
3. Recreate state in beforeEach
For state that must exist per test — a store, a query client, a provider’s initial value — build it fresh in beforeEach so it never carries from one test to the next within a file.
// header.test.tsx — fresh state per test
import { beforeEach, expect, test } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeContext, type Theme } from '@/context/ThemeContext';
import { createFeatureFlags, type FeatureFlags } from '@/featureFlags';
import { Header } from './Header';
let flags: FeatureFlags;
let theme: Theme;
beforeEach(() => {
flags = createFeatureFlags(); // betaCheckout: false, every test
theme = { name: 'light', overrides: {} }; // fresh provider value
});
test('beta checkout is off by default', () => {
expect(flags.betaCheckout).toBe(false);
});
test('renders the header inside an explicit theme provider', () => {
render(
<ThemeContext.Provider value={theme}>
<Header flags={flags} />
</ThemeContext.Provider>
);
expect(screen.getByRole('banner')).toBeInTheDocument();
});
What this does: each test receives its own flags and theme objects. Even if a test mutates them, the mutation dies at the end of the test because beforeEach rebuilds both before the next one runs.
4. Fail fast on ambient reads
Add a setup file that resets shared browser state and hard-fails on console.error, so a leak that slips past the other layers surfaces on the first run rather than as a flaky CI job.
// src/test/setupTests.ts
import { afterEach, beforeEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
vi.spyOn(console, 'error').mockImplementation((...args) => {
throw new Error(`console.error during test: ${args.join(' ')}`);
});
beforeEach(() => {
vi.useRealTimers();
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
cleanup(); // unmount React trees so no provider survives the test
vi.clearAllMocks();
});
What this does: cleanup() tears down every mounted component so no provider or effect outlives its test, and clearing storage plus mocks removes the ambient state that a leaking test would otherwise read. Throwing on console.error means React’s own “leaked state” and act() warnings fail the test instead of scrolling past in the log.
Verification
Run the suite with shuffled order and multiple workers. If the fix holds, order no longer matters.
npx vitest run --pool=forks --no-file-parallelism=false
Expected output once state is isolated:
✓ src/checkout.test.tsx (1 test) 14ms
✓ src/header.test.tsx (2 tests) 11ms
Test Files 2 passed (2)
Tests 3 passed (3)
Prove the leak is gone by forcing the worst-case order many times — a leak would surface as an intermittent failure across runs:
for i in $(seq 1 20); do npx vitest run --sequence.shuffle || break; done
Twenty green runs with shuffled file order is strong evidence that no state crosses a worker boundary. Before the fix, header.test.tsx fails on the runs where checkout.test.tsx is scheduled first:
FAIL src/header.test.tsx > beta checkout is off by default
AssertionError: expected true to be false
Edge cases and caveats
isolate: falsefor speed reintroduces the risk. Teams sometimes setisolate: falseto shave seconds off a large suite. That reuses the module registry within a worker, so module-level singletons leak again. If you need the speed, keepisolate: trueand instead reduce work by sharding tests across runners rather than disabling isolation.- Native singletons survive even the forks pool per worker. A module that registers a global (
globalThis.__APP_BUS__) or patches a native prototype persists across every test in that worker regardless ofisolate. Reset those explicitly inbeforeEach, or better, stop attaching state toglobalThis. vi.mockfactories run once per file, not per test. If a mock factory captures a mutable object, that object is shared across the file’s tests. Return the state from a function the test calls inbeforeEach, or usevi.mocked(fn).mockReturnValue(...)insidebeforeEachinstead of baking values into the factory.
FAQ
Why does my Vitest suite pass in isolation but fail when run in parallel?
A value held at module scope — an exported singleton, a mutable Context default, or a cached client — is mutated by one test and read by another. When Vitest runs files in parallel with a shared module registry, that mutation is visible across files, so the outcome depends on which worker ran first. Enabling pool: 'forks' with isolate: true gives each file a fresh registry, and resetting the state in beforeEach removes the carry-over inside a file.
Does pool: ‘threads’ share state between test files?
With isolate: true (the default) Vitest re-imports modules per file even on the threads pool, so module state is not shared between files. The threads pool can still leak through genuinely global objects such as globalThis, the DOM, or native singletons that survive re-import. The forks pool uses a separate process per worker and is the stronger guarantee when a dependency caches state outside the module system.
Should I mutate a Context default value to seed test data?
No. The value passed to createContext is a process-wide default shared by every consumer that has no provider above it. Mutating it seeds state into every other test in the same worker. Wrap the component in a provider with explicit initial state instead, and create a fresh provider in beforeEach.
Related
- Isolation Principles — the parent section covering the full range of environment-decoupling techniques this page implements one slice of.
- How to Isolate React Components for Unit Testing — the companion page on decoupling a single render from its providers, which pairs with the per-worker isolation described here.
- Mock Boundaries — how to design contract-based interceptors so mocked modules do not become the next thing that leaks between tests.
- Managing Component State During Automated Tests — state injection patterns for Redux, Zustand, and React Query that keep per-test state explicit.
- Component Testing Fundamentals — the top-level guide to the entire testing workflow this page sits within.