Skip to content
Rémy Duthu Rémy Duthu
May 19, 2026 · 6 min read

Vitest's isolate:false buys you 30% speed and a class of flake you cannot grep for

Why disabling per-test module isolation creates cross-file leaks that look identical to logic bugs, what the failure modes actually look like, and the audit pass that lets you keep the speed.

A perf-conscious engineer flips isolate: false in vitest.config.ts and the suite runs 30% faster. CI is happy for two weeks. Then three tests start failing intermittently — not the same three, and never together. Reverting the config makes them green again. The config flip caused the failures, but rolling back is the wrong response: the underlying state-sharing bugs were always there, and isolate: false is just the canary.

We see this pattern often enough on Mergify Test Insights that it earned its own slot in our flaky Vitest catalog. The cause is module-level singletons surviving across test files when isolation is off. The fix is an audit pass on the modules your tests touch.

What you see

// vitest.config.ts
export default defineConfig({
  test: { isolate: false },
});

That is the entire change. The default is isolate: true, which gives every test file a fresh module graph. Setting it to false reuses the cached module graph across files in the same worker. The speed win is real: parsing and evaluating TypeScript modules is the dominant cost for small test files, and reusing the cache eliminates it.

The failures look like ordinary logic bugs:

// flags.ts (production code)
let cached: Flags | null = null;
export function flags(): Flags {
  if (!cached) cached = loadFlags();
  return cached;
}
export function _resetForTesting() { cached = null; }

// a.test.ts
test("loads flags from env", () => {
  process.env.NEW_BILLING = "true";
  expect(flags().newBilling).toBe(true);
});

// b.test.ts (different file, same worker)
test("defaults to legacy billing", () => {
  expect(flags().newBilling).toBe(false); // fails: true
});

flags.ts caches the loaded flags. a.test.ts populates the cache. b.test.ts reads the cached value, sees the override from a.test.ts, fails.

Under isolate: true, each file gets a fresh module graph, the cache is empty when b.test.ts runs. Under isolate: false, the module graph is shared, the cache survives. The failure is real — the production code is genuinely depending on call order — but it only surfaces when the optimization is on.

Why the audit is hard

The bug is invisible at the test level. b.test.ts does not import a.test.ts. It does not even know a.test.ts exists. The shared state is in flags.ts, which both files import indirectly through the production code.

You cannot grep for the bug. The smell is “module-level mutable state in production code that tests inadvertently touch.” Every let at module scope, every cached singleton, every Sinon-style _resetForTesting helper is a candidate. Most large codebases have dozens of these. Most of the time the cache is correctly invalidated by something else, so tests do not see the leak.

isolate: false exposes the ones that are not. The audit is “find every module-level mutable in production code, check whether tests rely on its state being clean.”

The naive fix and why it is incomplete

// b.test.ts
import { _resetForTesting } from "./flags";

beforeEach(() => _resetForTesting());

Add a per-test reset. Works for b.test.ts. Does not work for the next file that imports flags and forgets to reset. Every test that touches a cached module needs the reset, forever, including tests written by people who do not know isolate: false is on. The fix shifts the burden from the framework to every test author.

// vitest.config.ts
export default defineConfig({
  test: {
    isolate: false,
    poolMatchGlobs: [
      ["**/integration/*.test.ts", "isolate-true"],  // this is not a real option
    ],
  },
});

Vitest does not offer per-file isolation overrides. You enable isolation everywhere or disable it globally. Workarounds (running integration tests in a separate vitest run invocation) work but split the suite into two CI steps, which often costs more than the 30% speedup saved.

The fix that holds

Refactor the production code to remove module-level mutable state where you can:

// flags.ts
export function loadFlags(): Flags {
  return /* read from env */;
}

// caller
const flags = loadFlags();

No cache, no singleton, no leak. The test imports loadFlags, calls it, asserts on the result. Each call is fresh. Whether isolate is on or off does not matter.

For caches that genuinely need to be process-wide (a connection pool, an LRU for an expensive computation), wrap them in a class and inject the instance:

// flags.ts
export class FlagsService {
  private cached: Flags | null = null;
  flags(): Flags {
    if (!this.cached) this.cached = loadFlags();
    return this.cached;
  }
}

// production: one instance at app startup
export const flagsService = new FlagsService();

// tests: a fresh instance per test
test("loads flags from env", () => {
  const service = new FlagsService();
  process.env.NEW_BILLING = "true";
  expect(service.flags().newBilling).toBe(true);
});

The class encapsulates the cache. Tests construct their own. The production singleton is the only one that survives across calls, and it is the only one CI tests use.

For caches you cannot refactor, register the reset in a vitest/setup.ts file that imports every cached module and resets each in beforeEach. Centralizing the reset means new caches need to be added to the setup file, and someone reviewing the setup file sees the full list of cached modules — which is itself useful documentation.

When isolation is the right default

For most suites, isolate: true is the right call. The performance hit is bounded (parsing is a fraction of total run time once you account for actual test work) and the safety net is huge. Reserve isolate: false for suites where:

  • Test files are small and numerous (so module load is the dominant cost)
  • Production code is mostly pure (no module-level caches, singletons, or registries)
  • You have a CI policy that catches cross-file leaks early (Test Insights, or a similar tool)

If those do not all hold, leave isolation on.

How Mergify catches this before you ship

Cross-file leaks under isolate: false are the hardest flakes to triage manually. The failing test is not the test with the bug. The test author looks at the file, sees nothing wrong, retries until green. The bug compounds: more cached state accumulates, more tests start failing, eventually someone reverts the config flip and concludes “isolate: false is broken.”

Test Insights detects the cross-file signature: file B fails consistently after file A ran on the same worker, and never alone. The dashboard tags the dependency and surfaces both files together. You see the actual culprit (the test that mutated the cached module) on the same screen as the symptom.

For teams running isolate: false, this is the difference between keeping the 30% speedup and reverting the change.

To preserve the speed win without the flake risk, point Mergify at your suite. Native plugin: @mergifyio/vitest. One npm install and you’re set.

More patterns like this

isolate: false cache sharing is one of the eight patterns in the flaky-tests-in-Vitest guide. The others are variants of the same theme: defaults that optimize for speed at the cost of obvious semantics. Thread-pool state leakage, vi.mock hoisting traps, snapshot races inside test.concurrent, fake-timer leakage. One bug class, many faces.

Once the test infrastructure makes the leak visible, the underlying production-code refactor (drop the singleton, inject the cache) is usually a one-PR change.

Test Insights

Tired of flaky tests blocking your pipeline?

Test Insights detects flaky tests, quarantines them automatically, and tracks test health across your suite.

Try Test Insights

Recommended posts

Testing

Hypothesis is not flaky. Your code under test is, and Hypothesis is the messenger.

May 17, 2026 · 5 min read

Hypothesis is not flaky. Your code under test is, and Hypothesis is the messenger.

Why a property test that passed for months suddenly fails on a CI run with an example you cannot reproduce locally, the @example pattern that pins the failing case, and why the right response is never to delete the test.

Rémy Duthu Rémy Duthu
Testing

RSpec system specs see an empty database. database_cleaner is why.

May 15, 2026 · 6 min read

RSpec system specs see an empty database. database_cleaner is why.

How Capybara's separate browser connection breaks transactional fixtures, the per-spec-type strategy that fixes the leak, and the modern Rails 7.1 alternative that makes database_cleaner optional.

Rémy Duthu Rémy Duthu
Testing

Playwright route handlers fire only on requests they were registered before

May 13, 2026 · 5 min read

Playwright route handlers fire only on requests they were registered before

Why a `page.route()` call placed after `page.goto()` silently misses the request you wanted to intercept, and the registration-order rule that makes network mocks deterministic.

Rémy Duthu Rémy Duthu