How to speed up a slow Stryker run

Updated . Numbers from the Reapr bench report. Read this guide as Markdown.

A mutation run on a small diff should take seconds of test time. In practice it often takes most of a minute, and almost none of that is tests. This guide shows where the time went in one measured case and which Stryker settings changed it.

The measured case

The numbers come from Reapr’s bench report: one package of a pnpm + Turborepo monorepo (~55k files incl. build caches), StrykerJS 9.6.1, Vitest 4, a diff that produced 3 mutants, and medians of 5 runs per variant on a 22-thread Linux machine. Every run was under load from other jobs, so treat absolute seconds as inflated. The comparisons between variants are what matter.

Where the time goes

A median run took 54.0 s. About 85% of it happened before the first test ran:

Phase Median Share
Start, load the config, walk 55,389 files 18.0 s 33%
Instrument and preprocess 4.1 s 8%
Checker, sandbox copy, test runner startup 24.8 s 46%
Initial test run (83 tests) 1.4 s 3%
Testing the 3 mutants 2.7 s 5%
Reporters and temp-dir cleanup 1.7 s 3%

Testing, the initial run plus the mutants, was about 8% of the wall time. Before you tune test speed, look at startup.

Keep build caches out of the sandbox

Stryker copies your project into a sandbox directory before it runs anything. This finding applies to Turborepo repos with a local cache inside the repository (.turbo/cache); not every monorepo has one, and a review of 8 open source Stryker repositories found none. In the measured package, .turbo/cache was 53,832 of the 55,390 files copied (97%) and 1,277.6 MB of the 1.288 GB (99.2%). The config’s ignorePatterns did not exclude it.

Adding .turbo to ignorePatterns took a plain Stryker run from 38.4 s to 12.5 s (−67%), with identical results. CPU time fell from 62.8 s to 19.8 s and peak memory from 607 MB to 399 MB.

// stryker.config.mjs
export default {
  // ...your existing config
  ignorePatterns: [".turbo"],
};

Stryker 9.6.1 and 10.x always ignore node_modules, .git, *.tsbuildinfo, /stryker.log, .next, .nuxt and .svelte-kit. Anything else in the project directory is copied. Other large directories that your tests never read are candidates too, but do not ignore files your test runner needs, such as its config or fixtures.

Stryker prints how many files it found at the default log level, in a line like ProjectReader Found N of M file(s). If M is far above the number of source files you expect, list the largest directories:

du -sh .[!.]* * 2>/dev/null | sort -h | tail

Point the TypeScript checker at the files you mutate

The TypeScript checker builds a TypeScript program to discard mutants that do not compile. In the measured package it cost about 3.2 s and checked nothing. The tsconfig it used had no include, so it covered only three shared Vitest config files. The mutated package was outside that program, and the checker let every mutant through.

Check what your checker’s tsconfig covers:

npx tsc --listFilesOnly -p tsconfig.json | grep -v node_modules

If the files you mutate are not in the list, point tsconfigFile at a tsconfig that includes them, or remove the checker if you do not need compile-error filtering:

export default {
  checkers: ["typescript"],
  tsconfigFile: "tsconfig.json", // must include the files you mutate
};

Use debug logging only to diagnose

--logLevel debug is useful to find which phase is slow, but it is not free. In the measured case it added 21 s to the median wall time (59.4 s against 38.4 s at the default level). In one run it held Stryker’s event loop for about 4 s while it printed the list of 55k files.

Do not raise concurrency for small diffs

With 3 mutants, --concurrency 8 made the run slower, not faster. Starting four TypeScript checkers in parallel took 8.9 s against 2.9 s, and mutant testing saved only 1.1 s. The phases added up to 5.7 s more. A bigger pool pays off when there are many mutants to spread across it.

About coverageAnalysis

coverageAnalysis decides which tests run against each mutant. With perTest, the default since Stryker 5, Stryker runs only the tests that cover the mutant. With off, it runs every test for every mutant. Stryker’s docs say it does not change the score. The Vitest runner ignores the setting and always uses perTest, so for Vitest projects there is nothing to tune here. For Jest, Mocha or Jasmine, keep perTest unless your tests depend on each other’s order.

What Reapr does here

Reapr runs your Stryker config as written and does not change any of these settings. In the measured runs, Reapr’s own work outside the Stryker process took 1.7 s. It records the engine time, CPU time and peak memory of every run in .git/reapr/runs/<run_id>/run.json, so you can compare a config change against the runs before it.

Questions about this guide

Why is Stryker slow even when only a few mutants run?
Most of the time goes to startup, not to tests. In a measured run with 3 mutants, about 85% of the wall time passed before the first test ran: walking the project, instrumenting files, copying the sandbox and starting the checker and test runner.
What does ignorePatterns do in Stryker?
It lists files and directories that Stryker does not copy into its sandbox. Stryker 9.6.1 and 10.x always ignore node_modules, .git, *.tsbuildinfo, /stryker.log, .next, .nuxt and .svelte-kit. Other caches, such as Turborepo's local cache in .turbo, are copied unless you add them.
Does coverageAnalysis change the mutation score?
No. Stryker's docs say coverage analysis is a performance optimization that does not influence the score. The default is perTest, and the Vitest runner always uses perTest whatever you set.