Persistent workers
Keep worker processes warm between actions to cut compile startup time on heavy toolchains.
Some toolchains have nontrivial startup cost. The JVM is the
canonical example — javac takes 300-500 ms just to come up. For
actions where the work itself takes 50 ms, that overhead dominates.
Persistent workers solve this by keeping a pool of long-lived worker processes running and routing matching actions to them. The startup cost is paid once; subsequent actions reuse the same process.
License note
Remote persistent workers are licensed under the Business Source License. Individual developer cache use does not need a commercial license. Teams using persistent workers in shared, production, or commercial settings can use NativeLink Enterprise or an intentionally very inexpensive separate license. See the license page.
When this matters
| Toolchain | Startup cost | Persistent worker win |
|---|---|---|
javac | 300-500 ms | 5-10× |
kotlinc | 1-2 s | 10-20× |
| Bazel-style worker | 100-500 ms | 3-10× |
Plain clang | ~10 ms | Negligible |
Plain rustc | ~30 ms | 2-3× |
If your build is C++ on clang, persistent workers aren't worth
configuring. If it's a JVM monorepo, they pay back almost immediately.
How NativeLink supports them
The worker config has a persistent_worker block that tells
NativeLink which toolchains to keep warm:
workers: [{
local: {
worker_api_endpoint: { uri: "grpc://scheduler:50051" },
cas_fast_slow_store: "CAS_MAIN_STORE",
upload_action_result: { upload_action_result: { ac_store: "AC_MAIN_STORE" } },
platform_properties: {
OSFamily: { values: ["linux"] },
toolchain: { values: ["jdk21"] },
},
persistent_workers: {
max_workers_per_pool: 8,
idle_timeout_s: 600,
protocols: ["proto", "json"],
},
},
}],The scheduler routes any action tagged
supports-workers=1 to a worker advertising the
persistent_workers block.
Telling Bazel to use them
In your BUILD.bazel:
java_library(
name = "my-lib",
srcs = glob(["src/main/java/**/*.java"]),
exec_properties = {
"supports-workers": "1",
"toolchain": "jdk21",
},
)Or globally via .bazelrc:
build --experimental_worker_strategy_for=Javac=remote
build --modify_execution_info=Javac=+supports-workersPool sizing
Persistent workers trade memory for latency. Each warm worker holds
the toolchain in resident memory; max_workers_per_pool sets the
ceiling.
Reasonable defaults:
- JVM toolchains: 4-8 workers per pool, 600 s idle timeout.
- Heavy native toolchains: 2-4 per pool.
- Lighter wrappers: 8-16 per pool.
idle_timeout_s is how long a worker hangs around after its last
action. Long enough to absorb think-time gaps; short enough that idle
clusters don't waste memory.
Caveats
- Persistent workers are not sandboxed by default. A misbehaving
toolchain that mutates global state between invocations will
produce nondeterministic results. The mitigation: run the worker
itself inside a sandbox (container,
bwrap,landlock), then rotate it periodically. - Memory leaks. Long-running JVMs leak. The
idle_timeout_sis a soft mitigation; for serious workloads, set a hardmax_invocationsand recycle.
FAQ
What's next
- Configuration → Production — the full worker block.
- Architecture — how the scheduler matches actions to workers.