Bazel Remote Caching: Cutting CI Build Times Without Remote Execution
Remote caching shares Bazel action outputs across CI agents without remote execution infrastructure. A practical guide to enabling it, measuring hit rates, and avoiding cache poisoning.
09 Jan 2026, 15:41 UTC

The Problem: Repeated Work Across CI Agents
Every pull request triggers a fresh Bazel build on a clean CI agent. Even when only one library changes, the entire dependency graph re-executes because each agent starts with an empty local cache. For a medium-sized monorepo, this means 10–20 minutes of redundant compilation and test runs per PR. Remote execution (RBE) solves this, but it requires a dedicated cluster, gRPC infrastructure, and operational overhead many teams aren't ready for.
Remote caching offers a lighter alternative: share action outputs across machines without running the actions remotely. The first CI run populates the cache; subsequent builds pull artifacts instead of rebuilding. The catch is that cache hits only materialize when builds are hermetic and the cache backend is configured correctly.
How Remote Caching Works
Bazel computes a content-addressable hash for every action (compile, link, test) based on its inputs: source files, compiler flags, toolchain configuration, and declared environment variables. That hash becomes the key in a remote blob store. When an action's key matches an existing entry, Bazel downloads the output artifacts instead of executing locally.
The cache backend can be any HTTP/1.1 or gRPC endpoint that implements the Remote Execution API v2. Common choices:
- Cloud object storage: Google Cloud Storage, Amazon S3, Azure Blob Storage via the built-in HTTP cache client.
- Self-hosted gRPC servers: bazel-remote, EngFlow, or BuildBuddy.
No remote workers are involved. The local machine still runs actions on cache miss; it only uploads results on success.
Enabling Cache Read/Write in .bazelrc
Add the following to your repository's .bazelrc (checked into version control) so every developer and CI job uses the same endpoint:
# Remote cache endpoint (HTTP for GCS/S3, gRPC for self-hosted)
build --remote_cache=https://storage.googleapis.com/my-bazel-cache
# Upload local action results after a successful build
build --remote_upload_local_results=true
# Optional: separate instance name for multi-tenant servers
build --remote_instance_name=my-project
# Optional: timeout for cache operations
build --remote_timeout=30s
Run bazel build //... --remote_cache=https://storage.googleapis.com/my-bazel-cache once to verify connectivity. The first run will be a cold build (all actions executed). The second run on the same or another machine should show cache hits for unchanged actions.
Worked Example: CI Pipeline with Cache Warm-Up
Consider a GitHub Actions workflow for a Java/Kotlin monorepo. The cache lives in a GCS bucket with a lifecycle rule deleting objects older than 30 days.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@v2
- name: Build with remote cache
run: |
bazel build //... \
--remote_cache=https://storage.googleapis.com/${{ secrets.GCS_BUCKET }}\
--remote_upload_local_results=true
- name: Test with remote cache
run: |
bazel test //... \
--remote_cache=https://storage.googleapis.com/${{ secrets.GCS_BUCKET }}\
--remote_upload_local_results=true
On the first PR, the cache is empty—every action executes and uploads. On the second PR (even from a different fork, if the bucket is shared), Bazel downloads compiled .jar files and test outputs for any library whose sources haven't changed. In practice, a 12-minute cold build drops to 3–4 minutes when only application code changes.
Verify the hit rate with bazel build //... --profile=profile.gz followed by bazel analyze-profile profile.gz. Look for "remote cache hit" versus "local execution" counts in the action summary.
Trade-offs and Limitations
Hermeticity Is Non-Negotiable
If a genrule embeds $(date) or reads $HOME/.config, its action hash stays constant while outputs differ. Bazel will incorrectly reuse stale artifacts. Run bazel build //... --experimental_check_output_files to detect undeclared outputs, and audit rules for local = True or missing tools attributes.
Network Latency vs. Recomputation
For tiny actions (e.g., a 50 ms protocol buffer compilation), the round-trip to GCS (30–80 ms) can exceed local execution time. Bazel's --remote_cache_compression (default: ZSTD) helps, but consider disabling cache for specific targets via tags = ["no-remote-cache"] in BUILD files.
Cache Poisoning and Version Skew
Non-deterministic compilers (timestamps in binaries) or mixed Bazel versions (7.x vs 8.x) produce different action hashes for the same source, fragmenting the cache. Pin Bazel with .bazelversion and enforce --stamp=yes only where intentional.
Egress Costs and Retention
A 50 GB cache with daily 10 GB uploads adds up on cloud egress. Set bucket lifecycle policies (delete after 14–30 days) and monitor bazel info used-heap-size to avoid OOM during large uploads.
Actionable Next Steps
- Provision a GCS/S3 bucket or spin up
bazel-remoteon a small VM (2 vCPU, 4 GB RAM, 100 GB SSD). - Add the
.bazelrcsnippet above to your repo; gate it behind a--config=ciflag if you want local developers to opt in. - Run two consecutive CI builds on a feature branch. Compare
bazel analyze-profileoutput: expect >60% cache hit rate for unchanged libraries. - Audit the top 10 slowest actions in the profile. If they're small and cache-missing, add
tags = ["no-remote-cache"]to their rules.
Remote caching won't replace remote execution for massive scale, but it delivers 50–70% CI time reduction with a fraction of the operational cost. Start with a shared bucket, measure hit rates, and iterate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.