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=8

The 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

ArtifactWhat it holdsWhat invalidates it
jest-transform-cache-<hash>One file per transformed module plus a companion hash fileModule contents, transformer, transformer config, or Jest version changes
haste-map-<hash>.jsonThe crawl of the file tree: paths, sizes, modification times, module and mock namesFiles added, removed, or touched
Sequencer timing filePer test file duration and pass or fail state from the previous runThe 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 labelvCPURAM--maxWorkersHeap headroom per worker
warp-ubuntu-latest-x64-2x28 GB24 GB
warp-ubuntu-latest-x64-4x416 GB44 GB
warp-ubuntu-latest-x64-8x832 GB84 GB
warp-ubuntu-latest-x64-16x1664 GB164 GB
warp-ubuntu-latest-x64-32x32128 GB324 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-8x with --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.
PathMinutes per runMonthly runner minutesMonthly runner cost
No Jest cache3.202,112$33.79
Cached0.65429$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.