The bug that ends a team’s trust in build caching always looks the same. Someone changes a shared constant, CI goes green in a fraction of the usual time, the artifact ships, and production behaves as though the change never happened. It did not happen. The build system looked at a cache key that did not include the file that changed, found a hit, and handed back last week’s output.
Nobody debugs that quickly, because the whole team’s instinct is to look at the application code. The build is assumed correct. It takes hours before someone says “wait, did that actually compile?” and then a --no-cache run proves it did not.
That failure is the reason to read a caching tool’s key computation before its marketing. Every product in this category promises faster builds. What separates them is whether the key is derived from a complete, honest description of the inputs, and what each one does when it cannot be honest.
The second thing worth internalising before you pick anything: a remote cache is a network call. You are trading local compute for a round trip plus a transfer. For a task that takes a long time and produces a small artifact, that trade is overwhelmingly good. For a task that takes almost no time and produces a large artifact, it is a loss, and a cache configured without that distinction makes some builds slower while the dashboard says hit rate is high.
Key takeaways
- A cache key must cover the full transitive input closure: source files, dependency versions, toolchain version, relevant environment variables, and the command line itself. Anything omitted is a source of silent false hits.
- A false hit ships the wrong artifact and is nearly undebuggable. A false miss just costs time. Tune your configuration so mistakes land on the miss side.
- Remote caching pays off in proportion to task duration divided by artifact size. Short tasks with fat outputs should stay local.
- Cache hit rate on its own is a vanity metric. The number that matters is wall-clock time to a green pipeline, measured on real branches, not on a repeated run of the same commit.
What a cache key actually is
A build cache is a content-addressed key-value store. The value is the output of a task: compiled objects, a bundled JavaScript file, a test result, a container layer. The key is a hash that is supposed to mean “this exact work, with these exact inputs.”
Getting the key right means hashing the complete transitive closure of everything that can change the output. In practice that is five categories.
Source inputs. The files the task reads. Not the files in the directory, the files the task actually reads, which is a different and harder thing to know. Tools that hash a whole package directory are over-approximating: they will miss when a README changes, which is a wasted rebuild but safe. Tools that hash a declared list are under-approximating unless the declaration is enforced.
Dependency outputs. In a monorepo, a task’s inputs include the outputs of everything it depends on. This is why the key has to be computed bottom up through the graph. Change a leaf library and every key above it in the graph changes, which is correct and is also why a one-line change in a shared package can legitimately invalidate almost everything.
The toolchain. Compiler version, runtime version, the version of the build tool itself, and the flags passed to all of them. Two machines running different minor versions of a compiler produce different bytes. If the toolchain is not in the key, the cache will happily hand output from one to the other.
Environment. Environment variables that the task reads, the target platform, the architecture, and sometimes the operating system. This is the category most often under-declared, because environment variables are read implicitly by libraries deep in the stack rather than explicitly by the task.
The command. The literal task definition. Change a flag in a script and the output changes, so the script has to be in the key.
The interesting property is that this is all or nothing. A key that covers four of the five categories is not eighty percent correct. It is a key that is correct until the day someone changes the thing in the fifth category, at which point it is silently wrong.
Hermeticity is the property underneath all of this
A task is hermetic if, given the same declared inputs, it produces the same outputs, on any machine, at any time. Hermeticity is what makes a cache key meaningful, and almost nothing is hermetic by default.
The common leaks: a build that embeds a timestamp or a git SHA into the artifact, a test that reads the system clock or the network, a code generator that iterates a hash map in unspecified order, a compiler that records absolute paths in debug info, a script that reads an environment variable nobody declared.
Tools take two positions on this. Bazel and Pants enforce hermeticity by executing tasks in a sandbox where undeclared inputs are not present, so a task that reads something it did not declare fails loudly instead of caching wrongly. Turborepo, Nx and Gradle trust your declaration and give you tools to widen it, which is far cheaper to adopt and puts the correctness burden on you.
Neither position is wrong. The enforcement approach costs weeks of migration and gives you a cache you can trust without auditing. The trust approach costs an afternoon and gives you a cache that is correct as long as someone keeps the input declarations honest. What is wrong is adopting the second and believing you got the first.
Why a wrong key is worse than no cache
Cache failures come in two shapes and they are not symmetric.
A false miss is a key that changes when the output would not have. You rebuild something you did not need to. The cost is time and CI minutes, it is visible, and it is measurable. Over-approximating inputs, for example hashing an entire package directory rather than the files a task reads, produces false misses. That is the safe direction to be wrong in.
A false hit is a key that stays the same when the output would have changed. You get back an artifact that does not correspond to the current source. The cost is a wrong binary in a pipeline that reported success, and the debugging cost is brutal because every normal instinct points at the application code rather than the build.
False hits have a nasty second property: they are usually not reproducible on the machine of the person investigating, because that person’s local cache has a different state. “Works on my machine” becomes literally true and completely unhelpful.
The practical consequences for how you configure these tools:
Declare too much rather than too little. If you are unsure whether an environment variable affects a task, put it in the key. The cost is extra rebuilds when it changes.
Put the toolchain in the key explicitly. Do not assume the tool does it. Several of them derive it from a lockfile, which works until someone uses a different runtime version locally.
Never let a cache write happen from an unverified machine. The standard configuration is that CI writes to the remote cache and developer machines only read. A developer with a dirty working tree and write access can poison the cache for everyone, and the poisoned entry will look exactly like a valid one.
Keep a scheduled no-cache build. A nightly or weekly full build from an empty cache, compared against the cached result, is the only mechanism that catches a false hit before a human does. It costs one pipeline run and it is the difference between finding a key bug in a scheduled job and finding it in production.
Needs first-hand data: Run your full pipeline twice on the same commit, once with caching disabled entirely and once warm, and diff the produced artifacts byte for byte. Any difference is either a hermeticity leak or a false hit. Record which tasks differ and what made them differ, because that list is your actual correctness backlog.
Remote cache latency versus the work it saves
A local cache is a disk read. A remote cache is a DNS lookup, a TLS handshake if the connection is cold, a metadata request to ask whether the key exists, and a download of the artifact. That total is small, but it is not zero, and it is paid on every task, including the ones that miss.
The break-even is simple to reason about even though the numbers are yours to measure. A remote cache hit saves you the task’s execution time and costs you the round trip plus the transfer time for the artifact. So the tasks that benefit are the ones where execution time is large relative to artifact size divided by available bandwidth. Long compile steps producing modest object files are the ideal case. A bundling step that takes seconds and produces a large directory of assets is the opposite case, and caching it remotely can be a net loss.
Four factors change that calculation, and all four are worth checking in a product evaluation.
Where the cache lives relative to the runner. A remote cache in the same region as your CI runners behaves completely differently from one across an ocean. Providers that colocate cache storage with compute have a structural advantage here that no amount of client-side cleverness matches. If your cache and your runners are in different clouds, you are also paying egress.
Whether misses are cheap. A well-designed client asks about many keys in one batched metadata request before downloading anything, so a build where everything misses costs one round trip rather than one per task. A client that checks keys serially turns a cold cache into a slow build. This is the single biggest client-side implementation difference between products.
Compression and transfer. Artifacts compress well, and whether the tool compresses before upload changes transfer time materially. Some tools also deduplicate at a sub-artifact level so an unchanged file inside a changed output does not move.
The cold-runner problem. Ephemeral CI runners start with nothing. Every build is a cold cache locally, which means the remote cache carries all of the benefit and the network path is on the critical path of every single task. This is why remote caching and runner choice are one decision rather than two, and it is covered further in the CI runner and compute provider comparison.
There is also a failure mode worth designing for in advance: the cache being down. A build tool that treats a cache timeout as a hard error will take your entire pipeline out when a storage backend has a bad afternoon. Check the timeout behaviour and set it so that an unreachable cache degrades into a slow build rather than a failed one.
Needs first-hand data: Instrument your ten longest tasks and record, per task, execution time when cold, artifact size, and remote fetch time from your CI region. Any task where fetch time approaches execution time should be excluded from remote caching. Publish that table, because it is the artifact that makes the configuration defensible to whoever inherits it.
Turborepo

Turborepo is the pragmatic default for JavaScript and TypeScript monorepos. It reads a task graph from a config file, hashes inputs per package, and stores outputs either locally or in a remote cache. The design priority is obvious from the adoption cost: you can add it to an existing repo in an afternoon without restructuring anything, and it gets you most of the benefit of a real build system for none of the migration.
Pros
- Adoption is genuinely incremental, so an existing npm or pnpm workspace gets caching without changing its layout or its scripts
- Hashes the package directory plus the lockfile by default, which over-approximates and therefore errs toward false misses rather than false hits
- The remote cache protocol is simple enough that several third parties implement it, so you are not locked to one hosted backend
- Task graph and cache behaviour are described in one config file that a new engineer can read in a sitting
Cons
- Input declaration is trust-based with no sandbox, so a task that reads an undeclared file will cache wrongly and nothing will tell you
- Package-level granularity means a change to any file in a package invalidates every task in that package, which gets coarse in large packages
- Environment variable handling has historically been a source of surprise, and getting it right requires declaring them deliberately rather than relying on defaults
Best for: JavaScript and TypeScript monorepos that want caching this week without committing to a build system migration.
Pricing: Open source and free to run with a local cache or a self-hosted remote cache. The first-party hosted remote cache is bundled into a platform subscription, metered on cache storage and transfer.
Nx Cloud

Nx Cloud is the hosted cache and task execution layer for Nx. Beyond storing outputs it can distribute tasks across multiple agents and reassemble the results, which makes it a different category of product from a plain artifact store. Its computed cache key is finer grained than Turborepo’s because Nx builds a project graph from actual import statements rather than package boundaries.
Pros
- Cache key derived from a parsed dependency graph rather than directory contents, so it invalidates less than a package-level hash would
- Distributed task execution means a cold cache on a large graph can still finish quickly by fanning out rather than by reusing
- Replays the full task log on a cache hit, so a cached task looks identical to an executed one in CI output, which matters for debugging
- Detects and reports when a task writes outputs it did not declare, which catches a class of key bug before it becomes a false hit
Cons
- The value proposition is strongest inside the Nx ecosystem, so adopting it usually means adopting Nx plugins and executors rather than keeping plain scripts
- Distributed execution introduces an orchestration layer that becomes a dependency of every CI run, with its own failure mode
- Pricing is metered on cached task executions, which grows with team activity rather than with repo size, so cost rises exactly when the tool is working
Best for: Teams already standardised on Nx whose graph is large enough that distributing work matters as much as reusing it.
Pricing: Metered on the number of cached task executions per month with a usage-based tier structure, plus enterprise options for self-hosting the cache backend.
Bazel

Bazel takes the opposite position from everything else here: tasks run in a sandbox containing only their declared inputs, so undeclared reads fail rather than silently poisoning a key. That enforcement is what makes a Bazel remote cache trustworthy across machines and platforms, and it is also what makes adopting Bazel a project rather than a configuration change.
Pros
- Sandboxed execution turns hermeticity from a convention into an enforced property, which is the only way to actually trust a shared cache
- The remote cache and remote execution protocols are open, so multiple independent backends implement them and you can move between them
- Action-level granularity means a single changed file invalidates the specific compile actions that read it rather than a whole package
- Cross-language by design, so a repo mixing several languages gets one cache and one graph rather than one per ecosystem
Cons
- Migration cost is the highest in this category by a wide margin, because every build step has to be expressed in Starlark with explicit inputs and outputs
- Ecosystem rules for fast-moving language ecosystems, particularly JavaScript, lag the native tooling and require ongoing maintenance
- The learning curve is steep enough that Bazel expertise becomes a staffing dependency, and losing the person who owns it is a real risk
Best for: Large polyglot repositories where cache correctness across machines is a hard requirement and there is a platform team to own the build.
Pricing: Open source with no vendor and no bill for the build tool itself. Remote cache and remote execution backends are either self-hosted infrastructure you pay for directly or a commercial service metered on storage and compute.
Gradle
The Gradle build cache is the mature option in the JVM world and its design is worth studying regardless of your stack. Tasks declare inputs and outputs as typed properties, Gradle hashes them, and cached outputs can be shared locally or through a remote node. Its build scan tooling will tell you exactly why a task missed the cache, which is rarer and more useful than it sounds.
Pros
- Per-task input and output declarations are part of the task API, so cacheability is expressed in types rather than in a config file bolted on afterwards
- Build scans identify the specific input that changed and caused a miss, which turns cache debugging from guesswork into reading a diff
- Handles the incremental compilation case well, so a small source change recompiles a small set of classes rather than a module
- Long track record on large JVM codebases, including the awkward parts like annotation processors and code generation
Cons
- Scoped to the JVM ecosystem, so a repo with significant JavaScript or native code needs a second system alongside it
- Custom tasks and third-party plugins are frequently not cacheable out of the box, and making them cacheable is real work you inherit
- Configuration cache, build cache and incremental compilation are three separate mechanisms whose interactions confuse people and produce misleading conclusions about what is actually being reused
Best for: JVM codebases where module count is high enough that full rebuilds hurt and the team can invest in making custom tasks cacheable.
Pricing: The build tool and the local and remote cache implementations are open source with no licence cost. The commercial developer productivity platform that provides hosted build scans and a managed remote cache node is sold as a subscription.
sccache
sccache is a compiler cache in the tradition of ccache, wrapping the compiler invocation, hashing the preprocessed source plus the compiler and flags, and storing the object file. It supports several storage backends including object storage and Redis, which is what makes it usable from ephemeral CI runners. It solves one layer of the problem, and solves it without asking you to restructure anything.
Pros
- Drops in as a compiler wrapper, so adopting it requires no change to the build system or the project layout
- Backend-agnostic storage means the cache can live in object storage you already run, in your own region, with no new vendor
- Covers native and Rust compilation, which is exactly the slow part that JavaScript-oriented tools do not touch
- Hashes the preprocessed source, so a change to a comment in a header does not invalidate every translation unit that includes it
Cons
- Caches compilation only, so link steps, test runs, packaging and everything else in your pipeline get no benefit
- No task graph and no awareness of project structure, which means it cannot skip work, only make repeated work cheaper
- Correctness depends on the compiler and flags being fully captured, and unusual toolchain setups or compiler plugins can produce results nobody expects
Best for: Rust and C or C++ builds where compilation dominates wall-clock time and a full build system migration is not on the table.
Pricing: Open source with no vendor and no bill. Your only cost is the storage backend you point it at and the traffic to reach it.
Depot

Depot approaches caching from the infrastructure side rather than the build tool side. Rather than asking your build tool to store artifacts somewhere, it provides compute with persistent cache volumes attached, so the layer cache or build cache survives between runs on the same machine class. That changes the problem from “download the cache” to “the cache is already on the local disk.”
Pros
- Persistent local cache volumes eliminate the network round trip entirely for the common case, which sidesteps the latency-versus-work tradeoff rather than optimising it
- Container image builds get a real layer cache across runs, which is the single largest source of repeated work in most pipelines
- Native architecture builds rather than emulation, which matters enormously for multi-architecture images where emulated builds dominate pipeline time
- Works as a drop-in behind existing build commands, so adoption does not require rewriting pipeline definitions
Cons
- It is a hosted compute provider, so adopting it means moving that part of your pipeline to a third party with the data residency and access questions that implies
- Cache locality is tied to the machine pool, so the benefit depends on your builds landing on warm machines rather than on a globally addressable store
- Narrower than a general build system: it accelerates the build you have, it does not give you a task graph or let you skip unchanged work
Best for: Teams whose pipeline time is dominated by container image builds or native compilation and who would rather buy faster infrastructure than adopt a build system.
Pricing: Usage-based on build compute minutes by machine size, with cache storage included in the plan rather than metered separately.
GitHub Actions

The GitHub Actions cache is the baseline nearly everyone starts with, and understanding its limits explains why the rest of this category exists. It is a key-value store of tar archives, scoped per repository, where you write the key expression yourself, usually by hashing a lockfile. It is not a build cache in the sense the other tools mean. It is a directory cache with manual keys.
Pros
- Already available in any workflow with no vendor decision, no account and no additional configuration beyond a single step
- Restore keys give you a useful partial-hit mechanism, so a changed lockfile can still start from the previous dependency tree rather than from nothing
- Scoped and access-controlled along the same lines as the repository, so it inherits permissions you have already reasoned about
- Perfectly adequate for the dominant use case of caching a dependency directory keyed on a lockfile hash
Cons
- The key is a string you write, which means correctness is entirely your problem and a key that omits an input produces silent false hits with no warning
- Branch scoping rules mean a feature branch can read the default branch cache but not the reverse, so cache behaviour differs between a pull request and a merge in ways that surprise people
- Eviction is size-bounded per repository and least-recently-used, so a busy repo evicts entries you were counting on and hit rate quietly degrades over time
- No task graph, so it can make a step start faster but can never skip a step that does not need to run
Best for: Any repository that needs dependency directories cached and does not yet have a build system worth wiring a remote cache into.
Pricing: Included with the platform within a per-repository storage allowance, with no separate meter for cache reads or writes. The constraint is the storage ceiling and its eviction behaviour rather than a bill.
How to choose
Start by finding out where the time actually goes. Break your pipeline into steps and record wall-clock time per step across a week of real builds, not a single rerun. Most teams discover that two or three steps account for the majority of the time and that the rest is not worth optimising. If dependency installation dominates, you need a directory cache and nothing more. If compilation dominates, you need a compiler cache or a real build system. If container image building dominates, you need layer caching and faster machines.
Then decide whether you need correctness enforcement or convenience. This is the fork in the category. If a false hit would be catastrophic, if you ship compiled artifacts to customers, or if you have enough contributors that nobody can police input declarations, the sandboxed approach earns its migration cost. If your team is small enough that a cache bug would be noticed and fixed in an afternoon, take the convenient tool and put the effort into a scheduled no-cache verification build instead.
Match the cache location to the runner location. A remote cache that is not in the same region as your runners is paying latency and egress on every task. This is a bigger factor than most client-side optimisations and it is often the cheapest thing to fix.
Make CI the only writer. Read-only credentials for developer machines, write access from verified CI jobs only. This single policy removes the most common route to a poisoned cache.
| Tool | Key computed from | Correctness model | Scope |
|---|---|---|---|
| Turborepo | Package contents plus lockfile | Trust your declaration | JavaScript and TypeScript tasks |
| Nx Cloud | Parsed project graph | Trust, with undeclared-output detection | Nx task graph, plus distribution |
| Bazel | Declared action inputs | Sandbox enforcement | Any language, whole build |
| Gradle | Typed task input properties | Trust, with scan-based diagnosis | JVM tasks |
| sccache | Preprocessed source plus flags | Compiler-level determinism | Compilation only |
| Depot | Build tool native, on persistent disk | Inherits the build tool you use | Container images and compilation |
| GitHub Actions | A string you write | Entirely manual | Directories |
The combination that suits most teams is unglamorous: a directory cache for dependencies, a task-level cache in whatever build tool matches your language, remote storage colocated with the runners, and a weekly clean build that proves the cache is not lying. If you are still choosing the platform underneath all of this, the CI/CD platform comparison covers how cache storage limits and runner locality differ between them, and the monorepo build tool guide goes deeper on the task graph side.
Frequently asked questions
What is the difference between a build cache and a CI cache?
A CI cache is a directory archive with a key you write by hand, usually restoring node_modules or a package manager directory between runs. It makes a step start faster. A build cache understands your task graph, computes keys from actual inputs, and can skip a task entirely because its output is already known. The first saves download time, the second saves execution time, and most pipelines want both.
Why is my cache hit rate high but my builds are not faster?
Usually because the tasks that hit are not the tasks that take time. A pipeline can hit on fifty cheap tasks and miss on the one expensive one, producing an impressive hit rate and an unchanged wall clock. Measure time saved per task rather than hits per task. The second common cause is that fetching the cached artifact takes about as long as producing it, which happens on tasks that are quick but produce large outputs.
Is it safe to let developer machines write to a shared remote cache?
No, and this is worth being firm about. A developer machine has uncommitted changes, a different toolchain version, and locally patched dependencies, any of which can produce an artifact that does not correspond to the key it gets stored under. Give developers read-only access and let only verified CI jobs write. The cost of that policy is some duplicated local work; the cost of skipping it is an artifact nobody can explain.
How do I know whether my cache keys are correct?
Run a full build with caching disabled and compare the artifacts byte for byte against a fully cached run of the same commit. Any difference means either a hermeticity leak or a key that is missing an input. Schedule that comparison rather than doing it once, because keys drift as tasks are added. Tools with sandboxed execution catch most of this at build time; tools that trust your declarations do not, which is exactly why the scheduled check matters more for them.
Does remote caching make sense for a single-application repository?
Sometimes, but the benefit is much smaller than in a monorepo. A single application has one dependency chain, so a change near the bottom invalidates nearly everything above it and there is little to reuse. The exceptions are dependency installation and container layer caching, which help regardless of repo shape. Task-level remote caching pays for itself when you have many independent projects and most changes touch a few of them.
Related reading
- Best CI/CD platforms — where cache storage limits, runner locality and build minute billing actually live.
- Best monorepo build tools — the task graph layer that decides what can be cached in the first place.
- Best CI runner and compute providers — why cold ephemeral runners put the remote cache on every critical path.
- GitHub Actions vs GitLab CI vs CircleCI — how the three built-in caching mechanisms differ in scoping and eviction.
- Best container registries — layer caching and pull performance, the other half of image build time.
- Best preview environment tools — where a warm build cache has the largest effect on perceived speed.