Why warm Jest runs are slow (it's not the test code)
There is a class of complaint about Jest that is unkind but true: “I added one expect and the run got slower.” The complaint is unkind because Jest is not a slow runner in the cynical sense. It is a careful runner with a careful execution model and a substantial transform pipeline. It is true because in most repositories the time you spend waiting for npx jest to do anything is dominated by work that has nothing to do with your tests.
This post is a tour of where that time goes. Then we explain how rjest moves each chunk into a place where it can be paid once instead of every time.
What “slow” actually means
When a developer says Jest is slow, they usually mean one of two things. They mean the watch-mode rerun feels sluggish when they save a file. Or they mean CI feels expensive — the test step is a meaningful share of pipeline minutes. Both perceptions are about invocation cost: the time between starting the command and seeing useful output. Almost nobody complains that an individual expect(a).toBe(b) is slow, because that part really is microseconds.
So the right question is: what does Jest do before it runs your first test? It turns out to be quite a lot.
Phase 1: Config discovery and parsing
When you run jest, the first thing the runner does is find your configuration. It walks up from the working directory looking for jest.config.js, jest.config.ts, jest.config.mjs, or a jest block in package.json. If it finds a TypeScript config, it has to compile it before it can read it — which means loading ts-node or an equivalent, parsing the config file, and evaluating it.
For most repos this is in the low hundreds of milliseconds. For a monorepo with shared preset packages, it can climb higher because the preset chain has to be resolved and merged.
You pay this cost every invocation, even when only one file changed.
Phase 2: Plugin discovery and transformer setup
Once Jest has a config, it has to wire up the transformer chain. If your config uses Babel, that means loading Babel and its plugins. If you use ts-jest, that means loading ts-jest, which loads TypeScript. If you use SWC via a Jest transformer, you avoid the worst of this, but you still load the bridge.
For a vanilla setup with Babel and a few presets, you should expect 200-500ms just in module loading.
You pay this cost every invocation.
Phase 3: Test file discovery
Jest walks your project to find test files. The default glob is broad. For a repository with thousands of source files, this walk is fast but not free, and on cold disk caches it is meaningfully slower. Watch mode mitigates this for subsequent runs, but the cold pass on a fresh shell pays it.
Phase 4: Worker boot
Jest’s default execution model forks worker processes. Each worker is a fresh Node.js process that has to start V8, load the Jest runtime, load your transformer, and warm up the JIT. With four workers, you pay the cost four times in parallel. The actual wall-clock cost is dominated by whichever worker is slowest to become ready.
This is the largest single chunk of invocation overhead for most repos. It is also the most invisible — workers boot in parallel, so the developer just sees “Jest is slow” without a clear breakdown.
Phase 5: First transform
Now Jest has workers ready and a queue of test files. Each worker takes a file, hands it to the transformer, and waits for compiled output. The first transform per worker is slow because it pays for transformer warmup. Subsequent transforms are faster because some caches are populated.
A meaningful subset of repos have a Babel cache that does not persist across processes. Each worker, even on a warm run, has to rebuild it.
Phase 6: Module loading inside the VM
Compiled test code is then loaded inside a VM context. Each require or import in your test pulls in your source, which pulls in your dependencies. For a test that exercises code with a deep dependency tree, this is hundreds of milliseconds of module resolution and evaluation.
By the time Jest is ready to run your first assertion, a real codebase has spent one to several seconds on the work above. The test code itself often runs in milliseconds.
Where rjest changes the math
rjest’s claim is mechanical: every phase above is something you should pay once per machine session, not once per command.
Config discovery and parsing is paid by the daemon at startup. Subsequent invocations skip directly to “I already know your config.”
Plugin discovery and transformer setup is moot. The daemon is the transformer — SWC runs natively in Rust, inside jestd. There is no Node-side transformer chain to wire up.
Test file discovery is paid by the daemon’s filesystem watcher. The set of test files is always current; an invocation simply reads it.
Worker boot is paid once. The daemon keeps a pool of warm Node.js worker processes. They have already loaded V8, the test runtime, and the necessary glue. New invocations dispatch onto them; they do not spawn fresh.
First transform is paid by the disk cache. sled holds compiled output keyed by blake3 content hash of the source. If a file has not changed, the cache hit is microseconds. If it has, SWC is fast enough that the cost is small.
Module loading inside the VM still has to happen — that is the test executing. But the workers retain VM caches between runs, so import graphs that have been touched before are warm.
What is left is real test work: running your assertions, doing your I/O, computing your snapshots. That is what gets reported as ~14ms in the repo’s benchmark. The number is not surprising once you account for what was removed; it is a small constant plus actual work.
The corollary: cold runs are slower
The honest other side of this is that the very first run, before the daemon exists, is slower than Jest. The daemon has to boot. The cache is empty. The workers have not yet been forked.
For a representative suite this is about 1.9s for rjest versus 1.4s for Jest — a few hundred milliseconds slower. The trade is that you pay this once per session and amortize it over every subsequent run.
In CI, where each job typically starts cold, this trade looks different. If your CI runs rjest once and exits, you do not see the warm benefit. If your CI runs many test commands in one job (lint, type-check, unit, integration), or if you can warm the daemon between PRs on a persistent runner, the math swings the other way. We have notes on both patterns in the benchmarking post.
The point
Jest is not a slow runner in any intrinsic sense. The expect() loop is fast. What is slow is the invocation overhead, and the invocation overhead is paid every time because Jest’s model is “one shot per command.”
rjest does not make Jest faster. It makes “another command” faster, by paying the once-per-session costs once per session. If you have ever sat staring at a watch-mode reload and wondered why it takes so long for a file change to produce a test result, you now know: it is not the test runner thinking. It is the test runner getting dressed.
We think there is a better factoring.