Incremental builds: only changed targets

Incremental builds with only changed targets rebuild exactly and only the build targets that depend on files that have changed since the last build.

L3 · SYSTEMATICWhat this level takes
MUSTNot met, not at this level
  • Incremental builds run only changed targets (not full rebuild)
  • Remote execution (EngFlow or equivalent) distributes build steps across multiple machines
  • The main codebase uses a build tool with a dependency graph and a shared cache (Bazel, Buck2, or Pants)
SHOULDExpected in practice, not required
  • BUILD file maintenance is assigned to specific team members or automated
  • Remote cache hit rate exceeds 80%
EVIDENCEHow you would check
  • Bazel/Buck2/Pants BUILD files in repository
  • Remote execution configuration (EngFlow, BuildBuddy, or equivalent)
  • Build log showing incremental target selection
DEPENDS ON
  • Infrastructure L2 (Build System) - basic caching and parallelization must be in place before advanced build system adoption

What It Is

Incremental builds with only changed targets rebuild exactly and only the build targets that depend on files that have changed since the last build. This is fundamentally different from module-level incremental compilation in Maven or Gradle, which recompiles an entire module when any file within it changes. True target-level incrementality requires a precise, complete dependency graph - exactly what Bazel BUILD files and Buck2 BUCK files provide.

The mechanism is straightforward in concept: the build system maintains a hash of every input to every build action. When a source file changes, its hash changes. Every target that directly or transitively depends on that file has an input hash change, and must be rebuilt. Every target that doesn't depend on the changed file has an identical input hash to the previous build, and can be retrieved from cache. With Bazel, this computation happens before any compilation starts - the build system knows exactly which targets will be cache hits and which need to be rebuilt.

For a monorepo with 10,000 build targets, a change to a leaf utility function might affect 50 targets directly. A change to a widely-used base library might affect 5,000 targets. The build system computes the affected set precisely and rebuilds only that set - without redundant work and without missing anything. This precision is what makes large-codebase builds practical for agents doing iterative work.

The practical impact is dramatic. In a well-structured codebase with fine-grained targets, a one-line change in a leaf module rebuilds in under 5 seconds regardless of how large the overall codebase is. The build system doesn't care that there are 500,000 lines of code it doesn't need to touch - it skips them all. This property is what makes AI agent iteration loops viable at large codebase scale: the agent's build time is proportional to what changed, not to the total codebase size.

Why It Matters

  • Build time is proportional to change scope, not codebase size - a one-line change always takes the same time to build regardless of whether the codebase has 10,000 or 10,000,000 lines
  • Agent iteration loops hit only the relevant build work - an agent fixing a bug in one module doesn't wait for unrelated modules to compile; the build system proves they're unaffected and skips them
  • Developers can work confidently without full rebuilds - when the build system guarantees correct incrementality, there's no reason to run clean; developers and agents trust incremental build results
  • Large monorepos become viable for agent workflows - without target-level incrementality, monorepos are impractical for agent iteration; with it, a 10-million-line monorepo has the same iteration speed as a 100,000-line service
  • Cache hit rates are objectively measurable and improvable - Bazel reports exact cache hit and miss statistics; teams can track and optimize their cache effectiveness systematically

Getting Started

  1. Understand your current dependency graph shape - Use bazel query 'deps(//..., 1)' to see the immediate dependencies of all targets, and bazel query 'rdeps(//..., //path/to:target)' to see what depends on a specific target. Identify "heavy" targets that have many dependents - changes to these will trigger large rebuilds.
  2. Split large targets into smaller, more focused targets - A single BUILD target with 50 source files means a change to any of those 50 files triggers rebuilding everything that depends on the target. Split it into smaller targets with clear interfaces. More targets means finer-grained incrementality.
  3. Measure your actual affected-target set for common change types - Run bazel query 'rdeps(//..., //path/to:changed_file)' for your most common change types: fixing a bug in a specific service, updating a shared utility, modifying a proto definition. The output tells you exactly how many targets will rebuild for each change type.
  4. Identify and isolate wide-impact targets - Proto files, shared configuration, and base utility libraries often affect hundreds or thousands of downstream targets. Small changes to these files trigger large rebuilds. Isolate these targets by minimizing their interfaces and splitting stable from volatile parts.
  5. Enforce target granularity standards - Add a BUILD file linting rule that flags targets with more than N source files (N=10 is a reasonable starting point). Large targets are an incrementality anti-pattern. Make fine-grained targets the norm in your BUILD file review process.
  6. Configure affected-target-only test runs in CI - Use bazel test $(bazel query 'rdeps(//..., set(//path/to:changed_targets))') to run only tests for targets that depend on what changed. This is the CI equivalent of incremental builds: test only what could have been broken by the current change.
TIP

Run bazel build //... --explain=/tmp/explain.log --verbose_explanations to get a detailed log of why each target was or wasn't rebuilt. This log is invaluable for debugging unexpected cache misses or overly broad rebuilds. Look for "Output //path/to:target is stale" entries that describe the specific input that changed.

Common Pitfalls

Undeclared file dependencies that force cache misses. If a BUILD target reads a file at build time without declaring it as a dependency, Bazel doesn't know to invalidate the cache when that file changes. The result is either a stale cache hit (incorrect build) or constant cache misses (Bazel detects the undeclared input via sandboxing). Fix undeclared dependencies by declaring them explicitly in BUILD files.

Proto file changes cascading across the entire codebase. Proto files often have hundreds of downstream dependents. A proto change can trigger rebuilds of thousands of targets. Mitigate this by stabilizing proto interfaces, using proto extension fields for evolution, and structuring proto dependencies to minimize the fan-out from any single proto file change.

Overly coarse BUILD target granularity. Teams migrating from Maven/Gradle often create one BUILD target per Maven module - which can have hundreds of source files. This replicates Maven's module-level incrementality rather than achieving target-level incrementality. Break down BUILD targets to be as fine-grained as practical: one target per logical component, typically 5-20 source files.

Not using the incremental test runner. Getting incremental builds without incremental test runs leaves productivity on the table. Bazel supports test caching: if a test and all its dependencies haven't changed, the previous test result is used. Enable test caching with --cache_test_results=yes and verify tests are being cached with --test_summary=detailed.

Modifying widely-used headers or interfaces unnecessarily. In C++ and Rust, changes to header files or trait definitions cascade to all dependents. Developers and agents working on a widely-used interface need to be aware that their change scope is much larger than what they directly modified. Make this visible with bazel query output in code review.

How Different Roles See It

BobHEAD OF ENGINEERING

Bob's team completed the Bazel migration 3 months ago and is running with a remote cache. He's looking at the metrics: average agent incremental build time is 8 seconds, but there are outliers at 2-3 minutes. These outliers happen when agents change shared proto files or base utility libraries. Bob wants to understand the pattern and reduce the outlier frequency.

What Bob should do: Bob should have his infrastructure team run a "change type vs. rebuild scope" analysis: for each type of file that agents commonly change (service code, shared libraries, proto files, test utilities), measure the average number of targets affected and the average build time. This analysis will identify the high-impact change types. For those types - especially proto files - Bob should fund targeted refactoring work to reduce the downstream dependency fan-out. Splitting a large proto file into smaller domain-specific protos can reduce a "3-minute outlier build" to a "15-second normal build" by reducing the affected target set from 2,000 to 50.

SarahPRODUCTIVITY LEAD

Sarah has added "agent build time by change type" to her DevEx dashboard. She can see that 90% of agent builds complete under 15 seconds, but 10% take over 90 seconds. She wants to reduce the 10% tail. The data shows the long-tail builds are consistently triggered by changes to 12 specific "high-fan-out" targets.

What Sarah should do: Sarah should prioritize refactoring those 12 high-fan-out targets as a DevEx investment. Each one is a hot spot: agents that touch these targets pay a disproportionate build time penalty. She should work with the owners of those targets to reduce their dependency surface. Techniques include: splitting large targets into smaller ones, moving stable code to a separate target that changes less frequently, using interface abstractions to decouple implementations from interfaces. Sarah should track "number of high-fan-out targets with >100 dependents" as a metric and drive it to zero over two quarters.

VictorSTAFF ENGINEER - AI CHAMPION

Victor has set up automated affected-target analysis as part of his agent workflow. Before submitting any agent-generated branch to CI, a pre-CI step runs bazel query 'rdeps(//..., //path/to:changed_targets)' and reports the affected target count. If it's over 500, the agent adds a comment to the PR explaining the wide impact and requests human review before CI runs. This prevents agents from accidentally submitting wide-impact changes that saturate CI.

What Victor should do: Victor should contribute this affected-target analysis tool as a team standard. It should run automatically as a GitHub Actions check on every PR - both human and agent-generated. PRs that affect over 500 targets get a "wide impact" label and a comment explaining what they're changing. This creates organizational awareness of change scope and makes wide-impact refactoring a deliberate decision rather than an accident. Victor should also explore whether the 500-target threshold is calibrated correctly for the team's CI capacity, and adjust it based on observed CI queue behavior.

Where does your team actually sit on this?

This guide describes one level of one area. Run the assessment to place your team across all 16 areas, see which gates you have passed, and get a report you can take to your stakeholders.

Start the assessment