Build Systems at Scale: Hermeticity, Incrementalism, and Remote Caching
Build systems are the invisible engine of modern software delivery. They turn source code, generated assets, dependencies, and configuration into testable artifacts. When they are fast and correct, teams ship confidently. When they are slow or flaky, every commit becomes a tax on engineering time. At scale, the difference between a ten-minute build and a one-hour build is not just convenience; it changes how often teams integrate, how quickly they learn, and how safely they can refactor. This article explores the architectural principles behind high-performance build systems: hermeticity, incrementalism, content-addressable caching, and remote execution. It also covers practical design patterns, correctness pitfalls, security concerns, and an adoption roadmap for teams moving from ad hoc scripts to a scalable build platform.
Why Build Systems Become a Bottleneck
Most projects start with a simple script: install dependencies, compile, run tests. That works until the codebase grows, the number of contributors increases, and the artifact matrix explodes across operating systems, architectures, and configurations. The symptoms are familiar.
- Long feedback loops: Developers wait minutes or hours for CI, context-switch, and lose focus.
- Flaky builds: Non-deterministic outputs, hidden environment dependencies, and race conditions make failures hard to reproduce.
- Cache misses: CI rebuilds everything because the cache key is too broad or the environment is not isolated.
- Duplicated work: The same compilation and test actions run across branches, teams, and CI workers.
- Supply chain blind spots: Without hermetic inputs, it is difficult to prove what went into a binary.
A modern build system addresses these problems by treating builds as a graph of pure functions over immutable inputs. That mental model is the foundation for everything else.
The Core Properties of a Modern Build System
1. Target Graph and Dependency DAG
Build definitions declare targets such as libraries, binaries, tests, and generated files. Each target lists its inputs and dependencies. The build system resolves these declarations into a directed acyclic graph, usually called the action graph or dependency DAG. Correct graph construction enables parallelism, precise invalidation, and distributed scheduling. If the graph is too coarse, everything rebuilds together. If it is too fine, graph overhead dominates. The art is finding the right granularity for the language and workflow.
2. Hermeticity and Reproducibility
A hermetic build action runs in an isolated environment with all inputs explicitly declared. It cannot read arbitrary files from the host, depend on network access, or inherit unknown environment variables. Hermeticity makes builds reproducible: the same inputs produce the same outputs, bit for bit, or at least semantically identical artifacts. Reproducibility is not academic. It enables reliable caching, meaningful remote execution, auditability, and faster debugging. Without hermeticity, cache keys are guesses and remote workers become unpredictable.
3. Incremental Builds and Change Detection
Incremental builds avoid redoing work when inputs have not changed. The build system hashes the relevant inputs: source files, compiler flags, environment variables, toolchain versions, and dependency outputs. If the hash matches a previous execution, the cached result is reused. This is the heart of build acceleration. However, incremental correctness depends entirely on capturing every input that can affect the output. Miss one environment variable or generated file, and the cache can serve a stale artifact.
4. Content-Addressable Storage
Instead of keying outputs by file path or timestamp, content-addressable storage keys them by a cryptographic hash of their contents. The build system stores action results in a CAS (content-addressable storage) and action metadata in an action cache. When an action is requested, the system hashes the action description and looks up the result. If found, it downloads the outputs from CAS. This design allows sharing across branches, developers, and CI machines without worrying about file names or directory layouts.
5. Remote Execution and Caching
Remote execution moves build actions to a pool of workers. A scheduler matches actions to workers based on platform properties such as CPU architecture, operating system, and available memory. Remote caching stores action results centrally. Together, they turn a build farm into a shared, elastic compute layer. The Remote Execution API and similar protocols standardize how clients submit actions and retrieve outputs. The payoff is massive: a build that once took an hour locally can complete in minutes if the cache is warm and the graph is parallel.
How a Build Actually Executes
Understanding the execution pipeline helps when debugging cache misses and slow builds. A typical modern build system follows these stages.
- Graph construction: The system loads build files, evaluates rules, and resolves dependencies into an action graph.
- Input hashing: For each action, it computes a digest of all declared inputs, including command lines and environment variables.
- Cache lookup: It queries the local or remote action cache for a matching digest. A hit skips execution and fetches outputs.
- Scheduling: Cache misses are queued. The scheduler respects dependencies and platform constraints.
- Sandboxed execution: The action runs in an isolated sandbox with only declared inputs mounted.
- Output capture: The system records exit codes, stdout, stderr, and output files. Outputs are uploaded to CAS.
- Result publication: The action result is written to the action cache for future requests.
Each stage is an opportunity for optimization. Graph construction can be parallelized. Hashing can use fast content digests. Scheduling can prioritize the critical path. Sandboxing can use containers, namespaces, or virtual machines depending on the isolation requirements.
Tooling Landscape: Choosing the Right Foundation
There is no single perfect build system. The right choice depends on language ecosystem, repository structure, team culture, and scale.
- Bazel: A polyglot, hermetic build system with strong remote execution and caching support. It excels in large monorepos but has a steep learning curve.
- Buck2: A successor to Buck, designed for high performance and extensibility. It uses a Rust core and supports remote execution.
- Pants: Focuses on developer ergonomics and Python, Go, JVM, and shell support. It emphasizes fine-grained dependency inference.
- Nx: Popular in JavaScript and TypeScript monorepos. It offers computation caching, affected commands, and plugin-based task orchestration.
- Turborepo: A lightweight monorepo tool for JavaScript and TypeScript with remote caching and pipeline definitions.
- Gradle: A mature JVM build system with incremental build features, build cache, and configuration cache.
- Make and Ninja: Lower-level tools that excel when paired with a generator. Ninja is often used as a fast execution backend for larger systems.
Many teams combine tools: a high-level build system for orchestration and a lower-level executor for speed. The key is to avoid hidden state and to make dependencies explicit.
Designing a Monorepo for Fast Builds
Monorepos amplify both the benefits and the challenges of build systems. A well-structured monorepo enables atomic changes across projects, consistent tooling, and shared caching. A poorly structured one creates tangled dependencies and slow CI.
- Granular targets: Break code into small libraries with clear public interfaces. Avoid giant catch-all targets that force unnecessary rebuilds.
- Explicit dependencies: Every dependency should be declared. Implicit imports from the filesystem or environment break hermeticity.
- Visibility rules: Restrict which targets can depend on others. This prevents accidental coupling and keeps the graph clean.
- Code generation as targets: Treat generated code as first-class outputs with declared inputs. This makes generation cacheable and reproducible.
- Test sharding: Split large test suites into shards that can run in parallel. Pair sharding with deterministic ordering to avoid flaky failures.
- Standardized toolchains: Pin compiler, linker, and runtime versions. Toolchain drift is a common source of cache misses.
These practices reduce the blast radius of changes. A developer editing one library should not trigger a rebuild of the entire repository.
Remote Caching and Remote Execution in Practice
Remote caching is often the first step because it delivers immediate value with less operational complexity. Remote execution is more powerful but requires a worker fleet, sandboxing, and careful resource management.
- Action Cache: Maps action digests to metadata about outputs. It answers the question: has this exact action run before?
- Content-Addressable Storage: Stores the actual output files. It is immutable and can be replicated across regions.
- Platform properties: Describe worker requirements, such as OS, CPU architecture, and container image. The scheduler uses them for matching.
- Cache eviction: Use LRU or time-based policies. Cache size should be monitored; stale entries waste storage, but aggressive eviction lowers hit rates.
- Network locality: Place cache and workers close to developers and CI. High latency can erase the gains from parallel execution.
- Security: Treat the cache as a shared artifact store. Encrypt in transit and at rest. Isolate untrusted actions and scan outputs where necessary.
A common pattern is a two-tier cache: a local on-disk cache for fast repeated builds and a remote cache for sharing across machines. Local cache hits avoid network round trips; remote hits avoid rebuilding from scratch.
Incremental Correctness: Where Builds Go Wrong
Incremental builds are only as correct as their input tracking. The following pitfalls cause false cache hits and stale artifacts.
- Non-hermetic tools: Compilers that read environment variables, home directories, or system clocks can produce different outputs for the same declared inputs.
- Timestamps in outputs: Embedding build timestamps or random seeds breaks reproducibility and cache sharing.
- Implicit filesystem reads: Reading files that are not declared as inputs means the build system cannot hash them.
- Network access: Downloading dependencies during compilation makes builds non-deterministic and slow. Fetch dependencies as explicit actions instead.
- Dynamic dependencies: Languages that discover dependencies at runtime, such as Python imports, require careful analysis or explicit declarations.
- Environment leakage: Variables like PATH, HOME, and locale settings can change behavior. Pin them in the action environment.
- Order-dependent outputs: Parallel actions that write to the same directory can race. Use isolated output directories per action.
Fixing these issues often requires changes to build rules, toolchain wrappers, and code generation. The investment pays off in reliable caching and easier debugging.
CI/CD Patterns for High-Throughput Teams
CI is where build systems face their toughest test: many commits, many branches, and limited time. These patterns help keep CI fast and reliable.
- Affected target detection: Compute which targets changed and run only their tests and dependents. This is essential in monorepos.
- Merge queues: Serialize merges to the main branch and run builds on the merged result. This catches integration failures before they reach developers.
- Cache warming: Pre-populate the remote cache with common build actions from the main branch. New branches then start with a warm cache.
- Distributed builds: Use remote execution to fan out compilation and test actions across many workers.
- Test result caching: Cache test results when inputs and environment are identical. Be careful with flaky tests and external dependencies.
- Artifact promotion: Build once, then promote the same artifact through environments. Avoid rebuilding for staging and production.
These patterns reduce redundant work and shorten the critical path. They also make CI costs more predictable because cache hits avoid expensive compute.
Observability for Build Systems
You cannot optimize what you do not measure. Build observability focuses on a few key metrics.
- Build duration: Track p50, p95, and p99 for local and CI builds. Break it down by phase: graph loading, cache lookup, execution, upload.
- Cache hit rate: Measure local and remote hit rates separately. A low remote hit rate may indicate non-hermetic actions or poor cache key design.
- Critical path: Identify the longest dependency chain in the action graph. Optimizing off-path actions does not reduce wall-clock time.
- Action duration: Find slow actions and hot spots. Some actions may benefit from better parallelism or caching.
- Flaky tests: Track tests that pass and fail without code changes. Quarantine them and fix root causes.
- Resource utilization: Monitor worker CPU, memory, and network. Oversubscription causes thrashing; undersubscription wastes capacity.
Build telemetry should be available to developers, not just platform teams. When engineers can see why their build is slow, they can make better decisions about target granularity and dependencies.
Security and Supply Chain
Build systems are part of the software supply chain. A compromised build can inject malicious code into every artifact. Hermeticity and isolation are security controls, not just performance features.
- Isolation: Run actions in sandboxes with least privilege. Prevent network access unless explicitly allowed.
- Provenance: Record who built what, from which inputs, and with which toolchain. This supports SLSA and similar frameworks.
- SBOM generation: Produce a software bill of materials as part of the build. Store it alongside the artifact.
- Secret management: Never bake secrets into build actions. Use short-lived credentials and external secret stores.
- Dependency pinning: Lock dependency versions and verify checksums. Fetch from trusted mirrors.
- Cache integrity: Sign or verify cache entries where possible. Treat remote caches as untrusted unless authenticated.
Security and speed can coexist. In fact, hermetic builds are often faster because they avoid unpredictable network calls and environment drift.
Adoption Roadmap
Migrating to a modern build system is a journey, not a flip of a switch. A pragmatic roadmap reduces risk and builds momentum.
- Measure the baseline: Record build times, cache hit rates, flaky test rates, and CI costs. You need a baseline to prove improvement.
- Start with a pilot: Choose a small but representative project. Define a few targets and prove the workflow end to end.
- Invest in hermeticity: Pin toolchains, declare dependencies, and remove network access from build actions.
- Enable remote caching: This is usually the fastest win. Monitor hit rates and fix the top cache misses.
- Refine target granularity: Break large targets into smaller ones. Add visibility rules to prevent dependency creep.
- Adopt remote execution: Once caching is stable, add workers for parallel and distributed builds.
- Make it the default: Integrate with CI, IDE, and code review. Provide templates and documentation.
- Iterate on observability: Use build telemetry to find new bottlenecks and regressions.
Expect resistance when builds change. Clear communication, quick wins, and developer-facing metrics help teams see the value.
A Minimal Bazel-like Example
The exact syntax varies by tool, but the concepts are consistent. A target declares its name, sources, and dependencies.
load('@rules_cc//cc:defs.bzl', 'cc_binary')\ncc_binary(\n name = 'server',\n srcs = ['server.cc'],\n deps = [':lib'],\n)\n
This small declaration tells the build system how to compile the binary and what it depends on. From this, the system can hash inputs, schedule compilation, and cache results. Multiply this across thousands of targets, and you have a build graph that can be optimized and distributed.
Conclusion
Build systems at scale are not just build scripts. They are platforms for reproducible computation. Hermeticity ensures correctness. Incrementalism and content-addressable caching eliminate redundant work. Remote execution turns a local bottleneck into elastic capacity. Together, these properties allow teams to ship faster without sacrificing confidence. The journey requires investment in tooling, culture, and observability, but the payoff is a development loop that keeps up with the pace of modern software. Start with hermetic actions, measure cache hit rates, and let the build graph guide your optimization. The result is a build system that fades into the background, exactly where it belongs.

