26 August 2026
Making Bun and Cloudflare work together in tests
How we bridged Bun, Wrangler, Miniflare, and workerd, then turned fragile compatibility fixes into a pooled and isolated test harness.
The first version of bun-test-cloudflare looked like it should be small.
Wrangler already exposed a test harness. Bun already had a fast test runner. The obvious job was to put a typed wrapper around Wrangler, start a Worker, hand it to a test, and close it afterwards.
We were moving our JavaScript projects to Bun and wanted Cloudflare tests to use the same tooling. Wrangler, Miniflare, and workerd would still run the Worker environment.
The wrapper worked for a single request. Running a whole suite exposed the problems: parallel startup, leftover state, background failures, and processes that would not exit.
Cloudflare tests cross four independently evolving systems—Bun, Wrangler, Miniflare, and workerd. Each one can be correct in isolation while the seam between two of them still leaks a process, changes a stream contract, or waits forever for a message that already arrived.
The failures were rarely in application code
Our early regression fixtures covered failures such as:
- parallel server startup could hang on workerd's control pipe;
- runtime shutdown could race an in-flight request;
- Browser Rendering sessions could keep processes alive;
- Miniflare's synchronous proxy could receive worker-thread responses out of order;
- live
ReadableStreambodies could not cross that worker-thread bridge directly; - different Undici versions exposed different module and cloning behaviour;
- Wrangler's format probe could leave a shared esbuild service in a bad state after a timeout;
- workerd children could retain stdio handles or crash during teardown.
None of these is solved by adding another afterAll(() => server.close()). Cleanup has an order, and the order matters.
The harness now drains tracked platform-proxy operations, closes active Browser Rendering sessions, drains again, flushes Worker logs, stops the log stream, closes Wrangler, disposes captured Miniflare runtimes, and only then removes temporary persistence. A failed waitUntil() is treated as a test failure rather than disappearing during shutdown.
Compatibility code needs an expiry date
The first compatibility layer patched whatever Wrangler and Miniflare expected but Bun did not yet provide in the same shape: Web Streams internals, ws behaviour, worker-thread messaging, workerd child processes, Miniflare globals, Cache API access, and a minimal cloudflare:workers module for ordinary Bun imports.
We later gave each patch a name and a check for whether it was still needed.
Each patch can be regression-tested and disabled independently. When Bun 1.4 fixed stream constructors, WebSocket behaviour, extra child-process file descriptors, parts of Undici interop, and several Miniflare paths, the corresponding patches stopped installing automatically.
Those checks let us retire patches as Bun fixes the underlying issues.
Build once, then get out of Wrangler's way
Starting the test server was not the only repeated cost. Wrangler also bundled the same Worker again for each harness run.
The current preparation path reads the project's real Wrangler configuration, injects process.env.NODE_ENV = "test", and runs wrangler deploy --dry-run once. The result is stored under node_modules/.btcf/worker-build, then supplied to the test harness with no_bundle: true.
Parallel Bun test processes coordinate that build through a process-owned lock and a shared status file. A stale lock can be identified and removed; a failed build is serialised so every waiter receives the actual error instead of timing out with a secondary symptom.
This made startup both faster and more deterministic. OpenNext applications also use their existing generated entrypoint and asset layout instead of inventing a separate test build.
A warm Worker is useful only if its state is not
Prewarming workerd removed a large amount of latency, but reusing a runtime introduced a harder requirement: every test still needed a clean world.
The harness therefore leases a prepared run rather than sharing one freely:
In the pooled path, releasing a run checks its logs and schedules a reset before it can be leased again. The serial path resets when the next lease is acquired. Both prepare the run before the next test uses it.
To share one workerd startup across several tests, we added isolated Worker slots. Reset advances to an unused slot until all slots have been used, then starts a fresh persistence generation. Each slot has distinct Worker names and namespaced storage identities. Service and Durable Object bindings point at the matching slot, while Cache API requests are namespaced through a small Worker-side bridge.
The harness currently creates four slots when every configured binding has a deterministic isolation strategy. When Wrangler emits an unsupported binding kind, it falls back to the single-slot reset path. Explicitly requesting multiple slots with an unsupported binding throws rather than pretending isolation exists.
We keep the slower path for bindings we cannot yet isolate.
The Cache API needed request-scoped context
Worker handles expose bindings, but test helpers also need to use caches.default and named caches without passing a server object through every function.
The solution is an AsyncLocalStorage bridge. During harness.run(), global cache calls resolve against the active Worker's Cache API. With isolated slots, those operations travel through an authenticated internal endpoint on the active slot and receive the same namespace treatment as Worker-side calls.
The same idea powers the public run context: deeply nested fixture helpers can access the active typed workers and server, but a call made outside the lease fails immediately. Global-looking ergonomics are kept inside an explicit async boundary.
Regression fixtures became executable documentation
The repository eventually accumulated fixtures for Browser Rendering, Images, Wasm, OpenNext rendering, multiple Undici versions, parallel build ownership, runtime-close races, worker crashes, storage reset, and nearly every binding emitted by Wrangler's config converter.
A mocked fetch() would miss these failures. The fixtures need to start the processes, use the storage, and shut everything down.
When Wrangler adds a binding type, the catalogue test fails until its isolation behaviour is classified. When Bun removes the need for a patch, the patch-selection tests make that change explicit. When a process once hung, its minimal reproduction stays in the suite.
Keeping up with upstream changes
We cannot prevent Bun or Wrangler upgrades from breaking the harness. We can make those breaks easier to locate. Patches are version-gated, new binding types require an isolation decision, and build caches include the effective Worker configuration.
The setup for a project remains small: preload the harness, describe its typed Workers, and run a callback. Most of our work now sits underneath that API, in build coordination, storage reset, and cleanup.
We started with a wrapper around Wrangler. Getting the suite to finish reliably took much more work than getting the first test to pass.