Optimizing CI Pipelines with Remote Build Caching and Bazel 7.2
As monorepos scale, the bottleneck invariably shifts from code complexity to build latency. In mid-to-large scale TypeScript or Go environments, a full clean build can easily exceed 30 minutes, killing developer velocity and inflating CI costs. With the recent release of Bazel 7.2, new optimizations for persistent workers and improved Bzlmod integration provide a path to sub-five-minute builds, even for massive dependency graphs.
This post explores the implementation of remote build caching and the architectural tradeoffs of persistent workers in modern CI/CD environments.
The Problem: The Rebuild Tax
Most CI providers (GitHub Actions, GitLab CI) operate on ephemeral runners. Every job starts with a clean slate. While standard action caching (e.g., actions/cache) helps with node_modules or Go modules, it is a coarse-grained solution. If a single file changes, the entire cache entry is often invalidated or requires manual management of cache keys.
Bazel solves this by treating the build as a directed acyclic graph (DAG) of actions. Each action (compiling a file, running a test) is content-addressable. If the inputs to an action haven't changed, the output can be fetched from a cache. The challenge is making this cache available across a distributed team and CI fleet.
Implementing Remote Caching
Remote caching allows Bazel to share build outputs across different machines. When a developer builds a target locally, the result is pushed to a central store. When the CI runner encounters the same target, it downloads the artifact instead of executing the compiler.
Choosing a Backend
For production environments, you have three primary options:
- gRPC-based services: BuildBuddy or EngFlow provide high-performance caching and execution with deep observability. BuildBuddy offers an open-source core that is easy to self-host on Kubernetes.
- Cloud Storage: Using Google Cloud Storage (GCS) or AWS S3 via the HTTP/gRPC protocols. This is the simplest to set up but lacks the advanced UI for build telemetry.
- Nginx/WebDAV: A simple HTTP backend for smaller teams.
Configuration
In your .bazelrc, you can define a ci configuration that points to your remote cache. Using a gRPC backend is generally preferred for lower latency compared to raw HTTP.
# .bazelrc
build:ci --remote_cache=grpcs://remote.build-cache.com
build:ci --remote_upload_local_results=true
build:ci --google_default_credentials=true
build:ci --remote_timeout=3600
Persistent Workers and Bazel 7.2
One of the most significant performance gains in Bazel comes from Persistent Workers. Instead of spawning a new JVM or Node.js process for every compilation action—which incurs heavy startup overhead—Bazel keeps a long-running process alive and communicates with it via a thin protocol.
The Tradeoff: Determinism vs. Speed
Persistent workers can occasionally suffer from "poisoned" states if the worker process doesn't clean up memory or global variables correctly between tasks. Bazel 7.2 introduces improved sandboxing for workers, allowing for better isolation without sacrificing the speed of a warm process.
To enable workers for TypeScript (using rules_js), add the following to your configuration:
build --strategy=TypeScriptCompile=worker
build --worker_max_instances=8
In a CI environment, persistent workers are only useful if the runner is reused (e.g., self-hosted runners). On ephemeral runners, the overhead of starting the worker often outweighs the benefits for a single build.
Optimizing the CI Workflow
To maximize the efficiency of Bazel in CI, you must structure your pipeline to take advantage of the DAG. Instead of a single bazel test //... command, consider splitting the workload.
1. Selective Testing with bazel query
Use bazel query to determine exactly which targets are affected by a change. This prevents the CI from even attempting to evaluate parts of the graph that are untouched.
# Find all tests affected by changes compared to the main branch
bazel query "rdeps(//..., set($(git diff --name-only main)))" --output=label_kind | grep "_test"
2. Remote Execution (RE)
Remote Caching is the first step; Remote Execution is the endgame. With RE, the CI runner doesn't even perform the compilation. It sends the action to a cluster of powerful workers. This allows you to run hundreds of tests in parallel, far exceeding the CPU limits of a standard GitHub Actions runner.
Monitoring and Observability
A build system is only as good as its visibility. Bazel 7.2 produces a build_event_json file that can be ingested by observability tools. You should monitor:
- Cache Hit Rate: If this falls below 80%, your build actions are likely non-deterministic (e.g., embedding timestamps or absolute paths).
- Critical Path: Identify the longest chain of sequential dependencies that block the build.
- Action Overhead: The time spent by Bazel managing the graph versus the time spent by the actual compilers.
Common Pitfalls
Non-Deterministic Actions
The most common reason for cache misses is non-determinism. If your build script generates a file containing the current date or a random UUID, the cache key will change every time. Ensure all inputs are strictly defined and avoid using genrule to run arbitrary shell scripts that touch the network or system clock.
Large Artifacts
Caching large binaries or Docker layers can saturate the network bandwidth of your CI runners. Use .bazelignore to exclude heavy assets that don't need to be part of the build graph, and consider using --remote_download_toplevel to only download the final outputs of the build rather than every intermediate object file.
Conclusion
Transitioning to Bazel 7.2 with remote caching transforms CI from a bottleneck into a competitive advantage. By moving away from coarse-grained file caching to a fine-grained, content-addressable action cache, teams can maintain fast feedback loops even as the codebase grows. Start by implementing a remote cache with GCS or S3, then move toward persistent workers and remote execution as your scale demands it.