Caching Jest Runs on GitHub Actions
Jest re-transforms every module on a fresh GitHub Actions runner. Pin cacheDirectory, restore it with a key that survives dependency bumps, and size maxWorkers.
Last verified:
Jest keeps a transform cache on disk, and a GitHub Actions runner is destroyed when its job ends, so every run re-transforms every module the suite imports. Pin cacheDirectory to a stable path, restore and save it with a cache action keyed on the lockfile and the transformer configuration, and set --maxWorkers to the vCPU count of the runner label.
This guide covers what actually sits in the Jest cache directory, the key design that keeps an entry useful after a dependency bump, the workflow file with the worker count matched to the runner size, and the arithmetic on a suite of a stated size. For the wider question of carrying state between runs, see the guide to persistent caches for GitHub Actions runs.
Diagnosis
Measure before editing the workflow. A slow jest step has three common causes, and they respond to different fixes.
Every module is transformed again on every run
Jest transforms each module it loads, applying babel-jest, ts-jest, or an SWC transformer, and writes the result to disk. The on-disk entry is keyed by a hash over the file contents, the transformer, the transformer configuration, and the Jest version, so a warm directory turns transformation into a file read.
Confirm how much of the step is transformation. Run the suite twice locally, once after clearing the cache:
npx jest --showConfig | grep cacheDirectory
npx jest --clearCache
time npx jest --ci --maxWorkers=8
time npx jest --ci --maxWorkers=8The gap between the two timings is the transform work a warm cache removes. That number, and the module count from the run summary, are the inputs to the model at the end of this page.
The default cache directory is outside anything you cache
Jest puts the cache under the operating system temp directory. On a Linux runner that path sits inside /tmp and carries a user-derived suffix, so it is awkward to name in a workflow file and no dependency cache covers it. Point cacheDirectory at the workspace instead, which makes the path stable across runners and across local machines.
Three artifacts share that directory
| Artifact | What it holds | What invalidates it |
|---|---|---|
jest-transform-cache-<hash> | One file per transformed module plus a companion hash file | Module contents, transformer, transformer config, or Jest version changes |
haste-map-<hash>.json | The crawl of the file tree: paths, sizes, modification times, module and mock names | Files added, removed, or touched |
| Sequencer timing file | Per test file duration and pass or fail state from the previous run | The next completed run overwrites it |
The transform cache is the part worth carrying between runs. The haste map is cheap to rebuild on a small repository and expensive on a large one, and it rides along in the same directory at no extra effort. The sequencer file is what lets Jest start the slowest test files first, which shortens the tail on a wide worker pool.
The worker pool is one worker short
Jest runs one worker fewer than the reported CPU count. That default protects an interactive laptop and wastes a core on a GitHub Actions runner, where the job owns the machine. On a small shape the loss is severe: a 2 vCPU runner runs a single worker.
Separating a transform-bound step from a memory-bound one is where WarpBuild CI observability helps. It sits alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger in the product surface, and it correlates runner system metrics with job logs, so a step that pins every core looks different from a step where workers are being killed and restarted.
Fix
Three changes, in this order.
1. Pin the cache directory. Add it to jest.config.js and to .gitignore:
module.exports = {
cacheDirectory: '<rootDir>/.jest-cache',
};2. Restore and save that directory. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 and takes the same inputs, as the caching documentation describes. Use the split restore and save form so the save runs only on the default branch.
The Jest cache is a separate entry from the package manager store. WarpBuilds/setup-node@v6 already caches ~/.npm, the yarn cache folder, or the pnpm store, and those paths hold packed tarballs rather than transformed modules. Keep both entries, because they change on different schedules: the store moves when the lockfile moves, the transform cache moves whenever source files move. The answer on caching node_modules covers the store side.
3. Set the worker count. Pass --maxWorkers explicitly and match it to the runner label rather than to whatever the container reports.
Configuration
The whole workflow:
name: test
on:
pull_request:
push:
branches: [main]
jobs:
jest:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v5
- uses: WarpBuilds/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- name: Restore Jest cache
id: jest-cache
uses: WarpBuilds/cache/restore@v1
with:
path: .jest-cache
key: jest-${{ runner.os }}-node22-${{ hashFiles('package-lock.json', 'babel.config.js', 'jest.config.js') }}-${{ github.run_id }}
restore-keys: |
jest-${{ runner.os }}-node22-${{ hashFiles('package-lock.json', 'babel.config.js', 'jest.config.js') }}-
jest-${{ runner.os }}-node22-
- name: Test
run: npx jest --ci --maxWorkers=8
- name: Save Jest cache
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/cache/save@v1
with:
path: .jest-cache
key: ${{ steps.jest-cache.outputs.cache-primary-key }}The key that survives a dependency bump
Hash three files together. The lockfile belongs in the key because the transformer is a dependency: babel-jest, ts-jest, the SWC binary, and every Babel preset arrive through the lockfile, and a version change alters the transform output for every module. babel.config.js and jest.config.js belong there because a preset or a transform entry changes the same output without touching the lockfile.
The github.run_id suffix makes the primary key unique per run, so a save always writes a fresh entry that includes whatever the run transformed. Restores fall through the two shorter prefixes: the first returns the newest directory built from the same dependency and config set, the second returns the newest directory for the same runner and Node version after a dependency bump.
That second tier is the one that matters, and it is safe because Jest keys each module internally. Restoring a directory built before a Babel upgrade costs nothing beyond storage: Jest recomputes its own hash per module, misses on the modules the upgrade affected, and reuses the rest. A stale entry degrades to a partial hit rather than a wrong result.
Only the newest entry is ever restored, so superseded entries stop being read and expire 7 days after their last use. The delete-cache input on the cache action prunes them sooner when a repository holds more than it needs.
Workers matched to the runner size
| Runner label | vCPU | RAM | --maxWorkers | Heap headroom per worker |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8 GB | 2 | 4 GB |
| warp-ubuntu-latest-x64-4x | 4 | 16 GB | 4 | 4 GB |
| warp-ubuntu-latest-x64-8x | 8 | 32 GB | 8 | 4 GB |
| warp-ubuntu-latest-x64-16x | 16 | 64 GB | 16 | 4 GB |
| warp-ubuntu-latest-x64-32x | 32 | 128 GB | 32 | 4 GB |
Every Linux size holds 4 GB per worker at full width, which is enough for most suites. When workers are killed and restarted, set workerIdleMemoryLimit so Jest recycles a worker on its own terms, or drop --maxWorkers by a step. --maxWorkers also accepts a percentage, so --maxWorkers=100% tracks the label without a second edit when the size changes. The same worker arithmetic applies on each: read the vCPU count of the label from the cloud runners documentation and pass it through.
Past the largest single shape, split the suite instead of buying width. jest --shard=1/4 divides test files across jobs, each with its own restored cache directory; the guide to matrix sharding on GitHub Actions covers the fan-out, and the guide to sizing runners for Node test suites covers where one large runner beats four small ones.
In a monorepo, give each package its own cacheDirectory and hash that workspace's lockfile in its key, so a change in one workspace leaves the other entries warm.
Cost or Time Model
Substitute your own module count and timings from the diagnosis section. The assumptions below describe one suite:
- 1,200 test files importing 6,500 distinct modules, TypeScript transformed by
babel-jest. - Jobs on
warp-ubuntu-latest-x64-8xwith--maxWorkers=8. - 30 runs per weekday over 22 weekdays, so 660 runs per month.
- Cold transform pass across all 6,500 modules: 3.20 minutes.
- Warm path: 0.45 minutes to download and decompress a 340 MB entry, plus 0.15 minutes to transform the modules a pull request actually changed.
- The save runs on the default branch only, roughly 66 times per month at 0.50 minutes, which amortizes to 0.05 minutes per run.
| Path | Minutes per run | Monthly runner minutes | Monthly runner cost |
|---|---|---|---|
| No Jest cache | 3.20 | 2,112 | $33.79 |
| Cached | 0.65 | 429 | $6.86 |
Add the cache fees. About 15 entries stay resident at any moment, since a superseded entry lives out its 7 day expiry, and 15 entries at 340 MB is 5.1 GB, which bills at $0.20 per GB-month for $1.02. The 660 restores and 66 saves are 726 operations at $0.0001 each, another $0.07. The cached path lands at $7.95 per month against $33.79 uncached, a difference of $25.84. On BYOC runners, cache storage and operations are included, so only the runner minutes column applies.
Two levers stack, and it helps to keep them separate. The cache removes minutes; the list price sets what each remaining minute costs. Holding the cached 429 minutes fixed, warp-ubuntu-latest-x64-8x costs $0.016 per minute against $0.022 for the 8-core Linux larger runner at the same 8 vCPU and 32 GB shape, which is 27 percent lower list price and $6.86 against $9.44 for the month (GitHub Actions billing reference, checked on 2026-08-13). WarpBuild rates come from the pricing page and the cloud runners documentation.
FAQ
Where does Jest store its cache on a GitHub Actions runner?
Under the operating system temp directory by default, which on a Linux runner means a path inside /tmp that carries a user-derived suffix. Run jest --showConfig and read the cacheDirectory value to see the resolved path. Because that path is unstable and lives outside the workspace, set cacheDirectory explicitly in jest.config.js to a path such as <rootDir>/.jest-cache before you point a cache action at it.
What cache key keeps the Jest cache valid when dependencies change?
Hash the lockfile together with the Jest and transformer configuration files, because the transformer version and its presets are dependencies and they change the transform output. Add a per-run suffix to the primary key so every save writes a fresh entry, then list two shorter prefixes under restore-keys so a config change still restores the previous directory. A partially stale directory is safe, because Jest computes its own per module key and re-transforms only the modules whose inputs changed.
How many Jest workers should I run on a GitHub Actions runner?
Jest defaults to one worker fewer than the reported CPU count, which leaves a core idle on a runner that runs nothing but the job. Set --maxWorkers to the vCPU count of the runner label, so 8 on warp-ubuntu-latest-x64-8x and 16 on warp-ubuntu-latest-x64-16x. If workers are killed by the out-of-memory killer, drop the count or set workerIdleMemoryLimit so Jest restarts a worker once its heap passes the limit.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.