A Build Cache Is Only as Good as Its Key
Our monorepo cache reported a high hit rate and saved almost no time. The problem was not the cache โ it was what we were hashing into the key.
Dana Whitlock ยท July 20, 2026 ยท 8 min read

Note: This is demonstration content. The team, repository, and all figures below are fictional and created to illustrate the publishing setup.
We turned on remote build caching and the reported hit rate settled around 90%. Build times dropped by about four percent. Those two numbers cannot both be good news, so we went looking for which one was misleading us.
A hit that restores nothing
The hit rate was real. The cache was genuinely finding entries. The problem was what those entries contained.
Our cache key was computed from source file hashes, but the output we stored was scoped too narrowly โ we saved the compiled artifacts for a package while omitting the generated type declarations that downstream packages needed. So every downstream build got a hit, restored an incomplete output, discovered it was missing declarations, and regenerated them from scratch.
A hit that forces the work to happen anyway is worse than a miss, because you pay the download cost on top of the build cost.
Keys that are too specific fail quietly
The second issue was in the opposite direction. Our key included the absolute path of the workspace root:
// Every developer and every CI runner hashes to a different key.
const key = hash([
workspaceRoot, // /Users/dana/src/app vs /home/runner/work/app
...sourceFiles.map(readAndHash),
JSON.stringify(process.env), // also this
]);
Both of those inputs were poison. The absolute path meant a developer could never reuse a CI-produced artifact. Hashing the entire environment meant a single unrelated variable โ a build number, a session ID โ invalidated everything.
The corrected version hashes only inputs that actually affect the output:
const key = hash([
...sourceFiles.map(readAndHash), // relative paths, sorted
lockfileHash,
toolchainVersion,
pickRelevantEnv(process.env), // explicit allowlist
]);
The allowlist is the important part. An explicit list of environment variables that matter is auditable; process.env is a moving target that guarantees cache misses for reasons nobody can reconstruct later.
Correctness comes before hit rate
There is a real tension here, and it's worth being explicit about it. A looser key means more hits and more risk of restoring something subtly wrong. A stricter key means fewer hits and more confidence.
We resolved it by ordering the questions: first make sure a hit produces a byte-identical result to a cold build, then work on raising the rate. That ordering matters, because a fast build that occasionally produces the wrong artifact will burn more engineering hours than it ever saves โ and the failures surface far from the cause.
To verify the first part, we run a scheduled job that builds a package twice, once cold and once from cache, and diffs the outputs. It has caught two key bugs that no one would have noticed until something strange shipped.
What we'd do differently from the start
- Store complete outputs. Enumerate what a package produces, including generated files, and verify the restore is sufficient to skip the step entirely.
- Never hash absolute paths. Relative, sorted, normalized.
- Allowlist environment variables. Don't hash the whole environment.
- Measure time saved, not hit rate. Hit rate is a proxy that can be gamed by accident. Wall-clock time is the thing anyone actually cares about.
- Diff cold vs. cached builds on a schedule. This is the only check that catches a cache which is fast and wrong.
The reframe that helped most was to stop treating the cache as an optimization and start treating it as a claim: this input always produces this output. Once it's a claim, testing it is obvious, and the key stops being a place to sprinkle in whatever seems relevant.