The Vitest cache, explained: what it stores, when to keep it, and when it lies to you
Vitest has two caches, and most teams cache the wrong one in CI. Here's what each does, how to configure them, and how one of them can make your test order nondeterministic.
BuildPulse Team
June 13, 2026

The ritual
Every JavaScript team has the ritual. A test fails with some inscrutable transform error — Cannot find module, a stale snapshot of a file that was deleted last week, an import that resolves locally but not on the runner — and someone in the PR thread types the incantation: "try clearing the Vitest cache." Someone runs rm -rf node_modules/.vite, the test passes, everyone moves on, and nobody learns anything.
I'm not going to tell you the ritual never works. It does, sometimes, which is exactly why it's dangerous. If you're a platform engineer responsible for CI, "delete the cache and pray" is not a runbook entry you want to defend in a change-management review. So let's actually pull the thing apart: what the Vitest cache stores, how to carry it across CI runs without shooting yourself in the foot, and the one cache behavior that can quietly make your test order nondeterministic.
Vitest has two caches, not one
When people say "the Vitest cache," they usually mean one blob of mystery state in node_modules. It's actually two separate things with very different jobs:
1. The Vite transform and deps-optimizer cache. Vitest runs on Vite, and Vite caches the output of dependency pre-bundling (esbuild crunching your node_modules imports into something fast to load) plus transformed modules. This lives in Vite's cacheDir, which defaults to node_modules/.vite. This is the cache that makes your second vitest run start noticeably faster than your first, especially in a large TypeScript monorepo with heavy dependencies.
2. The Vitest results cache. Vitest also records the outcome and duration of every test file from previous runs. It uses this to be clever about ordering: by default, the sequencer runs previously failed tests first and slower tests earlier (so they parallelize better across threads). This cache historically lived in node_modules/.vitest; in Vitest 3 the standalone cache.dir option is deprecated and the results cache moved under Vite's cacheDir instead.
The first cache is about speed. The second is about scheduling. They fail in completely different ways, and the correct CI strategy for each is different — which is the part most blog posts and most actions/cache snippets get wrong.
Configuring it (and the Vitest 3 change)
If you're on Vitest 3+, the supported knob is Vite's cacheDir:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
cacheDir: 'node_modules/.vite', // the default; shown for clarity
test: {
// disable the results cache entirely if you want deterministic ordering
cache: false,
},
})
A few things worth knowing:
test.cache.diris deprecated as of Vitest 3. If your config still sets it, you'll get a warning and it'll eventually break. Move to top-levelcacheDir.vitest --no-cacheskips the results cache for a single run without touching config.rm -rf node_modules/.vitenukes both caches in modern Vitest, since the results cache lives insidecacheDirnow. On older versions you may also neednode_modules/.vitest.
If you're debugging "works locally, fails in CI" and you suspect cache state, the honest first step is to reproduce locally with --no-cache plus a cleared cacheDir. If the failure survives a cold cache, stop blaming the cache. If it doesn't, you've found a real staleness bug worth filing — usually a transform invalidation miss after a dependency or tsconfig change.
Caching Vitest in GitHub Actions: do it, but cache the right thing
CI runners start cold. No node_modules/.vite, no pre-bundled deps, nothing. For a big suite, that means every CI run pays the deps-optimizer tax that your laptop only pays once. Carrying the transform cache across runs with actions/cache is a legitimate win:
- name: Restore Vitest transform cache
uses: actions/cache@v4
with:
path: node_modules/.vite
key: vitest-vite-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
vitest-vite-${{ runner.os }}-
Two opinions about that snippet, both earned the hard way:
Key on the lockfile, and accept partial restores. The deps-optimizer output is a function of your dependencies, so hashFiles('pnpm-lock.yaml') is the right primary key. The restore-keys fallback means a dependency bump still gets a mostly warm cache — Vite invalidates what changed and reuses the rest. If you key on github.sha, you get a 0% hit rate and a cache that's pure overhead. If you key on nothing, you get a cache that grows stale forever.
Measure before you celebrate. Cache restore and save aren't free — actions/cache has to download and upload a tarball, and on GitHub-hosted runners that network hop can eat most of your savings. I've seen teams add a cache step that saved 40 seconds of pre-bundling and spent 35 seconds on transfer. Run ten builds with the cache and ten without, compare the vitest step duration and the total job duration, and keep it only if the whole job got faster. (This is the same discipline we push for any GitHub Actions caching decision — caches are a performance feature, and performance features you don't measure are decorations.)
For what it's worth, this is one of the quieter advantages of self-hosted or managed runner fleets like BuildPulse Runners: cache locality. When the cache lives next to the runner instead of across the public internet, the restore cost drops to nearly nothing and the math changes in your favor. But the measurement discipline applies either way.
The results cache: where "cache" meets "can I trust my CI signal?"
Here's the part that earns this post a spot on a flaky-test company's blog.
Remember that the results cache reorders your tests — failed tests first, slow tests early. That's genuinely useful on a laptop: you get failure feedback fast. But it has a sharp edge that most teams never notice: your test order now depends on the history of previous runs.
On a cold CI runner with no results cache, Vitest orders test files deterministically. Fine. But if your actions/cache path sweeps up the results cache along with the transform cache — which it will, in Vitest 3, because they share cacheDir — then your CI test order changes based on what failed and how long things took in some previous build, possibly on a different branch.
Now walk through what that does to a hidden order dependency. Suppose checkout.test.ts accidentally relies on a module-level singleton that cart.test.ts happens to initialize. As long as the order holds, everything's green. Then one build is slow, the timing data shifts, the sequencer reshuffles, checkout.test.ts runs first — and fails. The next run restores a different cache entry, the order shifts back, and it passes. Congratulations: you've built a flakiness generator out of a performance optimization, and the failure pattern is correlated with cache state, which is roughly the last place anyone looks.
This class of failure — order-dependent tests masked by stable ordering — is one of the most common things we see when teams start tracking their flaky tests systematically instead of rerunning them. The test was always broken. The ordering was just polite enough to hide it.
My recommendation for CI:
// vitest.config.ts
export default defineConfig({
test: {
cache: process.env.CI ? false : undefined,
},
})
Disable the results cache in CI, keep it locally. You lose failed-first ordering on the runner — which you weren't really using anyway, since CI runs the whole suite — and you gain deterministic ordering, which makes every failure reproducible. Reproducibility beats a marginal scheduling win every single time. If you operate under SOC2 or similar change-management controls, deterministic CI behavior is also just an easier story to tell: "the same commit produces the same test execution" is a sentence your auditors will like.
If you want to go further and flush out order dependencies on purpose, flip the sequencer to random with a logged seed:
vitest run --sequence.shuffle --sequence.seed=$GITHUB_RUN_ID
Log the seed in the job output so any failure is replayable locally with the exact same order. Shuffling without a recorded seed is just generating unreproducible failures, which is worse than not shuffling.
A short field guide
- CI is slow on cold starts → cache
node_modules/.vitekeyed on your lockfile, measure the end-to-end delta, keep it only if the job got faster. - Weird transform or resolution errors after a dep bump → cold-start once (
rm -rf node_modules/.vite && vitest --no-cache). If that fixes it, your cache key probably isn't capturing everything that affects transforms — consider addingvitest.config.tsandtsconfig.jsontohashFiles. - A test fails intermittently and the failures don't correlate with code changes → check whether the results cache is riding along in your CI cache. Set
cache: falsein CI, rerun, and see if the flake pattern changes. If it does, you have an order-dependent test, not a cache problem. - Someone proposes "clear cache" as a permanent retry step → push back. A scheduled cache wipe is an admission that you don't know your invalidation keys. Fix the keys.
The Vitest cache is a good piece of engineering. The transform cache deserves a place in your CI pipeline; the results cache deserves a place on your laptop and nowhere else. Keep those two straight and the ritual incantation can finally retire — or at least get demoted from "fix" to "diagnostic step three, after we've checked the things we can actually explain."
Related posts