# Vitest vs Jest vs node:test in 2026: Benchmarked on a Real TypeScript Project

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-v8` 5.1.0
- **Jest 30.5.2** with `@swc/jest` 0.2.39 and `@swc/core` 1.16.21, and separately with `ts-jest` 29.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-jest` 29.4.15 declares `typescript >=4.3 <7`, so it doesn't support TypeScript 7.0.2, the current `latest` on 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](https://fabwaseem.com/blog/test-mcp-server). 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 `forks` pool starts a fresh child process for each test file, and its own summary told me so: "39 workers spawned, ~81ms startup each". `node --test` also runs each file in its own process. Jest isolates each file in a separate `vm` context but reuses its 7 worker processes. Turn isolation off and Vitest (0.68 s) and node:test (0.51 s) both beat Jest.
- **`vmThreads` is 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 a `vm` sandbox, like Jest does. Vitest's docs warn that this leaks memory on large suites (workers restart when they pass `vmMemoryLimit`) and that objects from Node APIs such as `fetch`'s `response.json()` and `structuredClone()` have a different prototype. That's the same realm problem I hit with Jest below, and this suite only passed under `vmThreads` because I had already fixed those tests.
- **`ts-jest` with type checking costs about 2.3 seconds here.** It was 3.13 s warm against 0.81 s for `@swc/jest`. With `isolatedModules: true` it skips the type checker and drops to 1.14 s. Running `tsc --noEmit` on 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 `fsModuleCache` stable, but on a project this small it made no measurable difference. `node --test` has 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

```ts
// 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](https://fabwaseem.com/blog/nestjs-12).

### Jest with @swc/jest

```js
// 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:

1. **Top-level await and `import.meta`.** My MCP server file had an `if (import.meta.url === ...)` block with a top-level `await` to 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 own `main.ts`, which is cleaner anyway, and used the same code for all three runners.
2. **Realm mismatches with `node:assert`.** Jest runs each file in a `vm` context with its own `Object.prototype`. Objects created by Node's globals, like `Response.json()` from `fetch` or `structuredClone()`, come from the outer realm. `assert.deepStrictEqual` checks prototypes, so it failed with "Compared values have no visual difference". Jest's own `expect().toEqual()` and `toStrictEqual()` passed on the same objects. Vitest's default pool and node:test didn't have the problem. I changed those tests to `JSON.parse(await r.text())` for all runners.
3. **`jest.mock` hoisting.** With `@swc/jest` 0.2.39, a `jest.mock()` call wasn't hoisted when `jest` was imported from `@jest/globals`. The real module ran, and my "must not send email" guard threw. Using the global `jest` object fixed it, and so did `ts-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()`.

```js
// 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

```json
{
  "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.

```ts
// 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);
```

```ts
// 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);
```

```ts
// 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](https://fabwaseem.com/blog/bun-vs-node) separately, and I covered what changes in [TypeScript 7](https://fabwaseem.com/blog/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](https://fabwaseem.com/blog/vitest-vs-jest-vs-node-test).*
