Build Caching Explained: Incremental and Remote
Every build cache answers one question: has this exact piece of work already been done somewhere? When the answer is right, the tool skips the work and restores the result. When it is wrong, one of two things happens. Either the tool redoes work it could have reused, which is slow, or it restores a result that no longer matches the source, which is much worse. Understanding how that question gets answered is the difference between a cache you trust and one you keep clearing.
Incremental builds: skipping what you just did
The simplest form of caching is the incremental build. The tool remembers what it did last time in this workspace and skips any step whose inputs have not changed.
Gradle's incremental build docs describe the mechanism clearly. Before a task runs for the first time, Gradle takes a fingerprint of its inputs: the paths of the input files and a hash of the contents of each one. On the next run it takes a new fingerprint, and if it matches the previous one, Gradle assumes the outputs are up to date, skips the task and labels it UP-TO-DATE in the console.
Two details in that description matter everywhere, not just in Gradle:
- It hashes contents, not timestamps. A tool that compares modification times will rebuild a file you merely touched, saved without changes, or checked out again. A tool that compares content hashes will not.
- A task needs declared outputs. Gradle notes that incremental build will not work unless a task has at least one output. If the tool cannot see what a step produces, it cannot know whether that product is still valid.
The limit of an incremental build is its memory. It only knows about the previous build in the same workspace. Switch to another branch and back, or open a second checkout of the same repository, and it may redo work that was already done a few minutes earlier.
Cache keys: reusing work from any earlier build
A build cache removes that limit. Gradle's build cache documentation explains that it uses the same logic as the up-to-date check, but instead of being limited to the previous build in the same workspace, it can reuse task outputs from any earlier build in any location on the machine.
To do that, the tool computes a cache key: a hash of everything that could change the output. If two runs produce the same key, they are treated as the same work. The documented ingredients are instructive:
- Gradle includes the task type and its classpath, the names of the output properties, the names and values of the input properties, the classpath of the Gradle distribution,
buildSrcand plugins, and the build script content when it affects the task. - Nx says in its caching docs that a task hash can include project source files and files from project dependencies, relevant workspace configuration, versions of external dependencies, runtime values such as the operating system and CPU architecture, and command-line arguments.
- Turborepo computes two hashes, a global hash and a task hash, and its caching guide says that a change to either one causes a cache miss. Lockfile changes, configuration changes and the values of environment variables listed in
globalEnvall feed the global hash.
Notice what the lists have in common. The key covers the source, but also the tools, the configuration, the environment and the arguments. Anything that can change the output and is missing from the key is a future stale result.
When the key matches, the tool restores what it stored. Turborepo caches the files listed in a task's outputs and always captures the terminal output so it can replay the logs. Nx restores the cached output files and prints the stored terminal output. In Gradle, a task restored this way is labeled FROM-CACHE.
Local and remote caches
A local cache lives on your own disk. Turborepo keeps its results in .turbo/cache. Gradle's local build cache is a directory, used once you turn the build cache on with --build-cache or org.gradle.caching=true:
# gradle.properties
org.gradle.caching=true
Bazel can keep a disk cache that is shared across branches and workspaces on the same machine.
A remote cache is shared. Bazel's remote caching docs describe it as something a team of developers or a continuous integration system uses to share build outputs. It holds two things: an action cache, which maps action hashes to result metadata, and a content-addressable store of the output files themselves. You point Bazel at one with a flag, and it accepts HTTP, HTTPS, gRPC and gRPCs endpoints:
# .bazelrc
build --disk_cache=/path/to/bazel-disk-cache
build --remote_cache=grpcs://cache.example.internal
The lookup order is similar across tools. Gradle tries the local cache first and the remote cache second, and anything found remotely is also stored locally so the next lookup is faster. Nx checks the local cache and then the remote cache, if one is configured.
Who should write to the shared cache
Reading from a remote cache is low risk. Writing to it is where trouble starts. Bazel's docs warn that when an input file is modified during a build, Bazel might upload invalid results to the remote cache. On a developer machine, that happens easily: an editor saves a file while a build is running, and the result that gets stored no longer matches its key.
Gradle's defaults reflect this. Its local build cache has pushing enabled, while the remote cache has pushing disabled until you turn it on. A common arrangement is to let only clean, automated builds write to the shared cache, while developer machines only read from it.
When a remote cache does not help
A network round trip is not free. Turborepo's guide points out that caching can be slower than running the task when the task finishes faster than a round trip to the remote cache, or when its output is enormous. Cache the expensive steps; let the trivial ones just run.
Hermetic inputs: the cache is only as good as its key
Bazel defines a hermetic build as one that, given the same source code and configuration, always returns the same output, by isolating the build from changes to the host system. Its hermeticity page lists the usual ways builds fall short:
- tools or actions that create files non-deterministically, usually by embedding build IDs or timestamps
- system binaries that differ between hosts, such as programs in
/usr/bin, absolute paths and system compilers - writing to the source tree during the build
Each one breaks caching differently. A timestamp embedded in an output means two identical builds never produce identical files, so anything downstream always misses. A compiler taken from the host means two machines with different compiler versions share one key, so one of them gets the other's output. A build that writes into the source tree changes its own inputs.
Environment variables deserve special attention because they are invisible. Bazel only includes the variables you explicitly pass through with --action_env in an action's definition. Turborepo hashes the variables named in its configuration. If a build reads an API URL, a feature switch or a locale from the environment without declaring it, the cache cannot tell two different builds apart.
Common cache invalidation mistakes
Most cache problems come from a short list of causes:
- Undeclared inputs, which cause false hits. A config file outside the package, an unlisted environment variable or a tool version the key does not include. The build restores an old result that is simply wrong. This is the dangerous one.
- Undeclared outputs, which cause no reuse or missing files. Gradle's incremental build does not work for a task with no declared outputs, so that task runs every time and nobody notices because nothing breaks. Turborepo will not cache file outputs you do not declare, so a cache hit replays the logs but does not restore the files, and a later step fails because they are not there.
- Over-broad inputs, which cause constant misses. A timestamp, a build number, an absolute path or a generated file in the inputs changes the key on every run.
- Changing files mid-build. The stored result no longer matches its key, which is why shared caches should be written only by builds nobody is editing.
When a cache misbehaves, resist the urge to wipe it. Compare instead. Turborepo's --dry flag shows what would run without running it, and --summarize writes a summary of each task's inputs and outputs, so comparing two summaries shows why two hashes differ. If you suspect a false hit, turbo run build --force ignores existing cached artifacts for that run.
The most useful habit is to treat every unexplained miss or suspicious hit as a missing entry in the key, then find that entry and declare it. A cache that is cleared every time it acts up never gets fixed; a cache whose keys are complete can be trusted with every build.