Runners
6 min read

Vitest performance: why your test suite is slow and how to fix it

Vitest is fast by default, but default isn't always fast enough. Here's how to actually diagnose and fix a slow Vitest suite.

BuildPulse Team

May 30, 2026

Vitest Performance: Speed Up Your Test Suite | BuildPulse Blog

Your Vitest suite shouldn't take four minutes

Vitest ships with Vite's transform pipeline and runs tests in parallel workers by default. Out of the box, it's meaningfully faster than Jest on most codebases. And yet — I've watched teams migrate from Jest to Vitest, celebrate a 30% speedup on day one, and then quietly watch that lead evaporate over the next six months as the suite grows and nobody touches the config.

Four minutes to run unit tests is not a Vitest problem. It's a configuration and architecture problem that Vitest is happy to let you have if you don't think about it.

This post is about actually diagnosing what's slow and applying fixes that have a measurable impact — not the usual checklist of "make sure you're on the latest version" advice.

Diagnose before you tune

Before touching a single config option, run Vitest with --reporter=verbose and pipe it through something you can actually read. Better: use the built-in JSON reporter and analyze it.

vitest run --reporter=json --outputFile=test-results.json

Then pull out the slowest files:

node -e "
  const r = require('./test-results.json');
  r.testResults
    .sort((a, b) => b.duration - a.duration)
    .slice(0, 10)
    .forEach(t => console.log(t.duration + 'ms\t' + t.testFilePath));
"

Nine times out of ten, a handful of test files account for the majority of wall-clock time. Fixing those ten files will do more than globally tweaking maxWorkers. Know your bottleneck before you start turning knobs.

The worker configuration actually matters

Vitest defaults to spawning one worker per logical CPU, minus one. On a developer laptop with 8 cores that's fine. On a 2-core CI runner — which is what you get on the free tier of most providers — you're paying context-switching overhead for workers that can't actually run in parallel.

Vitest exposes pool and poolOptions in vitest.config.ts:

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

export default defineConfig({
  test: {
    pool: 'threads',
    poolOptions: {
      threads: {
        minThreads: 2,
        maxThreads: 4,
      },
    },
  },
});

For a 2-core runner, setting maxThreads: 2 often beats the default because you stop paying for thread contention. For a 4-core runner, maxThreads: 4 is roughly right. On an 8-core runner (like the GitHub Actions ubuntu-latest that costs 2× the standard rate), you have room to push higher.

One thing people miss: Vitest has two pool modes worth caring about — threads (the default, using node:worker_threads) and forks (using child_process.fork). Fork mode has higher per-test overhead but better process isolation. If you have tests that leak globals or mess with process.env, you've probably already switched to forks for correctness reasons. Just know you're paying a speed cost for it. If you're using forks by default without a reason, switch to threads.

Transform and module resolution overhead

Vitest reuses Vite's transform pipeline, which means every file your tests import goes through module resolution and potentially a transform. In a monorepo with deep import graphs, this adds up.

Two things help here:

1. Exclude what you don't need to transform.

If you're testing TypeScript source but your test environment is Node, you often don't need to transform every dependency in node_modules. Vitest respects server.deps.external for this:

export default defineConfig({
  test: {
    server: {
      deps: {
        external: ['lodash-es', 'date-fns'],
      },
    },
  },
});

Be careful here — some ESM-only packages need to be transformed. But if you have pure-CJS packages in your dependency tree that don't need special handling, marking them external skips a transform pass.

2. Use deps.optimizer for frequently-imported packages.

Vitest's dependency optimizer (backed by Vite's pre-bundler) can cache transformed versions of heavy dependencies. For packages like @mui/material, recharts, or anything that pulls in a large ESM tree:

export default defineConfig({
  test: {
    deps: {
      optimizer: {
        web: {
          include: ['@mui/material', 'recharts'],
        },
      },
    },
  },
});

The first run is slower. Every run after that — in the same environment with a warm cache — is faster. In CI, this pairs well with caching the Vite optimizer cache directory (.vite/deps by default).

Cache the right things in CI

Speaking of CI: the single highest-leverage change for most teams running Vitest on GitHub Actions is caching node_modules and the Vite transform cache together.

# .github/workflows/test.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Cache Vite/Vitest transform cache
        uses: actions/cache@v4
        with:
          path: |
            node_modules/.vite
            .vite
          key: vitest-transform-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            vitest-transform-${{ runner.os }}-

      - run: npm ci
      - run: npx vitest run

The actions/setup-node cache: 'npm' handles node_modules. The second cache action handles Vite's pre-bundled deps. On a real-world app with 80+ dependencies, this can shave 30–60 seconds off a cold CI run that was spending that time re-transforming packages it already transformed yesterday.

Mocking is expensive — mock less, mock smarter

Here's a Vitest performance issue nobody talks about: vi.mock() calls at module scope are hoisted and run before every test file that includes them. If you've got 40 test files all mocking the same three modules, you're paying that setup cost 40 times.

The fix is rarely "mock less" in terms of correctness — you need those mocks. The fix is don't mock more than you need to at module scope.

Instead of:

// Mocking an entire module when you only need one function
vi.mock('../../lib/analytics', () => ({
  track: vi.fn(),
  identify: vi.fn(),
  page: vi.fn(),
  group: vi.fn(),
  // ... 12 more stubs
}));

Use vi.spyOn inside your test or beforeEach, or use the factory pattern to return only what the test actually calls. The test isolation is the same; the setup overhead is lower.

Also worth checking: are you using vi.mock() to mock something that you could just pass as a dependency directly? A lot of mock overhead in Vitest suites is mocking side effects that could be injected instead. This is a testing architecture conversation, not a Vitest one — but the performance payoff is real.

Isolate slow tests instead of slowing everything down

If you've diagnosed a handful of files that are slow because they do real I/O, spin up databases, or render large component trees — don't let them drag down the rest of the suite.

Vitest supports project-style configuration splits, but for a simpler pattern: use separate config files and run them as separate CI jobs.

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - run: npx vitest run --config vitest.unit.config.ts

  integration:
    runs-on: ubuntu-latest
    steps:
      - run: npx vitest run --config vitest.integration.config.ts

The jobs run in parallel. Your unit tests finish in 45 seconds. Your integration tests take two minutes. Neither waits for the other, and your PR feedback loop is gated on the unit tests returning first. The wall-clock time your engineers actually feel is the unit test time.

Runner hardware is an underrated variable

Everything above is about extracting more from the hardware you already have. But sometimes the right answer is better hardware.

GitHub's standard ubuntu-latest runner gives you 2 vCPUs. Vitest's parallelism is CPU-bound for transform and test execution. A 4-core runner doesn't just run tests 2× faster — it also means your maxThreads default is 3 instead of 1, which compounds the benefit.

The tradeoff is cost. GitHub's 4-core runners cost 2× the per-minute rate of a 2-core runner. But if that drops your test time from 4 minutes to 90 seconds, you're actually spending less money and your engineers are spending less time context-switching while they wait.

If you're running high test volume, this math matters a lot. Managed runner services (including BuildPulse Runners) exist largely because the default GitHub runners are a poor performance-per-dollar option for compute-heavy workloads like large test suites.

What to actually do next

Here's the order I'd work through this:

  1. Profile first. Run the JSON reporter and find your 10 slowest files. If a single file is taking 30+ seconds, there's probably a specific fix (bad mock, real I/O, missing setup/teardown boundary) that dwarfs everything else.

  2. Fix CI caching. Add Vite transform cache to your GitHub Actions workflow if it's not there. This is free performance.

  3. Tune maxThreads to your runner. Default behavior on a 2-core runner is suboptimal. Be explicit.

  4. Separate slow integration tests. Run them in a parallel CI job so they don't gate your fast feedback loop.

  5. Consider runner hardware. If you've done all of the above and you're still grinding, the bottleneck is CPU and the answer is more of it.

Vitest is genuinely fast. The teams that feel like it's slow are usually fighting their own configuration, their own mocking habits, or the hardware floor imposed by default CI runners — not Vitest itself.