Splitting Jest Tests by Timing Data for Balanced Shards
Jestβs --shard splits by file path hash, so shard 3 gets the four slowest integration suites and shard 1 gets forty trivial unit files. The job takes as long as shard 3 while three runners sit idle. A custom testSequencer fixes it in about forty lines, using durations Jest already reports.
When to use this pattern
- You already shard a Jest suite and the shard durations differ by more than about 30%.
- Test file runtimes vary widely β a mix of pure unit tests and database-backed suites.
- You want the fix inside Jest rather than as an external file-list generator.
Prerequisites
Where the imbalance comes from
The default sequencer is deliberately simple: hash the path, take the modulus, done. It is deterministic and requires no state, which is the right default β and it is exactly wrong for a suite whose files differ by two orders of magnitude in runtime.
Complete working example
// jest-sequencer-timed.cjs β assign files to shards by recorded duration.
const Sequencer = require('@jest/test-sequencer').default;
const { readFileSync, existsSync } = require('node:fs');
const path = require('node:path');
const TIMINGS = path.resolve('test-timings.json'); // { "src/a.test.ts": 31.4, β¦ } seconds
const DEFAULT_SECONDS = 1.5; // optimistic guess for unseen files
class TimedSequencer extends Sequencer {
constructor(...args) {
super(...args);
this.timings = existsSync(TIMINGS) ? JSON.parse(readFileSync(TIMINGS, 'utf8')) : {};
}
duration(test) {
const rel = path.relative(process.cwd(), test.path);
return this.timings[rel] ?? DEFAULT_SECONDS;
}
// Jest calls shard() before sort(); returning only this shard's slice is the whole job.
shard(tests, { shardIndex, shardCount }) {
// Greedy longest-processing-time: place the biggest item in the emptiest bin.
const bins = Array.from({ length: shardCount }, () => ({ total: 0, tests: [] }));
for (const test of [...tests].sort((a, b) => this.duration(b) - this.duration(a))) {
const target = bins.reduce((min, b) => (b.total < min.total ? b : min));
target.tests.push(test);
target.total += this.duration(test);
}
return bins[shardIndex - 1].tests; // shardIndex is 1-based
}
// Within the shard, still run slowest-first so workers stay busy to the end.
sort(tests) {
return [...tests].sort((a, b) => this.duration(b) - this.duration(a));
}
}
module.exports = TimedSequencer;// jest.config.cjs
module.exports = {
testSequencer: '<rootDir>/jest-sequencer-timed.cjs',
// Jest still owns the --shard flag; the sequencer only changes how it partitions.
};# .github/workflows/timings.yml β regenerate the data nightly, serially.
name: Refresh test timings
on:
schedule: [{ cron: '0 3 * * *' }]
permissions: { contents: write }
jobs:
timings:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci --ignore-scripts
# --runInBand: measured durations must not be contaminated by worker parallelism.
- run: npx jest --runInBand --json --outputFile=raw.json
- run: |
jq '[.testResults[] | { (.name | sub("^\(env.PWD)/";"")):
((.endTime - .startTime) / 1000) }] | add' raw.json > test-timings.json
git add test-timings.json
git diff --cached --quiet || git -c user.name=ci -c user.email=ci@local \
commit -m "chore: refresh test timings" && git pushStep-by-step walkthrough
shard() is the extension point, not sort(). Jest calls shard() first to select this runnerβs files, then sort() to order them. Overriding only sort() reorders within an already-unbalanced slice and changes nothing about the imbalance.
Every shard runs the same packing. The algorithm is deterministic and each runner computes the full assignment, then returns its own bin. That is why no coordination is needed between runners β an important property, since they have no way to talk to each other.
Sort longest-first before packing. Greedy bin packing is only close to optimal when large items are placed first. Feeding the list in arbitrary order can leave a 40-second suite as the last item, with nowhere balanced to put it.
Unseen files get an optimistic default. A new test file has no timing. Guessing 1.5 seconds and letting it land in the lightest bin costs a slightly uneven run; the alternatives β skipping it, or guessing high β are worse, because one silently loses coverage and the other permanently distorts the packing.
Measure with --runInBand. Durations recorded while four workers compete for two cores are inflated and unevenly so. The nightly job runs serially precisely so the numbers describe the tests rather than the contention.
Verification
# 1. The packing is balanced β print each shard's predicted total.
for i in 1 2 3 4; do
npx jest --shard="$i/4" --listTests --silent 2>/dev/null \
| sed "s|^$PWD/||" \
| jq -R . | jq -s --slurpfile t test-timings.json \
'map($t[0][.] // 1.5) | add | floor' \
| xargs printf "shard %s: %ss\n" "$i"
doneExpected β a spread well inside 15%:
shard 1: 96s
shard 2: 94s
shard 3: 99s
shard 4: 92s# 2. Every file is in exactly one shard β no duplicates, no omissions.
for i in 1 2 3 4; do npx jest --shard="$i/4" --listTests --silent; done \
| sort | uniq -d | head # must print nothing
for i in 1 2 3 4; do npx jest --shard="$i/4" --listTests --silent; done | wc -l
npx jest --listTests --silent | wc -l # the two counts must matchThe second check is the important one. A sequencer bug that drops files produces a green run that tested less than you think β the most dangerous possible failure mode for this change.
Keeping the timing data useful
Timing data decays, and it decays in a way that quietly restores the imbalance you removed. Every new test file added between refreshes carries the default estimate, so a fortnight of active development can put a dozen unmeasured files in whichever shard was lightest at generation time β which is exactly where the packing is most sensitive.
Two habits keep it honest. Refresh nightly rather than weekly: the cost is one serial run on a schedule, and it means no file carries a guess for more than a day. And track the spread as a number β the ratio of the slowest shard to the fastest β rather than eyeballing job durations. A spread that has drifted past 1.2 usually means the timing file is stale, a heavy new suite arrived, or something in the environment changed enough that the recorded durations no longer describe reality.
Common pitfalls
Recording timings from a sharded run. Durations measured under parallelism describe contention, not the tests, and feeding them back makes the next packing worse. Always measure serially.
Committing timings from a pull-request run. The pull-request environment may have a different database or cache state. Generate from the main branch on a schedule so the data has one consistent source.
Assuming the sequencer applies to --runInBand. It does β but with one shard the packing is a no-op, so a local single-shard run tells you nothing about whether the sequencer works. Test it with --shard, as in the verification step.
Commit the timing file rather than caching it. A cached timing file is invisible in review, expires silently, and cannot be inspected when the packing looks wrong. A committed one shows up in diffs, can be checked out at any past commit, and makes the nightly refresh jobβs behaviour auditable.
Related
- Parallelizing Test Suites in CI β the parent guide on shard mechanics and choosing a count.
- Sharding Playwright Tests Across GitHub Actions Runners β the browser-suite equivalent, with different overhead.
- Merging Coverage Reports from Parallel Test Shards β reassembling what the shards measured.
- Build Optimization & Caching Strategies β the section overview.
β Back to Parallelizing Test Suites in CI