Testing

Vitest vs Jest Speed: The Suite Time Difference, Measured

Every Vitest vs Jest speed claim you have read was written by someone who did not re-run it. Vitest’s documentation promises instant feedback, Jest’s promises a batteries-included default, and the blog posts in between repeat both without a stopwatch. This post takes one real open-source repository, 110 test files and 1,367 tests, and runs the identical suite under both runners with every command and its raw output pasted below. The result is not the one the folklore predicts: at default settings Jest finished first, and the flag that makes Vitest win is also the flag that makes it flaky.

This is for developers who maintain a Node or TypeScript suite large enough that the runner’s wall time shows up in a code review cycle or a CI bill. If you are choosing a runner for a greenfield project, or weighing a migration that a teammate has proposed on speed grounds, the numbers below are the ones to argue with.

How These Vitest vs Jest Speed Numbers Were Produced

A runner benchmark is worth exactly what its method survives, so the method here is narrow on purpose. One repository, one dependency list, two runners pointed at the same 110 files. Neither runner’s configuration was tuned to flatter it.

Here is the exact environment every number below came from:

Measured 2026-09-10
Windows 11 Pro 25H2 (build 26200), AMD Ryzen 5 8600G (6C/12T), 15.2 GB RAM, NVMe SSD
Node 24.20.0, npm 11.19.0
Jest 30.5.1 (its CLI self-reports 30.5.0), ts-jest 29.4.12
Vitest 5.0.0, @vitest/coverage-v8 5.0.0, Vite 8.2.2
TypeScript 5.9.3, @types/node 22.19.1
Repo under test: commander.js v14.0.2, commit 0692be5
Windows Defender real-time protection: on

Every figure is the median of five runs, and all five raw times appear beside it. Test runner timings move between runs, so a single number would be an anecdote rather than a result. Each configuration also gets one discarded warm-up run first, which means no runner pays for a cold file cache that the other already warmed.

This harness runs Node from a portable archive rather than a system install, so no version manager, shim or shell wrapper sits in the measured path. The harness calls each runner’s bin directly and wraps a stopwatch around the process:

function Invoke-Timed([string]$dir, [string]$bin, [string[]]$argv) {
  Push-Location $dir
  [System.GC]::Collect()
  $sw = [Diagnostics.Stopwatch]::StartNew()
  $output = & $NODE $bin @argv 2>&1 | Out-String
  $sw.Stop()
  $code = $LASTEXITCODE
  Pop-Location
  return [pscustomobject]@{ Ms = [math]::Round($sw.Elapsed.TotalMilliseconds); Code = $code }
}

Wall-clock time is what the table reports, because that is what a developer actually waits for. Both runners also print their own internal duration, and both under-report, because neither counts the Node process startup that precedes it. Where the two disagree interestingly, both appear.

The One Repo Both Runners Had to Agree On

Picking the repository is the part of this benchmark that decides whether it means anything. A suite written against Jest’s mocking API cannot run under Vitest without edits, and edits are where a benchmark quietly turns into a comparison of two different test suites.

commander.js v14.0.2 earns the slot because it is unusually portable. A survey of its 110 test files turns up exactly two Jest-specific APIs, jest.fn used 102 times and jest.spyOn used 18 times, with no module mocks, no snapshots and no expect.extend. Both have identical equivalents on Vitest’s vi object.

So the only adaptation needed is a three-line setup file:

// vitest.setup.mts
import { vi } from 'vitest';

// commander's suite calls jest.fn and jest.spyOn; vi implements both identically.
(globalThis as any).jest = vi;

Paired with a config that turns on Jest-style globals:

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

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    setupFiles: ['./vitest.setup.mts'],
    coverage: { provider: 'v8', include: ['index.js', 'lib/**'] },
  },
});

Not one test file changed. Both runners discover the same 110 files, skip the same file, and run the same 1,367 tests, which is the check that makes the comparison legitimate rather than rhetorical. If you already write CLI tooling, the repository under test is the same package covered in building CLI tools with Node.js and Commander.

Each project’s devDependency list holds the runner and nothing else, so install figures later in this post measure the runner rather than the repository’s linting stack.

Vitest vs Jest Speed on the Full Suite, Nothing Tuned

This is the number that matters most, because it is the one almost every team actually gets: the suite, no flags, whatever the runner does by default.

ConfigurationFive runs (ms)Median
jest --coverage=false2358, 2348, 2342, 2341, 23832348
vitest run (default)2894, 2949, 2887, 2933, 28552894
vitest run --no-isolate1582, 1574, 1595, 1750, 17591595

Jest beat Vitest’s default configuration by 546 ms, which makes Vitest 23% slower on this suite. That single comparison is what most Vitest vs Jest speed discussions are implicitly about, and it goes the unexpected way. Meanwhile the third row is the interesting one, and the rest of this post is largely about it.

Here is Jest’s raw output:

Test Suites: 1 skipped, 109 passed, 109 of 110 total
Tests:       16 skipped, 1351 passed, 1367 total
Snapshots:   0 total
Time:        2.454 s, estimated 3 s
Ran all test suites.

And Vitest’s, at defaults:

 Test Files  1 failed | 108 passed | 1 skipped (110)
      Tests  10 failed | 1341 passed | 16 skipped (1367)
   Start at  12:01:30
   Duration  3.20s (tests 51%, import 19%, transform 13%, setup 9%, worker 8%)

    Isolate  110 workers spawned · ~183ms startup each (spawn + environment, per file)
             at least ~1.65s faster with isolate: false — reuses workers across files instead of one per file

Note the last two lines. Vitest 5 diagnoses its own largest cost and tells you how to remove it, which is both a genuinely useful feature and an admission about the default. Those ten failures are real and get their own section further down; they do not change the work measured, because the child processes under test are still spawned either way.

Is Vitest Actually Faster Than Jest?

Not at its default settings on this suite. Jest finished the same 1,367 tests in 2,348 ms against Vitest’s 2,894 ms, making Vitest 23% slower. However, Vitest run with --no-isolate finished in 1,595 ms, which is 32% faster than Jest. The honest answer is that the default loses and the tuned configuration wins decisively.

That nuance matters because TeachMeIDEA’s own earlier guide, unit testing with Jest and Vitest in modern JS projects, lists Vitest’s startup speed as “very fast” against Jest’s “slower”, with no numbers attached. On the narrow question of starting up one file, that post is right, and the measurement below confirms it. On the question of finishing a whole suite at defaults, it is wrong, and this is the correction.

The isolate Flag That Reverses the Result

Vitest isolates every test file by default, spawning a fresh worker per file so that module state, globals and mocks cannot leak between files. Jest does something cheaper: it reuses a pool of workers and resets the module registry between files inside them.

On a suite of 110 small files, that design choice is the entire story. Vitest’s own diagnostic puts the cost at roughly 183 ms of spawn and environment setup per file, and 110 of those is far more than the tests themselves cost.

Turning isolation off collapses the difference:

# One worker per test file, the default
npx vitest run

# Reuse workers across files
npx vitest run --no-isolate
 Test Files  1 failed | 108 passed | 1 skipped (110)
      Tests  10 failed | 1341 passed | 16 skipped (1367)
   Duration  1.55s (tests 49%, transform 22%, import 21%, setup 6%, worker 1%)

Look at the breakdown shift. At defaults, worker accounts for 8% of the run; with isolation off it drops to 1%. Nothing about the tests changed, so the 1,299 ms saved was pure process overhead. For a suite of many small files, the isolate option is the single highest-leverage setting Vitest has.

What isolate false Costs You in Flaky Tests

A 32% win for one flag sounds like free money, so the next question is what it buys with. Running each configuration five times and recording the failure count answers it:

ConfigurationFailing tests across five runsDeterministic
vitest run (isolate on)10, 10, 10, 10, 10Yes
vitest run --no-isolate10, 10, 14, 10, 10No
vitest run --maxWorkers=1 --no-isolate10, 14, 10, 10, 14No

The baseline ten failures are the same ones every time, in the same file. Yet with isolation off, a fourth of the runs grew four extra failures in tests/command.configureOutput.test.js, a file whose tests set process.env.FORCE_COLOR and process.env.NO_COLOR and restore them afterwards. Once files share a worker, that restore races against whatever else is reading those variables in the same process.

This is precisely the bug class isolation exists to prevent, and it is worth being blunt about the trade: isolate: false makes this suite 32% faster and intermittently wrong. A suite that never touches global state pays nothing for the flag. A suite like this one pays in failures that reproduce on one run in four, which is the most expensive kind of test failure a team can own.

Consequently the recommendation is narrower than “turn it off”. Turn it off, then run the suite ten times in a row and see whether the failure count ever moves.

Watch Mode’s Real Number: One File, 23 Tests

Full suite time is a CI metric. The number a developer feels all day is the cost of running one file after one edit, and here the ranking flips.

ConfigurationFive runs (ms)Median
vitest run tests/command.action.test.js547, 548, 543, 543, 557547
jest --coverage=false tests/command.action.test.js664, 686, 666, 631, 641664
 Test Files  1 passed (1)
      Tests  23 passed (23)
   Duration  242ms (tests 70%, transform 12%, import 10%, setup 4%, worker 3%)
Test Suites: 1 passed, 1 total
Tests:       23 passed, 23 total
Snapshots:   0 total
Time:        0.359 s, estimated 1 s
Ran all test suites.

Vitest is 18% faster to run a single file, and the gap in the runners’ self-reported time is wider still, 242 ms against 359 ms. Both numbers exclude roughly 300 ms of Node startup that the wall clock includes, which is why the wall-clock difference is proportionally smaller than the internal one.

Crucially, this is where Vitest’s architecture pays off rather than costs. One file means one worker spawn, so per-file isolation is nearly free, and Vite’s transform pipeline beats Jest’s. If your day is dominated by saving a file and watching one suite re-run, the startup floor is the number that should decide your choice, not the full suite time above it.

Coverage Changes the Ranking Again

Most teams run coverage in CI, and coverage is a different benchmark, because the two runners instrument code in different ways.

ConfigurationFive runs (ms)Median
jest --coverage --coverageProvider=babel3604, 3405, 3500, 3506, 35353506
vitest run --coverage (v8)3517, 3589, 3611, 3564, 35913589
jest --coverage --coverageProvider=v84778, 5034, 4491, 3886, 38264491

Jest’s default Babel provider edged Vitest’s v8 provider by 83 ms, which is close enough to call a tie at this suite size. Jest’s own v8 provider, by contrast, was the slowest thing measured in this group and also the most variable, spanning 3,826 ms to 5,034 ms across five runs. If you have switched Jest to the v8 coverage provider for speed, this suite says you made it slower.

Coverage also narrows the gap in relative terms. Without coverage Vitest’s default was 23% behind; with coverage it is 2% behind, because instrumentation cost is shared while worker spawn cost is not. In other words, the Vitest vs Jest speed difference shrinks as the work per file grows.

The Branch Coverage Gap That Breaks CI Gates

Running coverage on both runners over the identical 109-file subset produced a result worth more than any timing in this post. The denominators match exactly, so the percentages are directly comparable:

MetricJest 30.5.1 (babel)Vitest 5.0.0 (v8)
Statements97.89% (1348/1377)97.67% (1345/1377)
Branches95.06% (828/871)87.48% (762/871)
Functions98.98% (293/296)97.29% (288/296)
Lines98.16% (1282/1306)98.08% (1281/1306)

Statements, functions and lines agree within half a percentage point. Branches do not: Vitest’s v8 provider counted 66 fewer covered branches, a gap of 7.58 points, on byte-identical source run by byte-identical tests.

The practical consequence is immediate. A repository with branches: 90 in its coverage thresholds passes under Jest and fails under Vitest, on the day of the migration, with no test changed and no code uncovered. Teams hit this, conclude the migration broke their tests, and spend an afternoon looking in the wrong place.

Jest’s v8 provider is stranger still. It reports against a completely different denominator, 4,218 statements rather than 1,377, because it counts raw V8 ranges instead of remapping to source statements:

=============================== Coverage summary ===============================
Statements   : 99.35% ( 4191/4218 )
Branches     : 98.16% ( 1014/1033 )
Functions    : 100% ( 207/207 )
Lines        : 99.35% ( 4191/4218 )
================================================================================

Three providers, three different pictures of the same code. Therefore any coverage threshold you enforce is a statement about your provider, not about your tests, and it has to be re-baselined whenever the provider changes.

Cold Cache: What a Fresh CI Runner Pays

Warm numbers flatter whichever tool caches more aggressively. A CI runner with no cache restored is the opposite case, so this group deletes both caches before every single run.

ConfigurationFive runs (ms)Median
jest --coverage=false, cache cleared2515, 2529, 2514, 2523, 25272523
vitest run, cache cleared2894, 3284, 3590, 3548, 37973548

Jest barely noticed, losing 175 ms against its warm median. Vitest lost 654 ms and, more tellingly, its five runs climb steadily from 2,894 ms to 3,797 ms rather than clustering. That upward drift is Vite’s dependency cache being partially rebuilt each time, and it means Vitest’s cold-start cost on this suite is less predictable than Jest’s, not merely larger.

For a pipeline without a restored cache, the default-settings penalty widens from 23% to 41%. Any Vitest vs Jest speed figure quoted without naming its cache state is therefore describing one of two quite different runs. If you are tuning a Node pipeline, that interacts directly with the cache configuration covered in CI/CD for Node.js projects using GitHub Actions.

Single Worker, Where Vitest Falls Apart

Constrained CI tiers pin test runners to one worker, so this configuration is common rather than academic. It is also where the isolation cost stops being a tax and becomes the whole bill.

ConfigurationFive runs (ms)Median
Jest, maxWorkers: 1 from config4630, 4658, 4631, 4609, 46134630
vitest run --maxWorkers=113581, 13521, 13509, 13519, 1366013521
vitest run --maxWorkers=1 --no-isolate3800, 3773, 3796, 3791, 42703796

Vitest at one worker took 2.9 times as long as Jest at one worker. With 110 worker spawns no longer overlapping, the per-file startup cost serializes into a straight 8.7 seconds of pure overhead, which Vitest’s own output confirms:

   Duration  15.25s (tests 50%, import 24%, setup 10%, worker 9%, transform 7%)

    Isolate  110 workers spawned · ~80ms startup each (spawn + environment, per file)
             at least ~8.70s faster with isolate: false — reuses workers across files instead of one per file

Add --no-isolate and the same configuration finishes in 3,796 ms, beating Jest by 18%. One flag is worth 3.6x here, which is more than the two runners differ from each other anywhere else in this post. If your CI runs a single worker and you have not set it, you are paying for isolation you could have measured.

Jest’s single-worker number came from config rather than the command line, for a reason that became its own finding. jest --runInBand runs tests in the main process, so Jest’s own CLI flags stay in process.argv, and commander has a test that calls program.parse() against exactly that. The suite aborts:

error: unknown option '--coverage=false'
  ● process.exit called with "1"
    at Command._exit (lib/command.js:538:13)
    at Object.<anonymous> (tests/command.parse.test.js:414:13)

Any flag triggers it, including --runInBand itself, so single-process Jest has to be configured through jest.config.js and invoked with no arguments at all. Vitest never hits this, because its tests always run inside a worker whose process.argv is clean.

Install Cost: Fewer Packages, More Megabytes

Suite time is not the only cost a runner imposes. Installing each runner alone, with nothing else in devDependencies, separates the runner’s footprint from the repository’s:

MeasureJest 30.5.1Vitest 5.0.0
Packages added27837
Files in node_modules5,627624
Size on disk33.5 MB37.1 MB
npm install, cold npm cache11,657 ms9,756 ms
npm ci, warm cache2,673 ms727 ms
$ npm install --no-fund --no-audit   # jest only
added 278 packages in 11s

$ npm install --no-fund --no-audit   # vitest only
added 37 packages in 11s

Vitest ships 7.5 times fewer packages and 9 times fewer files, and yet it occupies 3.6 MB more disk, because Vite, Rollup and esbuild arrive as a few large prebuilt binaries instead of hundreds of small JavaScript modules. Fewer packages, more megabytes, and a much smaller supply-chain surface to audit.

The file count is what shows up in CI time. npm ci with a warm cache took 727 ms for Vitest against 2,673 ms for Jest, a 3.7x difference that comes almost entirely from extracting 624 files instead of 5,627. On a pipeline that installs on every run, that saving is larger and far more reliable than anything in the suite-time tables above, which makes it the one Vitest vs Jest speed result here that needs no flag to collect.

The Ten Tests Vitest Could Not Pass

Ten tests in tests/command.executableSubcommand.search.test.js fail under Vitest and pass under Jest, on every run. The cause is specific and worth knowing before any migration, because it has nothing to do with the assertion library.

commander forwards process.execArgv to the subcommands it spawns, and the test asserts on the resulting argument list. Vitest’s workers do not have a clean execArgv:

AssertionError: expected [ …(8) ] to deeply equal [ Array(1) ]

- Expected
+ Received

  [
+   "--experimental-import-meta-resolve",
+   "--require",
+   "C:/tmvj/run/vitest/node_modules/vitest/suppress-warnings.cjs",
+   "--conditions",
+   "node",
+   "--conditions",
+   "development",
    "C:\\tmvj\\run\\vitest\\tests\\fixtures\\absolute\\exec.js",
  ]

Vitest adds seven extra arguments of its own to the worker’s execArgv, and commander dutifully passes every one of them to the child process. Switching to --pool=forks does not help, because the flags are added either way.

The general lesson generalizes past this repository. Any test that asserts on process.execArgvprocess.argv or the exact arguments of a spawned child process is asserting on its runner’s implementation details, and those details differ. Jest’s version of the same problem is the --runInBand abort above. Both runners leak themselves into the process; they simply leak different things.

What These Numbers Cost a Real Team

Consider a mid-sized Node service with a suite in the 1,000 to 2,000 test range, maintained by a small team, running on a CI tier that pins the test job to one or two workers. On numbers like the ones above, the choice of runner is worth a few seconds per local run and a few seconds per pipeline, which is real but rarely decisive on its own.

What dominates instead is install time and migration risk. The 1.9 seconds saved per npm ci recurs on every pipeline run, every branch, every day, and it does not depend on any flag being set correctly. A migration, by contrast, front-loads the cost: re-baselining coverage thresholds after a 7.58-point branch-coverage shift, and finding the handful of tests that assert on runner internals, is the kind of work that expands to fill an unplanned week.

The honest trade-off for such a team is therefore not Vitest vs Jest speed at all. Migrating a working Jest suite to chase 23% of a two-second run is a bad trade, because the run was never the bottleneck. Starting a new service on Vitest, where the install saving compounds and there is no coverage baseline to break, is a good one.

When to Use Vitest

Choose Vitest for a project already built on Vite

  • The config, transform pipeline and plugins are already in place, so the runner costs nothing extra to adopt
  • TypeScript and ESM work without a separate transform step or a ts-jest dependency

Start new projects on it rather than migrating old ones

  • No coverage baseline exists yet, so the branch-counting difference never becomes a regression
  • The 3.7x faster npm ci applies from the first pipeline run

Prioritise it when single-file feedback is the daily loop

  • It ran one 23-test file in 547 ms against Jest’s 664 ms
  • Per-file isolation is nearly free when only one file runs

Set isolate to false only after proving the suite tolerates it

  • Worth 32% on the full suite and 3.6x at a single worker
  • Run the suite ten consecutive times and confirm the failure count never moves

When NOT to Use Vitest

Keep Jest when the suite asserts on process internals

  • Ten tests here fail purely because Vitest adds seven flags to the worker’s execArgv
  • Tests that inspect process.argv or exact spawn arguments are asserting on the runner, not the code

Stay put when coverage thresholds gate the pipeline

  • The v8 provider counted 66 fewer covered branches than Jest’s babel provider on identical source
  • branches: 90 gate passes under Jest and fails under Vitest on migration day

Avoid it where a cold cache is the normal case

  • Vitest lost 654 ms without a warm cache against Jest’s 175 ms
  • Its cold-run times drifted upward across five runs rather than clustering

Rule out the default configuration at a single worker

  • 13,521 ms against Jest’s 4,630 ms, a 2.9x loss
  • The configuration is only competitive once isolation is disabled

Common Mistakes with Vitest vs Jest Speed Benchmarks

Comparing a warm cache against a cold one

  • The same Vitest configuration measured 2,894 ms warm and 3,548 ms cold
  • Discard a warm-up run per configuration, or clear both caches deliberately

Treating the default as the ceiling

  • Vitest’s default lost by 23% and its tuned configuration won by 32%
  • One flag moved the result further than the runners differ from each other

Reporting a single run as a result

  • Jest’s v8 coverage provider spanned 3,826 ms to 5,034 ms across five runs
  • Any single sample from that range supports whichever conclusion you wanted

Assuming identical coverage percentages mean identical instrumentation

  • Statements agreed within 0.22 points while branches differed by 7.58
  • Check all four metrics before trusting a threshold across runners

Letting an unrelated environment variable change the outcome

  • NO_COLOR=1 in the shell failed four tests under both runners, and FORCE_COLOR=0 failed two
  • Benchmark in a clean environment, because the suite may read it

What This Benchmark Does Not Measure

A benchmark with unstated limits is a trap, so here are the ones that bound every Vitest vs Jest speed number above. This is one repository, a CLI parser with 110 small, fast, Node-only test files and no DOM, no React and no heavy fixtures. Suites with fewer, slower files will see the per-file spawn cost matter far less, and the ranking at defaults could well invert.

Watch-mode re-run latency was not measured directly. The single-file numbers are the closest available proxy, and they favour Vitest, but neither runner’s incremental watch behaviour was timed.

Also unmeasured: Vitest’s browser mode, jsdom and happy-dom environments, monorepo and workspace configurations of the kind discussed in monorepos with Nx or Turborepo, sharding across CI machines, macOS and Linux, and any suite built natively on ESM rather than CommonJS. Jest 31 was not released at the time of measurement. Finally, the 1,367 tests here were written for Jest, so Vitest ran them through a compatibility shim rather than on its own idioms.

For a sibling measurement produced on the same machine with the same harness, see Bun vs Node vs Deno install, boot and test times measured.

Conclusion

On Vitest vs Jest speed, the measured answer is that defaults decide it and almost nobody checks them. Jest finished this 1,367-test suite 23% faster than Vitest out of the box, Vitest with --no-isolate finished 32% faster than Jest, and at a single worker the spread between Vitest’s two configurations was 3.6x. The runner you pick matters less than the isolation setting you leave alone.

The practical recommendation is split. Keep a working Jest suite on Jest, because 500 ms of run time does not pay for re-baselining a coverage gate that shifts 7.58 points on branches. Start new Node and TypeScript projects on Vitest, where the 3.7x faster npm ci compounds on every pipeline run and there is no baseline to break. Either way, run your own suite ten times before trusting any number in this post, including the ones above.

The concrete next step takes about a minute: run npx vitest run --no-isolate against your suite, compare it to your current time, then run it nine more times and watch whether the failure count stays still. If you are deciding what to test rather than what to test with, generating unit tests with large language models is the companion to this one.

Leave a Comment

Your email address will not be published. Required fields are marked *