Persistent Test Runners: How Persistent Workers for Tests Can Cut Test Times by 46%¶
This post is based on the BazelCon 2026 talk I gave with Antonio Di Stefano (Frekt.dev). You can watch the recording on YouTube below.
Bazel has used persistent workers for years to make compilation faster. A persistent test runner applies the same idea to tests: keep a long-running test execution process alive and reuse it across multiple tests, instead of paying its warmup cost every time.
Why persistent test runners?¶
Every test run in Bazel pays some overhead before the first assertion runs:
- Process startup, like
fork/execand JVM initialization. - Heavyweight fixtures, like external databases.
- Downloads, like container image pulls.
A persistent test runner can remove much of this:
- No repeated process startup. The runner can keep JVMs, emulators, and simulators alive across tests, so they're initialized once.
- Faster test cycles. Shorter feedback loops mean developers spend less time waiting.
- Warm JIT caches for JVM tests. A long-running JVM process keeps its JIT compilation state, so target code doesn't start cold on every run.
We've been here before¶
There was a previous attempt at this for java_test only: the
--experimental_persistent_test_runner flag was added on December 6, 2019, and
rolled back the same day. It was re-added on January 11, 2020, and the test
runner code followed on January 14, 2020. Both the flag and the test runner
were removed on March 21, 2022.
The original implementation was functional, but suffered from critical bugs in real-world use, all due to the difficulty of reusing a JVM for Java tests:
- Classpath handling. It didn't handle classpath changes well during incremental builds. Stale classes and leaked state corrupted test runs.
- Tight coupling. It relied heavily on
java_testbeing a native rule, with the Java test runner embedded inside Bazel, which blocked extensibility. - Shared dependencies. The test runner and the code under test shared common dependencies, like Guava, which made separating them in the runner difficult.
- JVM state purging. Clearing out JVM state between test runs is extremely hard without complex classloader isolation.
The previous approach wouldn't work well today: after
Starlarkification
of rules, java_test is just another Starlark rule outside of Bazel, and can't
be (and shouldn't be) handled separately from other test rules.
A new approach¶
Our new approach addresses this directly: a new API in Bazel for tests to
declare that they can use a persistent runner, and changes to java_test in
rules_java to use this feature.
Existing Bazel APIs¶
Today, Bazel has two separate APIs that each cover half of the problem.
The test API expects a test rule to return an executable that takes no
flags and carries all of its own configuration. Bazel sets up the environment
according to the Test
Encyclopedia,
including environment variables for sharding, test filtering, and the temporary
directory. The exit code determines pass or fail, stdout becomes the test log,
and the test can either write its own test.xml or let Bazel generate one from
the log.
The worker API creates actions with ctx.actions.run and execution
requirements that tell Bazel the action supports persistent mode, which worker
protocol it speaks (protobuf or JSON), and whether it supports multiplexing.
Bazel decides whether an action actually runs persistently, so a worker must
support both single-shot and persistent execution, receiving its arguments
through a param file in both cases.
The new PersistentTestInfo API¶
Our proposal is a new API for persistent tests that joins the two, designed to
fit into Bazel's modern execution model. A new PersistentTestInfo provider
describes how to run a test persistently. It carries the same kind of execution
requirements as ctx.actions.run, along with the action data that a worker
would otherwise receive.
Here's how a test rule's implementation returns it:
This is a fairly clear mix of the test and worker APIs: the new
PersistentTestInfo has fields that declare the worker protocol, executable,
tools, and arguments, but has separate fields for the test-specific inputs and
arguments.
Using this, Bazel can create a test action that works with any execution strategy: when run standalone the runner is invoked directly with the test inputs and arguments and exits immediately, just like before. But when executed with the worker strategy, the runner starts persistently and then waits for test work requests to be sent, and sends responses as necessary.
This makes the worker more complex. It's now responsible for:
- Handling test environment setup (such as temp directories).
- Correctly communicating the test exit condition and any logs.
- Cleanup and isolation between test runs.
Changes in rules_java¶
The changes to rules_java to use
the new PersistentTestInfo API are complicated, and will change slightly as we
evolve the APIs during the design review. Look out for a future discussion of
those changes from Antonio Di Stefano, who led the investigation into how to
make that work (and into the many dead ends he discussed in our talk).
Results: 1.9x faster on Bazel's own tests¶
Using Antonio's java_test branch, we measured Bazel's own Java tests,
comparing the local strategy against the persistent worker. Across 46 Bazel
Java test targets, with 4 runs each, the total of median runtimes dropped from
36.5 minutes to 19.6 minutes: a 46% reduction in test execution time, or a 1.9x
speedup.
35 of the 46 targets got faster. The 11 that got slower are all short tests, under 14 seconds each. Many are serialization tests that scan the classpath, which we expect to be slower in a long-lived JVM.
Try it out yourself¶
Experimental
This feature is under active development. Don't use it in production yet. The APIs will certainly change before and during the design discussion, so don't rely on the current implementation.
Clone the prototype branches of Bazel and rules_java:
| Bash | |
|---|---|
Build Bazel:
Then run your tests with persistent test runners enabled:
| Bash | |
|---|---|
What's next¶
- Finalize the Bazel
PersistentTestInfoproposal and share it with the community for feedback and design discussion. - Clean up the Bazel prototype to prepare it for wider review and testing.
- Send the Bazel prototype for code review.
- Update test rules to adopt the new persistent testing functionality, including other JVM languages, iOS and Android tests, and large integration tests that use test containers.
We'd love to hear how persistent test runners work on your codebase. Try the prototype and share your feedback.