Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com.
I ran the same TypeScript test suite under Vitest 5.1, Jest 30.5 and Node's built-in node:test, and timed cold runs, warm runs, single files, watch mode and coverage. The suite is 39 test files and 183 tests against a small NestJS-style orders service, an HTTP API and an MCP server. The result surprised me: with default settings, Jest with @swc/jest was the fastest full run, and Vitest was the slowest of the three. Change one isolation setting and the order flips. Below are the numbers, the setup for each runner, and the TypeScript and ESM problems I hit along the way.
What I tested
Everything ran on November 3, 2026 on one Linux VM with 8 vCPUs (Intel Xeon) and 16 GB of RAM, on Node.js 24.22.1:
- Vitest 5.1.0 (Vite 8.3.5),
@vitest/coverage-v85.1.0 - Jest 30.5.2 with
@swc/jest0.2.39 and@swc/core1.16.21, and separately withts-jest29.4.15 - node:test from Node 24.22.1, using Node's built-in type stripping (no loader)
- TypeScript 6.0.3 for type checks.
ts-jest29.4.15 declarestypescript >=4.3 <7, so it doesn't support TypeScript 7.0.2, the currentlateston npm.
The code under test is real, not generated: money math with cent allocation, slugs, cursor pagination, an LRU cache, a token-bucket rate limiter, retry with backoff, a CSV parser, an event bus, Zod order schemas, a pricing service, an orders service with status transitions, a node:http JSON API, and the MCP server from my MCP testing guide. That's 387 lines of source and 501 lines of tests. The HTTP and MCP tests start real servers on random ports and call them with fetch and the official MCP SDK client.
To keep the suite identical, every test file imports describe, it and hooks from #t, a one-line shim that each runner maps to its own API, and asserts with node:assert/strict. Fakes are hand-written, so no runner-specific mocking runs inside the benchmark. I compare the mocking APIs separately below.
How I measured: a script ran each configuration 10 times in shuffled order. "Cold" means I deleted the runner's caches first (jest --clearCache, and node_modules/.vite plus node_modules/.vitest-cache for Vitest). "Warm" is the next run with caches in place. Times are whole-process wall clock, and I report the median. Every run had to exit 0 with all 183 tests passing.
One honest caveat: this VM is shared, and other jobs were running (load average between 3 and 13 on 8 cores). Absolute times are noisy, so read the ranking more than the decimals. I ran the main configurations in two separate batches, and Vitest, Jest and node:test each landed within about 0.1 s of their first-batch medians.
Results: the full suite
39 files, 183 tests, median of 10 runs:
| Runner and config | Cold | Warm |
|---|---|---|
Vitest 5.1, defaults (forks pool, isolated) | 1.59 s | 1.51 s |
Vitest, --pool=threads | 1.55 s | 1.42 s |
Vitest, --pool=vmThreads | 0.91 s | 0.91 s |
Vitest, --no-isolate | 0.75 s | 0.68 s |
Vitest, --fsModuleCache | 1.63 s | 1.52 s |
Jest 30.5 + @swc/jest (CommonJS output) | 0.99 s | 0.81 s |
Jest + @swc/jest, native ESM (--experimental-vm-modules) | 1.19 s | 1.01 s |
Jest + ts-jest (type-checks every file) | 4.30 s | 3.13 s |
Jest + ts-jest, isolatedModules: true | 1.44 s | 1.14 s |
node --test (process per file) | 1.23 s | 1.24 s |
node --test --test-isolation=none | 0.52 s | 0.51 s |
The threads, vmThreads and --test-isolation=none rows come from the second batch. In that batch, the Vitest default, Jest + @swc/jest and node --test rows measured 1.52/1.40 s, 0.95/0.80 s and 1.14/1.23 s.
What the numbers say:
- Isolation is most of the cost. Vitest's default
forkspool starts a fresh child process for each test file, and its own summary told me so: "39 workers spawned, ~81ms startup each".node --testalso runs each file in its own process. Jest isolates each file in a separatevmcontext but reuses its 7 worker processes. Turn isolation off and Vitest (0.68 s) and node:test (0.51 s) both beat Jest. -
vmThreadsis the middle ground for Vitest. It kept per-file isolation and cut the run from 1.51 s to 0.91 s. The catch is that it runs each file in avmsandbox, like Jest does. Vitest's docs warn that this leaks memory on large suites (workers restart when they passvmMemoryLimit) and that objects from Node APIs such asfetch'sresponse.json()andstructuredClone()have a different prototype. That's the same realm problem I hit with Jest below, and this suite only passed undervmThreadsbecause I had already fixed those tests. -
ts-jestwith type checking costs about 2.3 seconds here. It was 3.13 s warm against 0.81 s for@swc/jest. WithisolatedModules: trueit skips the type checker and drops to 1.14 s. Runningtsc --noEmiton the whole project separately took about 1.5 s (median of 5), so a separate type-check step is cheaper than type-checking inside Jest. - Caches barely mattered at this size. Cold and warm runs differ by 0.1 to 0.2 s for Vitest and Jest with SWC. Vitest 5 made
fsModuleCachestable, but on a project this small it made no measurable difference.node --testhas no transform cache at all, because type stripping is cheap.
Results at 4x the size
To see how the gap scales, I copied the test tree three more times: 156 files and 732 tests, same source. Median of 7 runs:
| Runner and config | Cold | Warm |
|---|---|---|
| Vitest, defaults | 5.15 s | 5.03 s |
Vitest, --pool=vmThreads | 2.29 s | 1.99 s |
Vitest, --no-isolate | 1.32 s | 1.08 s |
Jest + @swc/jest | 1.75 s | 1.52 s |
node --test | 4.32 s | 4.26 s |
node --test --test-isolation=none | 1.08 s | 1.10 s |
The per-file process cost grows with the file count. Isolated Vitest went from 1.5 s to 5.0 s, while Jest went from 0.8 s to 1.5 s. Without isolation, Vitest and node:test stay around one second. On a busy machine, process startup is exactly the thing that gets slower, so your numbers on a quiet laptop may be closer together, but the direction should hold.
One file, the inner loop
Running only test/orders/service.create.test.ts (4 tests), warm, median of 7:
| Runner | Time |
|---|---|
node --test | 0.18 s |
Jest + @swc/jest | 0.39 s |
| Vitest | 0.40 s |
Jest + ts-jest | 1.40 s |
Node has nothing to boot except itself. Vitest and Jest each spend about 0.2 s more starting their runner.
Watch mode
I started each runner in watch mode, waited for the first run, then edited a source file and timed the rerun (median of 5). money.ts is imported by 3 test files. events.ts is imported by the shared test fixtures, so 18 files depend on it.
| Watch command | Edit money.ts | Edit events.ts |
|---|---|---|
vitest --watch | 0.23 s, 3 files | 0.97 s, 18 files |
jest --watch (needs git) | 0.25 s, 3 files | 0.52 s, 18 files |
jest --watchAll | 0.66 s, all 39 files | 0.61 s, all 39 files |
node --test --watch | 0.31 s, 3 files | 0.94 s, 18 files |
All three rerun only the affected test files. Vitest follows the module graph, and node:test did the same in my test. Jest's --watch uses git to find changed files, and outside a git repo it refuses to start ("--watch is not supported without git/hg, please use --watchAll"). Vitest's interactive watch UI is the nicest of the three (filter by name, rerun failed tests, the optional browser UI), but on raw rerun speed they're close.
Coverage
Full suite with coverage on, median of 5 runs after one warm-up:
| Runner | Time | Lines reported |
|---|---|---|
Vitest, @vitest/coverage-v8 | 1.99 s | 97.38% |
Jest + @swc/jest, default (Babel/Istanbul) | 0.99 s | 97.81% |
Jest + @swc/jest, --coverageProvider=v8 | 1.66 s | 98.44% |
node --test --experimental-test-coverage | 1.36 s | 100% lines, 95.79% branches |
Coverage added about 0.5 s to Vitest and less to Jest with Istanbul. The percentages aren't comparable between tools, because each counts lines, statements and branches differently. Node said 100% of lines while the others found a few uncovered lines in the same code. Pick one tool for your coverage threshold and stay with it. Node's coverage is still behind the --experimental-test-coverage flag on Node 24.
Setup for each runner
Vitest
// vitest.config.ts import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["test/**/*.test.ts"], coverage: { provider: "v8", include: ["src/**"] }, // isolate: false, // 0.68 s instead of 1.51 s, if your tests don't leak state // pool: "vmThreads", // keeps isolation, 0.91 s here }, }); That's all it needed. ESM, TypeScript, .ts extensions in imports, top-level await and import.meta all worked without extra config. The exception is decorators: a class with a legacy decorator and a constructor parameter property failed with "SyntaxError: Invalid or unexpected token" under my bare config, even with experimentalDecorators in tsconfig.json. Adding unplugin-swc 2.0.0 to the plugins, with experimentalDecorators and emitDecoratorMetadata in tsconfig, fixed it. If you're on NestJS 12, the Vitest config that nest new generates already handles this. I covered that in my NestJS 12 migration guide.
Jest with @swc/jest
// jest.config.cjs /** @type {import('jest').Config} */ module.exports = { testEnvironment: "node", testMatch: ["<rootDir>/test/**/*.test.ts"], transform: { "^.+\\.ts$": ["@swc/jest"] }, }; This works if SWC compiles your ESM TypeScript to CommonJS, which is what happens when you don't list .ts in extensionsToTreatAsEsm. It broke in three places:
- Top-level await and
import.meta. My MCP server file had anif (import.meta.url === ...)block with a top-levelawaitto start the server when run directly. As CommonJS, Jest failed to parse it: "await is only valid in async functions and the top level bodies of modules". I moved the entry point to its ownmain.ts, which is cleaner anyway, and used the same code for all three runners. - Realm mismatches with
node:assert. Jest runs each file in avmcontext with its ownObject.prototype. Objects created by Node's globals, likeResponse.json()fromfetchorstructuredClone(), come from the outer realm.assert.deepStrictEqualchecks prototypes, so it failed with "Compared values have no visual difference". Jest's ownexpect().toEqual()andtoStrictEqual()passed on the same objects. Vitest's default pool and node:test didn't have the problem. I changed those tests toJSON.parse(await r.text())for all runners. -
jest.mockhoisting. With@swc/jest0.2.39, ajest.mock()call wasn't hoisted whenjestwas imported from@jest/globals. The real module ran, and my "must not send email" guard threw. Using the globaljestobject fixed it, and so didts-jest, which hoisted the same file correctly.
For native ESM, add extensionsToTreatAsEsm: [".ts"], tell SWC to keep ES modules, and start Node with --experimental-vm-modules. On Node 24.22, Jest 30.5 still printed "VM Modules is an experimental feature" for every worker. In this mode, module mocks use jest.unstable_mockModule() followed by a dynamic import().
// jest.esm.config.cjs, run with NODE_OPTIONS=--experimental-vm-modules module.exports = { testEnvironment: "node", testMatch: ["<rootDir>/test/**/*.test.ts"], extensionsToTreatAsEsm: [".ts"], transform: { "^.+\\.ts$": ["@swc/jest", { module: { type: "es6" } }] }, }; Jest with ts-jest
ts-jest uses the TypeScript compiler, so it type-checks your tests, which explains the 3.1 s run. It also brings tsconfig friction. My project tsconfig uses "module": "nodenext" and verbatimModuleSyntax in a "type": "module" package, and ts-jest's CommonJS mode failed every file with TS1295: "ECMAScript imports and exports cannot be written in a CommonJS file under 'verbatimModuleSyntax'". I needed a separate tsconfig.jest.json with "module": "commonjs", verbatimModuleSyntax: false, and a paths entry for the #t import (Node-style imports aren't resolved with node10 resolution). You also can't move to TypeScript 7 with ts-jest yet, because of the <7 peer range.
node:test
{ "scripts": { "test": "node --test \"test/**/*.test.ts\"", "test:watch": "node --test --watch \"test/**/*.test.ts\"", "test:cov": "node --test --experimental-test-coverage --test-coverage-include=\"src/**\" \"test/**/*.test.ts\"" } } No dependencies and no config. Node 24 strips types by default, and the test runner finds .ts files from the glob. The price is that your TypeScript has to be erasable: Node removes types but doesn't compile anything. I checked each case on Node 24.22.1:
| Syntax | node file.ts | --experimental-transform-types |
|---|---|---|
enum Status { ... } | ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX | works |
constructor(private readonly repo: Repo) | ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX | works |
@Injectable() decorator | ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX | SyntaxError |
So a NestJS codebase full of decorators and parameter properties can't run under plain node --test. My orders service is written NestJS-style, with constructor injection, but it assigns dependencies to private fields by hand instead of using parameter properties. That makes it erasable, and "erasableSyntaxOnly": true in tsconfig keeps it that way. You also need .ts extensions in relative imports (allowImportingTsExtensions plus noEmit for tsc) and import type for type-only imports (verbatimModuleSyntax).
Mocking APIs compared
The same two jobs in each runner: replace an ES module that sends email, and fake a 60-second timer. All three files below passed.
// Vitest: vi.mock is hoisted above the imports import { vi, it, expect } from "vitest"; import { sendReceipt } from "./receipts.ts"; import { sendEmail } from "./mailer.ts"; vi.mock("./mailer.ts", () => ({ sendEmail: vi.fn(async () => ({ id: "msg_1" })) })); it("mocks an ES module", async () => { expect(await sendReceipt("a@b.co", 7)).toBe("msg_1"); expect(sendEmail).toHaveBeenCalledWith("a@b.co", "Receipt for order #7"); }); // timers: vi.useFakeTimers(); vi.advanceTimersByTime(60_000); // Jest (CommonJS output): use the global jest object so jest.mock gets hoisted import { sendReceipt } from "./receipts.ts"; import { sendEmail } from "./mailer.ts"; jest.mock("./mailer.ts", () => ({ sendEmail: jest.fn(async () => ({ id: "msg_1" })) })); // timers: jest.useFakeTimers(); jest.advanceTimersByTime(60_000); // node:test: needs --experimental-test-module-mocks, then a dynamic import import { mock, it } from "node:test"; import assert from "node:assert/strict"; const sendEmail = mock.fn(async (_to: string, _subject: string) => ({ id: "msg_1" })); mock.module("./mailer.ts", { exports: { sendEmail } }); const { sendReceipt } = await import("./receipts.ts"); it("mocks an ES module", async () => { assert.equal(await sendReceipt("a@b.co", 7), "msg_1"); assert.deepEqual(sendEmail.mock.calls[0].arguments, ["a@b.co", "Receipt for order #7"]); }); // timers: mock.timers.enable({ apis: ["setTimeout"] }); mock.timers.tick(60_000); Vitest has the smoothest module mocking for ESM. Jest's is fine in CommonJS but needs unstable_mockModule and dynamic imports in ESM. Node's mock.module() works, but it's experimental: without the flag, mock.module is not a function. Node 24 also prints a deprecation warning if you use the older namedExports option instead of exports. Node's mock.fn(), mock.method() and mock.timers need no flag and covered everything else I needed. For most service code, constructor injection with hand-written fakes, like this suite uses, avoids module mocking altogether.
Which test runner should you use?
Pick Vitest for a new TypeScript or ESM project, a Vite or React frontend, or a NestJS 12 app. It needed the least configuration, handled ESM and top-level await without flags, and has the best mocking and watch experience. Its default per-file process isolation is the slowest option at scale, so try pool: "vmThreads" or isolate: false once your suite is stable. Those settings made it the fastest or close to it in my runs.
Keep Jest if you have a large, working Jest suite, especially CommonJS. With @swc/jest it was the fastest default configuration here, and moving to Vitest is mostly an API rename (jest.fn to vi.fn) that buys you little speed unless you also change isolation. Swap ts-jest for @swc/jest and run tsc --noEmit separately: that alone took this suite from 3.13 s to 0.81 s.
Pick node:test for libraries, CLIs and backend code written in erasable TypeScript, where zero dependencies matters. It had the fastest single-file runs (0.18 s) and the fastest full run without isolation (0.51 s). You give up a rich expect, snapshot ergonomics and stable module mocking, and decorator-heavy code won't run. If you're comparing runtimes too, I measured Bun against Node separately, and I covered what changes in TypeScript 7 for build tooling.
FAQ
Is Vitest faster than Jest?
Not with default settings in my test. On a 39-file TypeScript suite, Jest 30.5 with @swc/jest took 0.81 s warm and Vitest 5.1 took 1.51 s, because Vitest's default forks pool starts a process per test file. With isolate: false Vitest took 0.68 s, and with pool: vmThreads 0.91 s. At 156 files the default gap grew to 1.52 s for Jest against 5.03 s for Vitest.
Can node:test run TypeScript without a loader?
Yes, on Node 24 type stripping is on by default and node --test picks up .ts files from a glob. It only removes types, so enums, constructor parameter properties and decorators fail with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. You also need .ts extensions in relative imports and import type for type-only imports.
Should I use ts-jest or @swc/jest?
Use @swc/jest and run tsc --noEmit as a separate step. In my runs ts-jest took 3.13 s warm because it type-checks every file, against 0.81 s for @swc/jest, while tsc on the whole project took about 1.5 s. ts-jest 29.4.15 also declares typescript below 7, so it doesn't support TypeScript 7 yet.
Does Jest support ESM in 2026?
Yes, but still behind Node's --experimental-vm-modules flag. Jest 30.5 on Node 24.22 ran my suite as native ESM in 1.01 s warm, but printed an experimental warning for every worker, and module mocks need jest.unstable_mockModule plus a dynamic import. Compiling to CommonJS with @swc/jest avoids the flag but breaks top-level await and import.meta.
Which test runner has the fastest watch mode?
They were close. After editing a file imported by 3 test files, Vitest reran in 0.23 s, jest --watch in 0.25 s and node --test --watch in 0.31 s, and all three reran only the affected files. jest --watch needs a git repository; jest --watchAll reruns every file.
Originally published on fabwaseem.com.

