Runners
8 min read

Vitest performance: why your suite is fast locally and slow in CI

Vitest is fast on your laptop and mysteriously slow in CI. Here's where the time actually goes, and the config changes that fix it without wrecking reliability.

BuildPulse Team

September 2, 2026

Listen

Vitest performance: making a slow suite fast in CI | BuildPulse Blog

The suite that takes 40 seconds on your laptop and 9 minutes in CI

A platform engineer I know spent a week fielding the same Slack complaint: "Vitest runs in under a minute on my machine, why is the CI check taking nine?" The suite hadn't changed. Nobody had added slow tests. The team had picked Vitest specifically because it was supposed to be the fast option, and here they were watching a progress bar in GitHub Actions like it was 2014.

The answer wasn't one thing. It never is. It was four things stacked on top of each other: a runner with a quarter of the cores of an M-series laptop, per-file worker isolation, a jsdom environment being booted for hundreds of files that never touch the DOM, and a setup file doing expensive work once per test file. Each one individually looked reasonable. Together they turned a fast test runner into a slow CI gate.

This post is the checklist I wish that engineer had. It's specifically about Vitest performance in CI, because that's where the pain is: local runs benefit from watch mode, warm caches, and big consumer CPUs. CI gets none of that by default.

Measure before you touch anything

Vitest already tells you where the time goes. At the end of a run you get a breakdown that almost nobody reads:

Duration  312.44s (transform 6.2s, setup 84.1s, collect 41.8s, tests 118.3s, environment 55.9s, prepare 6.1s)

Read it like this:

  • tests is time actually executing test bodies. If this dominates, your tests are genuinely slow and no runner config will save you.
  • environment is the cost of standing up jsdom or happy-dom, paid per test file. If this is large, you're paying DOM tax on files that don't need it.
  • setup is your setupFiles, also paid per test file. An 800ms setup file across 400 files is over five minutes of aggregate work.
  • collect is importing your test files and everything they pull in. Big numbers here usually mean barrel files dragging half the codebase into every test.
  • transform is Vite compiling your source. Usually small; if it isn't, look at heavyweight plugins.

Note these are aggregate numbers summed across workers, so they can exceed wall-clock time. That's fine. You're looking for proportions, not absolutes. Run with --reporter=verbose if you want per-file durations to find the outliers.

Do this on the actual CI runner, not your laptop. The proportions change dramatically when you go from 12 cores to 2.

Stop paying for jsdom everywhere

The single most common Vitest performance mistake I see: environment: 'jsdom' set globally because 30% of the tests render components, which means the other 70% boot a fake browser for no reason.

Split your suite with projects and give each slice the cheapest environment it can tolerate:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    projects: [
      {
        test: {
          name: 'unit',
          environment: 'node',
          include: ['src/**/*.test.ts'],
        },
      },
      {
        test: {
          name: 'components',
          environment: 'jsdom',
          include: ['src/**/*.test.tsx'],
          setupFiles: ['./test/setup-dom.ts'],
        },
      },
    ],
  },
})

Two wins here. The node project skips environment setup entirely, and the DOM-specific setup file (testing-library matchers, MSW handlers, the usual suspects) only runs for files that need it. On suites I've profiled, this alone cuts 20–40% off wall-clock time.

If your component tests don't lean on obscure browser APIs, try happy-dom for the DOM project. It's meaningfully faster to instantiate than jsdom. It's also less spec-complete, so run the full suite once and diff the failures before committing to it.

While you're in there, audit the setup files themselves. Anything that can run once per worker instead of once per file (starting an MSW server, building a fixture cache) should use globalSetup or module-level state, not a beforeEach in a setup file.

Isolation is the biggest lever and the sharpest knife

By default, Vitest gives every test file a fresh isolated context inside a worker. That's the safe choice, and it's expensive: module graphs get re-evaluated, mocks get rebuilt, and worker context churn eats CPU that could be running tests.

For pure-node unit suites, turning isolation off is often the single biggest speedup available:

{
  test: {
    name: 'unit',
    environment: 'node',
    pool: 'threads',
    poolOptions: {
      threads: { isolate: false },
    },
  },
}

Two notes. First, the pool: Vitest defaults to forks (child processes) because some native modules and libraries that mess with process state misbehave in worker threads. Threads are cheaper to spin up and share memory more efficiently, so if your suite runs clean under pool: 'threads', take the free speed. If it segfaults, you've found out why forks is the default. Second, and more important: isolate: false means module-level state survives between test files in the same worker.

That's where the knife cuts. A test that mutates a module-level singleton, forgets to restore a mock, or leaves a listener attached now poisons whichever file happens to run after it in that worker. You get tests that pass in one shard and fail in another, pass locally and fail in CI, pass on rerun. In other words, you've manufactured order-dependent flaky tests, which are among the most expensive kinds of test failures to debug because the failing test is rarely the guilty one.

My rule: turn isolation off per project, starting with the node-only unit project, and watch for new intermittent failures over the next couple of weeks. This is exactly the situation where flaky-test detection earns its keep. BuildPulse will flag which specific tests started failing intermittently after the config change, which turns "something is leaking state somewhere" into a short, ranked list of suspects instead of a bisection nightmare.

Don't flip isolate: false globally on a large legacy suite the week before a release. Ask me how I know.

Your CI runner has 2 cores. Your laptop has 12

Vitest parallelizes across files, and its default worker count scales with available CPUs. A standard GitHub-hosted runner gives you 2 vCPUs and 7GB of RAM. Your MacBook gives Vitest 10 or 12 performance cores and memory bandwidth the runner can only dream about. That gap, not anything in your config, is usually the biggest chunk of the local-versus-CI difference.

For a CPU-bound suite, scaling the runner is close to linear: 2 vCPUs to 8 vCPUs takes a 9-minute test job to roughly 2.5 minutes. On GitHub's larger hosted runners you pay 4x the per-minute rate for that, so you're trading a small cost increase for a large wall-clock win, which is almost always the right trade for a gate that blocks every merge. If the per-minute economics bother you (they should, at scale), BuildPulse runners run the same workloads on faster hardware at about half GitHub's per-minute price, which turns "faster but pricier" into "faster and cheaper."

One config note for CI: don't fight the scheduler. Leave maxWorkers alone unless you're colocating other work in the same job. Setting it below the core count because someone once read that context switching is bad will just leave cores idle.

Shard when one machine isn't enough

Past a certain suite size, vertical scaling runs out and you split the run across jobs. Vitest supports this natively with --shard, and the blob reporter lets you merge results afterward so you still get one coherent report:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm vitest run --shard=${{ matrix.shard }}/4 --reporter=blob
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: blob-report-${{ matrix.shard }}
          path: .vitest-reports/*

  report:
    if: always()
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - uses: actions/download-artifact@v4
        with:
          path: .vitest-reports
          merge-multiple: true
      - run: pnpm vitest run --merge-reports

Sharding has a fixed tax: every shard pays for checkout, dependency install, and any build step. Four shards means paying that tax four times. So the order of operations matters. Fix per-file overhead first (environments, setup files, isolation), then scale the runner, then shard. Sharding a suite that wastes 60% of its time on jsdom boot just parallelizes the waste. And keep your install step fast with lockfile-keyed caching, or the tax swallows the win.

Also set fail-fast: false. With it on, one flaky test in shard 3 cancels shards 1, 2, and 4, and now your team is rerunning the whole matrix to get signal. If reruns are already a reflex on your team, that's a CI trust problem wearing a performance costume, and it deserves its own fix.

Coverage and the barrel file tax

Two smaller items that show up in nearly every slow Vitest profile:

Coverage provider. Use provider: 'v8', not istanbul. V8 coverage piggybacks on the engine's built-in instrumentation instead of rewriting your source, and the difference on large suites is minutes, not seconds. If you're sharding, collect coverage per shard and merge in the report job rather than running a separate full-suite coverage pass.

Collect time and barrels. If the collect number in your duration breakdown is large, you almost certainly have barrel files: index.ts files that re-export an entire directory. Import one helper from a barrel and Vite transforms and evaluates everything the barrel touches, per test file. The fix is boring and effective: import from concrete module paths in test files, and break up the worst barrels in src. I've seen collect time drop by half from untangling two directories.

The order of operations

If your Vitest CI job is slow, do these in order and re-measure after each:

  • Read the duration breakdown on an actual CI runner. Let proportions pick your target.
  • Split node and DOM tests into projects; only DOM tests get jsdom (or happy-dom) and DOM setup files.
  • Move once-per-run work out of setupFiles into globalSetup.
  • Turn off isolation for the node-only project, then watch for new order-dependent flakes before going further.
  • Put the job on a bigger runner. CPU-bound suites scale almost linearly with cores.
  • Shard with --shard and blob reports once vertical scaling stops paying.
  • Switch coverage to the v8 provider and kill your worst barrel files.

The goal isn't a vanity number on a dashboard. A test gate that returns in three minutes gets run on every push and trusted when it fails. One that takes fifteen gets batched, skipped, and rerun until it goes green, and at that point it isn't a gate anymore. Fast and trustworthy are the same project. Work them together.

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