A note on the Vitest version: This migration was carried out in August 2026, using Vitest 4. All the figures, benchmarks, configuration details, and lessons described in this article refer to Vitest 4, not Vitest 5.
Migrating a test runner is rarely a magic "swap and go" operation.
Our experience moving our entire frontend test suite from Jest to Vitest was a real engineering undertaking, filled with bottlenecks, meticulous pool tuning, unmasked bugs, one rollback, and one optimization we shipped and then had to take back.
Over the course of about three weeks, our frontend team migrated eight repositories. We didn't settle for file counts; we ran every suite end-to-end to get the real numbers.
Here is the exact scale of what we achieved:
This is the story of what actually happened, drawn directly from our git history, PR bodies, and session logs, to help you navigate your own migration.
To save you some of the pain we went through, we also published a generalized version of the AI automation skill we ended up wishing we'd had from day one: github.com/Subito-it/articles-code/tree/main/jest-to-vitest-migration.
We didn't decide to migrate just because "Jest is slow" in the abstract.
We migrated because maintaining it had become an active, recurring tax on our engineering time.
We also wanted to be on the side of where the ecosystem was heading, since Vitest was fast becoming the default choice for new Vite/Next.js projects.
1. The CommonJS vs. ESM nightmare
By default, next/jest skips transforming anything inside node_modules, so when a dependency ships an ESM-only update, Jest's CommonJS require() fails.
We found ourselves hand-patching transformIgnorePatterns regexes to allow-list specific packages. Here's the exact code comment from our config, verbatim:
// ESM-only node_modules that Jest must transform. next/jest overwrites any
// user-supplied transformIgnorePatterns to skip all of node_modules, so we
// allow-list these on the resolved config instead.
const esmPackagesToTransform = ['cookie'];
Separately, and about six weeks before we'd even decided to migrate: a major version bump of uuid shipped as pure ESM and broke Jest outright.
The real commit message: "uuid v14 ships as pure ESM which breaks Jest (CommonJS). Replace the single usage of uuid() ... with the native crypto.randomUUID() ... removing the dependency entirely."
Rather than fight the transform config again, the team just deleted the dependency. When your test runner is dictating your dependency choices before you've even planned a migration, that's a real, quiet cost, not a hypothetical one.
2. Ecosystem bloat
Every repo was carrying a fragile pile of Jest-specific tooling: jest, jest-mock, jest-environment-jsdom, ts-jest, @types/jest, babel-jest, and repo-specific extras.
Moving to Vitest's Vite-based transform pipeline meant shrinking that footprint everywhere.
Vitest was not automatically faster than Jest, and we're not going to pretend otherwise.
In at least two of our repositories, out-of-the-box Vitest was measurably slower until we did real tuning work.
In one repo, keeping the same CI worker cap that worked fine with Jest, turned a ~35-second test run into a ~3-minute crawl with Vitest. The actual test execution time was near-identical.
The difference was almost entirely pool/worker overhead.
We fixed it by explicitly raising maxWorkers from 2 to 8 on a many-core CI runner.
You cannot guess your Vitest configuration; you have to measure it. Another repo, unrelated to the one above and with its own separate Jest baseline of 44.4 seconds on CI, ran a rigorous local benchmark sweep on Vitest's forks pool, on a 10-core machine, for its 146 files / 683 tests:
Throwing more workers at the problem didn't help past 6.
The "faster" Vitest wasn't actually faster than the previous Jest CI baseline until it was tuned to match the hardware.
Vitest offers different worker-isolation pools, and choosing the right one is a correctness decision, not just a speed one.
We ultimately moved every repo to the forks pool.
forks uses real child processes, so environment variables apply correctly before Node initializes.
That matters because the alternative, threads, mutates process.env too late for things like TZ to be picked up, resulting in timezone-dependent broken snapshots on CI in one of our repos.
The cost of forks is that it re-creates jsdom per file, making it the slower option out of the box (one repo measured this at roughly 5x slower than Jest before worker tuning).
We chose forks anyway, because it was the most correct option: it avoided race conditions that a shared-context pool (vmThreads) was silently masking, and it avoided an automocking gap that broke error-tracking calls under a different pool (next section).
Jest's __mocks__/ directory is magical: drop a file in there matching a package name, and Jest silently applies it everywhere in the import graph, including packages a test file never imports directly. Vitest requires an explicit vi.mock() call in the test file itself.
When that silent Jest magic went away, our tests broke in interesting ways.
A shared internal telemetry library had a background sync timer that Jest's implicit mocking had apparently been suppressing for years.
Under Vitest, the real module ran, the timer fired during tests, and we got unhandled promise rejections, across three separate repositories, each discovered independently rather than solved once and shared.
Separately, two repos found that with one particular pool configuration, our error-tracking SDK's calls resolved to undefined instead of running. A test scenario that should have triggered an error, did nothing for years, silently.
Both were fixed the same way, with an explicit mock factory instead of relying on automocking.
The riskiest case was quieter than either of those: several component tests had been relying on Jest silently intercepting a shared internal package's real implementation.
Under Vitest, without an explicit mock, the real implementation ran instead, and the tests didn't fail loudly.
They just started exercising different code paths than the ones they were written to verify, a false-positive-test risk that had been sitting there the whole time Jest was covering for it.
If you're picturing a tidy "one PR, done" migration for every repo: forget it.
One of our repos merged a migration PR, hit some non-deterministic flaky tests almost immediately, and made the right, low-drama call: roll it back with a revert commit, investigate properly, and land a successful retry about five days later.
That's not a production incident and it's not a failure. It's what a change of this size normally looks like when you don't rush the "done" call.
The pattern across this whole effort is to measure before declaring victory, and not be afraid to undo something that isn't working.
We don't want this to read as a pitch, so here's the other side. Jest, for years, just worked.
Nobody on the team had to think about pool models or worker-count sweeps to get a stable, reasonably fast suite.
Its __mocks__/ auto-mocking, for all the false-positive risk it turned out to be hiding, also meant nobody had to write an explicit vi.mock() for every transitively-mocked dependency.
And in at least two of our repos, out-of-the-box Jest was simply faster than out-of-the-box Vitest, full stop, until we did tuning work Jest had never demanded of us.
If your suite is small, stable, and not fighting ESM dependencies, "migrate to Vitest" is not an automatic win.
It's a trade of one set of maintenance costs for another, and ours only came out ahead because our specific pain points (ESM friction, jsdom-adjacent tooling, hidden automocking bugs) happened to be the ones Vitest's model addresses well.
Without AI assistance, we wouldn't have completed this migration on this timeline.
Manually converting thousands of test files across eight independently-owned codebases in about three weeks was not realistic for a team our size.
We delegated the mechanical conversion (thousands of repetitive, well-defined transformations) to Claude Code.
What we can say about the AI cost, precisely:
Two honest caveats on that cost figure: it's derived from real token counts using list pricing plus an assumed cache-discount multiplier, not an actual bill.
We don't have access to real invoiced numbers for these sessions. And our session-relevance filter matched any mention of "migration," which is broader than "Jest-to-Vitest migration specifically," so $751 is better read as an upper bound than a precise cost of this one effort.
We've also enforced a simple but powerful concept. Nowadays It's more important then ever having a deep knowledge on what we delegate to AI. Before the action, we spent some time to analyze Jest-Vitest differences, read Vitest docs and understand what plan best fitted our codebase. Without this step, we'd probably spent way more tokens.
What Claude Code bought us wasn't just speed on the mechanical work. It let our engineers spend their time on the actual judgment calls: which pool to pick, whether a "fix" really helped, when a migration attempt was actually ready to call done.
The hardest part of the migration itself wasn't the API syntax changes. It was the complete lack of guidance on worker benchmarking and pool selection, which is exactly where we lost the most time.
The skill itself wasn't written once and left alone. We built it, then kept adapting it as each repo hit a new pool bug or benchmarking surprise, so by the time the last repo migrated it already carried the lessons from every one before it. That ongoing adaptation was its own difficult part, separate from the migration work itself.
We're now making that skill available to everyone: the per-file migration rules it grew to include, with anything company-specific stripped out; grab it and run it on your own codebase:
github.com/Subito-it/articles-code/tree/main/jest-to-vitest-migration