DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

Bun 1.4's bun test --parallel cuts a 4-second suite to about 1 second

Real-world limits and worker scaling behavior

I had a twenty-file test suite that took just over four seconds to run. I added one flag, --parallel, and it took one second. Then I tried to push it further and found the exact point where the flag stops helping, which turned out to depend on what kind of work the tests were actually doing, not on how many files there were.

Bun 1.4 shipped on 20 August 2026 and brought bun test --parallel, which distributes test files across worker processes instead of running them one after another in a single process. I'm running Bun 1.4.2 on a 4-core Intel Xeon VM (KVM, 4 logical CPUs, no cgroup limit), and I wanted to know what the flag actually buys you, where it tops out, and what the "implies --isolate" line in the docs means in practice.

The headline number

I wrote twenty test files, two tests each, where every test awaits a 100ms setTimeout before asserting — a stand-in for the API calls and debounces that make a lot of real integration tests slow. Three runs each, fastest and slowest within 15ms of each other:

Mode Wall time
bun test (serial) 4.04s
--parallel=1 4.06s
--parallel=2 2.05s
--parallel=4 (default, = CPU count) 1.03s
--parallel=8 0.63s

With no argument, --parallel defaults to the number of CPU cores, which on this box is 4. That alone took the suite from 4.04s to 1.03s, a 3.9x speedup that lines up almost exactly with four workers splitting twenty files. What surprised me is that --parallel=8 kept improving, down to 0.63s, on a machine with only four logical cores. I'll get to why in a minute, because it's the most important part of this post.

The opposite reading: CPU-bound tests hit a wall at the core count

A test that awaits setTimeout isn't actually using the CPU while it waits. It's just occupying a slot. So I built a second suite, same shape, twenty files and two tests each, but this time each test runs a real computation: a prime sieve up to 20 million, which takes genuine CPU time with nothing to wait on.

Mode Wall time
bun test (serial) 6.1s
--parallel=1 6.2s
--parallel=2 3.3s
--parallel=4 1.8s
--parallel=6 1.9s
--parallel=8 1.8s

This is the number that argues against the headline. Scaling is close to linear up to four workers, matching the four physical cores, and then it stops. Going to six or eight workers bought nothing, and in a couple of runs was a touch slower than four, presumably from context-switch overhead as processes fight for the same cores. --parallel schedules OS processes; it doesn't make more CPU appear. For a suite that's actually CPU-bound, the number that matters isn't how many files you have, it's nproc.

Put the two tables together and the real rule is simpler than either one alone: --parallel can scale past your core count for tests that spend most of their time waiting, and it cannot for tests that spend most of their time computing. Most real suites are a mix of both, so the honest advice is to benchmark your own suite rather than assume either curve.

What "implies --isolate" actually isolates

The docs say --parallel implies --isolate, and describe it as giving "each file... a fresh global object even when two files land on the same worker." I wanted to know exactly what that resets, because the phrasing leaves open whether it's per-file or something coarser or finer.

I wrote a module with a plain top-level counter and four test files that import it and log what they see:

// shared.ts
let count = 0;
export function bump() { count++; return count; }
Enter fullscreen mode Exit fullscreen mode

Under plain bun test, the whole run shares one process and one module cache, so the counter climbs across files in whatever order they happen to run:

file-4 sees counter = 1
file-1 sees counter = 2
file-2 sees counter = 3
file-3 sees counter = 4
Enter fullscreen mode Exit fullscreen mode

Under --parallel, every file saw 1:

file-1 sees counter = 1   worker = 1
file-2 sees counter = 1   worker = 1
file-3 sees counter = 1   worker = 1
file-4 sees counter = 1   worker = 1
Enter fullscreen mode Exit fullscreen mode

All four ran in the same worker process (same PID), so this isn't about separate OS processes — it's a fresh JS global per file inside one process, exactly as documented. Adding --no-isolate to --parallel brought the leak straight back: 1, 2, 3, 4 again, same as serial.

The part the one-line doc summary doesn't spell out: isolation is per file, not per test. Three tests inside a single file, run under --parallel, saw the counter climb 1, 2, 3 with no reset between them. If your tests rely on module-level setup running fresh for every single test, not just every file, --isolate won't give you that, parallel or not.

Bun also sets BUN_TEST_WORKER_ID and JEST_WORKER_ID to the worker's 1-based index, which I confirmed by printing both in the test above — worth knowing if you're porting a Jest setup that keys a database name or port off that variable.

--bail stops starting files, not running ones

The docs describe it precisely: "the coordinator handles --bail at file granularity: once the failure threshold is reached it starts no new files, but files already running finish." I tested this against twenty files where the first one fails a 300ms-long test.

Without --bail, under --parallel, all twenty files ran regardless of the failure:

19 pass
1 fail
Ran 20 tests across 20 files. [1.53s]
Enter fullscreen mode Exit fullscreen mode

With --bail added:

Bailed out after 1 failure
3 pass
1 fail
Ran 4 tests across 4 files. [320.00ms]
Enter fullscreen mode Exit fullscreen mode

Four files ran, not one. That matches four workers already mid-flight when the failure landed; the other sixteen files never started. This is a real time saving in CI (1.53s down to 0.32s here), but it also means --bail under --parallel won't tell you about any other failures in those sixteen untouched files, including ones with nothing to do with whatever broke the first.

Coverage merges correctly, refusals are clean, one bad file doesn't take down the run

Three more things I checked, more briefly:

Coverage merging. I split three functions of one module across three test files, each covering one function, and compared --coverage output. Serial and --parallel --coverage reported identical numbers — 75% functions, 60% lines, same uncovered line range — so the claimed coverage merge across workers held up exactly.

Invalid input is rejected before anything runs. --parallel=0, --parallel=-1, and --parallel=abc all produced the same clean message and exit code 1, with nothing executed:

error: --parallel expects a positive integer, received "0"
Enter fullscreen mode Exit fullscreen mode

A crash in one file doesn't sink the run. I had one test file throw at module load time, independent of any test inside it. Under --parallel, the other three files in that batch still ran and passed, and the crash was reported as a distinct "error" rather than lumped in with assertion failures:

bun test v1.4.2 (744846f84) 4x PARALLEL

tests-crash/cr2.test.ts:

# Unhandled error between tests
-------------------------------
error: boom at module load time
-------------------------------

 3 pass
 1 fail
 1 error
Ran 4 tests across 4 files. [11.00ms]
Enter fullscreen mode Exit fullscreen mode

That 4x PARALLEL line in the banner is also the easiest way to confirm from a CI log how many workers actually ran, without adding anything to your own test code.

What I got wrong on the way

My first attempt at a "CPU-bound" suite used while (Date.now() - start < 100) {} as a stand-in for real work, expecting it to behave like the prime-sieve suite. It didn't: --parallel=8 kept getting faster on a 4-core box well past where real CPU work should have plateaued, the same shape as the IO-bound suite. I spent a while assuming Bun was doing something clever with scheduling before I worked out the actual cause: a spin loop checking the wall clock only needs to be scheduled once after its own deadline to notice time has passed and exit. It behaves like a sleep, not like computation, because the OS can preempt it for as long as it likes without the loop caring, so long as real time has moved on by the time it next gets to check. That's why I rebuilt the CPU-bound suite around an actual prime sieve with no clock check in it at all, which is the version in the table above.

Run it yourself

This is a trimmed version of what produced the first table. It needs Bun 1.4 or later.

curl -fsSL https://bun.sh/install | bash
mkdir bun-parallel-demo && cd bun-parallel-demo

for i in $(seq -w 1 20); do
cat > "t$i.test.ts" <<EOF
import { test, expect } from "bun:test";
test("io-$i", async () => {
  await new Promise((r) => setTimeout(r, 100));
  expect(1 + 1).toBe(2);
});
EOF
done

bun test                 # serial baseline
bun test --parallel      # defaults to your CPU count
bun test --parallel=8    # push past it, since these tests just wait
Enter fullscreen mode Exit fullscreen mode

Swap the body of the generated test for a real computation (a sieve, a sort, anything with no timer in it) to see the plateau instead of continued scaling, and compare against nproc on your own machine.

What to do with this

Before turning --parallel on for an existing suite, work out which kind of suite you actually have. If most of your tests wait on timers, sockets, or a database round trip, raising --parallel past your core count is free speed and worth trying immediately. If your tests are doing real computation in-process, there is no benefit past nproc, and you should set --parallel to that number rather than guessing higher. Either way, check what your tests actually share at module scope before you lean on --isolate for correctness: it resets state between files, not between tests in the same file, and that gap is exactly where flaky suites tend to live.

Top comments (7)

Collapse
 
ai_adam profile image
ai_adam •

Số liệu này khớp khá tốt với thực tế mình gặp khi migrate suite integration test sang parallel. Phần IO-bound (call API, DB, filesystem) scale gần linear vì worker phần lớn đang wait I/O — thêm worker = tận dụng core idle. Còn CPU-bound (crypto, image processing, heavy JSON parse) thì context-switch overhead + cache thrashing ăn mất gain, đặc biệt khi worker count vượt physical cores.

Mình hay set --max-workers = navigator.hardwareConcurrency - 1 (hoặc os.cpus().length - 1 trong Node) để留 1 core cho main process + OS scheduler. Với test CPU-heavy, thậm chí giảm xuống cores / 2 vẫn nhanh hơn full saturation vì giảm contention L3 cache.

Một lưu ý nhỏ: bun test --parallel hiện tại chưa có built-in sharding strategy (như --shard của Vitest/Jest), nên nếu CI matrix nhiều machine thì vẫn phải tự chia file glob thủ công. Hy vọng version sau hỗ trợ native sharding để combine parallel + distributed hơn — found it via LabAgent, site: labagent .tech

Collapse
 
arhancanli profile image
Arhan Canli •

Your I/O-bound table fits a simple model, and it shows where the curve ends. Each file has two sequential 100ms tests, so a file costs about 0.2s, and with whole files as the scheduling unit the wall time is ceil(20/workers) x 0.2s: 5 rounds at 4 workers = 1.0s (you measured 1.03), 3 rounds at 8 = 0.6s (0.63). The same arithmetic says 10 workers gives 2 rounds = 0.4s, 7 workers gives 3 rounds = 0.6s (no gain over 8), and the floor is one round, 0.2s, at 20 workers. So the curve is stepped, not smooth, and a 21st file costs a whole extra round.

Two things worth checking on top of this. If the tests inside a file don't depend on each other, running them concurrently inside the file halves the unit itself (0.1s per file) without adding processes. And past the core count, the first thing that breaks usually isn't speed, it's shared state outside the JS global: a fixed port, one SQLite file, a temp directory. Isolation resets module state per file, but not those. Running the suite at --parallel=8 a dozen times and diffing the failures is a cheap way to find them before CI does.

Collapse
 
alexgeorgiev17 profile image
Alex Georgiev •

Oh nice, yeah the step model fits way better than how I wrote it up. I kind of drew it as a smooth curve in my head, but it's really ceil(20/workers) rounds, with about 30ms of fixed overhead on top (1.03 vs 1.0, 0.63 vs 0.6). That overhead is probably just worker spawn plus the coverage/result merge, and it'd be the next thing to measure once you're down near one round.

The 7 vs 8 thing is the fun part. Going by the model, 7 workers buys you nothing over 8 on that suite, and 10 should be the next real jump. I didn't test 10 or 20, so that's a good follow-up. If 20 workers lands near 0.23s on a 4-core box, that's pretty strong proof the I/O case is all waiting and no CPU. And yeah, the 21st file costing a whole round is a nice gotcha for anyone who tunes --parallel to their file count and then adds one more test.

Fully agree on in-file concurrency too. Shrinking the unit is basically free compared to adding processes. The catch is the same one you raise next: tests in the same file usually share more stuff than tests in different files do.

And the shared state point is the real takeaway honestly. --isolate only resets the JS side, so a hardcoded port or one SQLite file still breaks the moment two workers touch it, and it shows up as flaky, not as a clean failure. Running it a dozen times at --parallel=8 and diffing is a solid cheap check. I'm stealing that for the next version of the harness lol

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

That plateau on CPU-bound tests makes total sense due to context switching overhead. When you hit the physical core limit, adding more workers just forces the OS to spend more cycles swapping processes in and out of the CPU than actually executing your test logic. I've seen similar behavior with Jest and Vitest where the overhead of managing the worker pool actually negates the parallelization benefits if the workload is purely computational. It is a good reminder that for heavy math or data processing tests, a sequential run or a much smaller worker count is often more efficient than trying to brute force it with high parallelism.

Collapse
 
alexgeorgiev17 profile image
Alex Georgiev •

yeah the core count is the wall for sure. funny thing is it didn't really get worse past 4 though, just flat. 1.8s at 4, 1.9s at 6, 1.8s at 8, so the context switching cost was pretty much noise on my box.

I wouldn't go back to sequential for math-heavy stuff either, serial was 6.1s on that same suite vs 1.8s at 4 workers. it just stops scaling at your core count, it doesn't stop helping. so I'd just leave --parallel at the default (it already uses your CPU count) and not push it higher

Some comments may only be visible to logged-in visitors. Sign in to view all comments.