bun test has three independent knobs for running more than one thing at a time:
They compose: a CI job can run
bun test --shard=2/4 --parallel, and files in that shard can still contain test.concurrent tests.
--parallel
terminal
bun test process becomes a coordinator. It discovers test files as usual, then starts worker processes and hands each one file at a time. Results stream back as each test finishes, so the output looks the same as a serial run. The coordinator prints each file’s results together under its filename, and never interleaves console.log output from a test with another file’s.
--parallel-delay=<ms>, default 5). A suite of tiny files therefore runs on a single worker with no process-spawn overhead, while the first slow file triggers full fan-out.
How files are distributed
The coordinator sorts files by path and splits them into one contiguous chunk per worker, so files in the same directory, which usually import the same modules, mostly land in the same process (a chunk boundary can fall inside a directory, and stolen files move). When a worker drains its chunk it steals the back half of the largest remaining chunk from another worker. With--timings the coordinator cuts the chunks by recorded duration instead of file count, each worker starts its slowest file first, and an idle worker steals the slowest not-yet-started file from whichever chunk has the most time left.
Every file is isolated (unless you opt out)
--parallel implies --isolate: each file runs in a fresh global object even when two files land on the same worker. Tests that pass with --parallel don’t depend on state leaked by an earlier file.
--parallel --no-isolate turns that off: each worker keeps a single global and module registry for all the files it is handed, exactly like a serial bun test does for the whole suite. Each worker evaluates imports (and --preload modules) once instead of once per file, which is the fastest way to run a large suite of small files. The price is that a file can observe whatever an earlier file on the same worker left behind. Preload-level beforeAll/afterAll hooks still wrap every file, since a worker never knows which file is its last.
Worker environment
Each worker getsBUN_TEST_WORKER_ID and JEST_WORKER_ID set to its 1-based index, so tests can pick a distinct database, port range, or temp directory per worker:
db.test.ts
--timeout, --preload, --define, --coverage, --update-snapshots, -t, --retry, --rerun-each, --concurrent, --randomize/--seed, …) are forwarded to workers. The coordinator handles --bail at file granularity: once the failure threshold is reached it starts no new files, but files already running finish.
The coordinator merges coverage, JUnit XML and snapshot writes, so --parallel --coverage --reporter=junit --reporter-outfile=junit.xml produces one report.
If a worker crashes (a native addon segfaults, or a test calls process.exit), the coordinator reports the file that worker was running as failed, and a replacement worker picks up the remaining files. A crash from a fatal signal aborts the whole run, so later passing files can’t mask it.
When --parallel helps, and when it doesn’t
--parallel pays off when the suite is dominated by test execution — I/O waits, real computation, subprocesses, many files. It costs something too: every file re-evaluates its imports in a fresh global (see --isolate), and each worker is a separate process with its own JIT warm-up. For a suite of very fast files that all import the same large module graph, plain bun test (one process, one shared module registry) can be faster. Try both; Bun prints the numbers at the end of every run.
--isolate
terminal
- creates a new
globalThis(so properties a file stuck onglobalThis, patched built-ins, and module-level state are gone), - clears the ESM and CommonJS module registries (every file re-evaluates its imports),
- closes servers, sockets, file watchers and subprocesses the file left open, cancels its timers, and restores fake timers,
- re-runs
--preloadscripts in the new global.
--isolate (the default), all files share one global and one module registry. That is the fastest mode and is fine for suites whose files don’t leak state into each other.
Concurrent tests within a file
--parallel spreads files across cores. Within one file tests still run one at a time unless you opt in to concurrency, which lets async tests overlap while one is waiting on I/O:
api.test.ts
test.concurrent(...)/describe.concurrent(...)mark individual tests or whole groups.--concurrenttreats every test as concurrent;test.serialopts back out.--max-concurrency=Ncaps how many run at once (default 20).concurrentTestGlobinbunfig.tomlturns it on for matching files only.
expect.assertions() and other per-test global state need care under concurrency — see Concurrent test execution.
Splitting a suite across CI machines with --shard
terminal
--timings, file i of the sorted list goes to shard (i mod n) + 1 — balanced by file count, not by how long files take.
Balancing with --timings
File count is a poor proxy for duration: one shard can end up with all the slow integration tests. Give bun test a record of how long each file takes and it cuts shards by total time instead, keeping neighbouring files (which share imports) together:
terminal
.bun-test-timings.json
- Paths are relative to the project root; values are wall-clock milliseconds for the whole file.
- Without
--shard,--update-timingsmerges into what it read, so re-running part of the suite locally refreshes those entries and keeps the rest. Entries for files that no longer exist are left alone; delete the file to start over. - With
--shard,--update-timingswrites only the files that shard ran — see below. - Bun assumes files with no entry take the median time when cutting shards, and starts them first under
--parallel. - With
--timings,--parallelalso uses the durations: the coordinator cuts worker chunks by time and each worker starts its slowest file first.
One timings file per shard
You can pass--timings more than once. Bun reads the files as one table and skips paths that don’t exist yet. --update-timings writes to the first path. Under --shard that output contains only the files the shard ran, so the shards’ outputs are disjoint. Read together on the next run, they add up to the whole suite with no merge step. Large codebases on the main page has the full CI workflow.
How it compares
2 000 TypeScript test files × 8 small tests each, all importing a small app built onzod, date-fns and lodash, with a shared setup file (custom matcher + beforeEach/afterEach) loaded via each runner’s preload mechanism — the shape of a large application’s unit-test suite (bench/test/app, bun app/setup.ts 2000 20). 16-core Apple M4 Max:
Wall-clock,
hyperfine --warmup 1, Bun 1.4, Node.js 25.6, each runner’s stock config plus its setup-file option
(bunfig.toml test.preload, setupFilesAfterEnv, setupFiles). The generator and configs are in the repository so
you can rerun it. The ratios move with what your tests do: this suite is deliberately dominated by per-file overhead
rather than test bodies.bun test) beats sixteen isolated workers, and sixteen shared globals (--parallel --no-isolate) beat both.