Skip to content

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/exec and 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_test being 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:

Python
test_info = PersistentTestInfo(
    ctx = ctx,
    multiplex = False,
    requires_worker_protocol = protocol,
    worker_key_mnemonic = "CaseCheckWorker",
    worker_executable = case_checker,
    worker_tools = [tools],
    worker_args = ["--protocol=" + protocol],
    test_inputs = [first_file, second_file],
    test_args = [test_args],
)

return [
    DefaultInfo(),
    test_info,
]

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
1
2
3
4
git clone --branch persistent-test-workers \
    https://github.com/TheGrizzlyDev/bazel.git
git clone --branch persistent-test-workers \
    https://github.com/TheGrizzlyDev/rules_java.git

Build Bazel:

Bash
bazel build //src:bazel-dev
cp bazel-bin/src/bazel-dev /path/to/custom/bazel

Then run your tests with persistent test runners enabled:

Bash
1
2
3
4
5
/path/to/custom/bazel test \
    --override_module=rules_java=/path/to/rules_java \
    --enable_persistent_test_runners=JvmTestRunner \
    --strategy=TestRunner=worker,local \
    //path/to:tests

What's next

  1. Finalize the Bazel PersistentTestInfo proposal and share it with the community for feedback and design discussion.
  2. Clean up the Bazel prototype to prepare it for wider review and testing.
  3. Send the Bazel prototype for code review.
  4. 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.