Vitest slow? Read the duration breakdown before you touch your config
Vitest tells you exactly where the time goes. Most teams never read it. Here's how to diagnose a slow Vitest suite and fix the real bottleneck instead of guessing.
BuildPulse Team
September 9, 2026
Listen

The line everyone scrolls past
A platform engineer pinged me last month: "Vitest is slow. Our unit suite takes nine minutes in CI and about ninety seconds locally. Do we need to switch back to Jest?"
No. And also, the answer was sitting in their CI logs the whole time. At the end of every run, Vitest prints a duration breakdown that tells you precisely where the time went:
Test Files 482 passed (482)
Tests 3,847 passed (3,847)
Start at 14:02:11
Duration 9m 12s (transform 41s, setup 3m 18s, collect 1m 02s, tests 2m 55s, environment 2m 40s, prepare 12s)
Most teams read the first number, sigh, and start cargo-culting config changes off GitHub issues. Don't. That breakdown is a profiler output, and each bucket points at a different fix:
- transform: Vite converting your TS/JSX to runnable JS. High numbers mean you're transforming too much code per test file.
- setup: your
setupFilesexecuting. This runs per test file, not once. A 500ms setup file across 482 files is four minutes of pure overhead. - collect: importing test files to discover tests. Dominated by your import graph. Barrel files live here.
- tests: actually running assertions. This is the only bucket you probably can't shrink much.
- environment: standing up jsdom or happy-dom. Again, per file.
- prepare: worker startup.
Note that these buckets are summed across workers, so they can exceed wall-clock time. That's fine. You're not looking for the totals to add up; you're looking for which bucket is embarrassingly large relative to tests. In the example above, tests is under three minutes and everything else is six. That suite isn't slow because the tests are slow. It's slow because of overhead, and overhead is fixable.
Let's go through the buckets in the order they usually hurt.
Setup and environment: the per-file tax
The single most common reason a Vitest suite feels slow is that every test file pays a fixed tax before a single assertion runs: boot an environment, run all setup files, then run the test. Multiply a modest 800ms tax by 500 files and you've bought yourself hours of aggregate compute.
Two fixes, in order of impact.
Stop using jsdom for tests that don't need a DOM. Teams set environment: 'jsdom' globally because some components need it, and then their pure-logic tests (validators, reducers, API clients) pay for a fake browser they never touch. jsdom setup routinely costs 200–400ms per file. Make node the default and opt into jsdom only where needed:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node', // default for everything
},
})
// button.test.tsx
// @vitest-environment jsdom
import { render } from '@testing-library/react'
On larger codebases, split this into Vitest projects (one node project, one jsdom project) so the intent is structural instead of a docblock convention someone forgets.
Audit your setup files like they run 500 times, because they do. I've seen setup files that initialize a mock service worker, parse a fixtures directory, and configure a fake timer, all for tests where two of those three are irrelevant. Move expensive setup into the specific projects or files that need it. If something genuinely must run once per worker rather than once per file, globalSetup exists for exactly that.
Isolation: what you're paying for and whether you need it
By default, Vitest runs every test file in a fresh isolated context. That's the safe default and it's the right one for most teams, because it prevents module-level state from leaking between files. It is also not free: fresh context means re-importing your module graph per file.
You can turn it off:
export default defineConfig({
test: {
isolate: false,
pool: 'threads',
},
})
On suites dominated by collect and prepare time, isolate: false can cut total duration by 30–50%. I've watched it take a suite from six minutes to three.
Here's the part of the blog post where I'm supposed to tell you to just do it. I won't, because I've also watched what happens six weeks later: a module singleton mutated in file A starts failing assertions in file Z, but only when the scheduler happens to put them in the same worker in that order. Congratulations, you've traded three minutes of runtime for order-dependent flaky tests, which are far more expensive than the compute you saved. Every red build that passes on rerun trains your engineers to ignore red builds, and in a SOC2 shop where CI gates are part of your change-management story, "we rerun until green" is not a sentence you want to say out loud.
So: disable isolation only if your tests don't mutate shared module state, and instrument the suite so you find out fast when that assumption breaks. This is precisely the failure mode BuildPulse's flaky test detection is built to catch, because order-dependent failures look random to a human reading one build but form an obvious pattern across hundreds of builds.
While you're in that config: try pool: 'threads'. Vitest defaults to forks (child processes) for compatibility reasons, but worker threads start faster and share memory more cheaply. Most suites work fine on threads. The ones that don't usually depend on native modules or process-level globals, and they fail loudly, so the experiment is cheap.
Transform and collect: your import graph is the problem
If transform and collect dominate, Vitest is spending its time turning TypeScript into JavaScript and walking imports, not running tests. The usual culprit is the beloved barrel file:
// src/utils/index.ts
export * from './dates'
export * from './currency'
export * from './pdf-generator' // imports a 2MB dependency
export * from './analytics' // imports the entire tracking SDK
A test that wants one date helper now transforms and evaluates the whole barrel, including the PDF generator and its dependency tree. Per file. In an isolated context. You can see exactly which modules are eating time by running a single slow test file with DEBUG=vite-node:* and watching what gets pulled in.
Fixes, cheapest first:
- Import from the concrete module (
utils/dates) instead of the barrel in test files and in the code under test. Lint rules likeno-restricted-importsmake this stick. - Mock heavyweight dependencies at the module boundary with
vi.mockso their trees never load. - Check
server.depshandling: if a large dependency is being inlined and transformed when it could be externalized (or vice versa for broken ESM packages), that's often several seconds per run.
Barrel files are a code-organization decision with a runtime bill, and the test suite is where the bill arrives.
"Fast locally, slow in CI" is usually just core count
Back to my friend with the 90-second local run and the 9-minute CI run. His laptop has 10 performance cores. The default GitHub-hosted runner for private repos has 2 vCPUs. Vitest parallelizes across files, so its wall-clock time scales almost linearly with cores until you run out of files. Same suite, one fifth the cores, roughly five times the duration. There's no config flag that fixes arithmetic.
You have two levers. The first is bigger runners. GitHub's larger hosted runners work but get expensive quickly at per-minute rates. This is, full disclosure, a thing we sell: BuildPulse runners are drop-in GitHub Actions runners that are roughly twice as fast at about half the cost, and CPU-bound test suites are the workload where more cores per dollar shows up most directly in your p50 build time. Whatever runners you use, set maxWorkers to match the actual vCPU count, because Vitest's detection inside containers can misread cgroup limits and either oversubscribe or leave cores idle.
The second lever is sharding, which brings us to the last section.
Sharding: the fix for suites that are just big
Once you've removed the overhead, some suites are still legitimately large. Fifteen minutes of real test execution doesn't compress; it splits. Vitest has first-class sharding:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx vitest run --shard=${{ matrix.shard }}/4 --reporter=blob
- uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shard }}
path: .vitest-reports/
merge-reports:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: .vitest-reports
merge-multiple: true
- run: npx vitest run --merge-reports
Two warnings from experience. First, sharding multiplies your fixed costs: four shards means four checkouts, four npm ci runs, four worker warmups. If your setup takes two minutes and your suite takes six, four shards saves you less than you think and costs 4x the setup minutes. Do the math before you pick a shard count, and cache aggressively. Second, merge the reports. Four disconnected test jobs turn "which test failed on this PR" into archaeology, and they fragment the failure history you need to spot flaky tests across builds.
The order of operations
When someone says "Vitest is slow," here's the sequence that actually works:
- Read the duration breakdown. Identify which bucket dwarfs
tests. - Kill per-file overhead: default to the
nodeenvironment, put jsdom behind opt-in, gut your setup files. - Fix the import graph: unbarrel, mock heavyweight modules, check dep handling.
- Consider
pool: 'threads', andisolate: falseonly with flake monitoring in place to catch the state leaks it invites. - Match CI cores to the workload, then shard what's left.
Most teams jump straight to step 5 because parallelism feels like engineering and deleting a setup file feels like housekeeping. But sharding an inefficient suite just runs the waste in parallel, on more machines, at your expense. Read the breakdown first. Vitest already told you what's wrong. The whole time.
Stop guessing which tests you can trust
BuildPulse finds your flaky tests, ranks them by the engineering time they cost, and lets you quarantine the worst in one click. See results on your first build.
Free to start · No credit card required · Setup is a single CI step
Related posts