The CircleCI Cache Key Bug That's Silently Serving Your Builds Stale Dependencies
Your CircleCI pipeline is green. Every job passes. And yet your app is running against a dependency version that hasn't shipped in a month — nobody committed it, nobody bumped it, it just quietly showed up in production. If you've chased a bug like this, the culprit is almost never your code. It's your cache key. This is a five-minute read and a fifteen-minute fix. Quick Win Friday, deployed to your . The failure mode CircleCI's dependency caching works on a simple contract: you compute a key from something that changes when your dependencies change (usually a lockfile checksum), and you save/restore a cache tied to that key. The contract breaks in three specific, extremely common ways: You checksum the wrong file. looks reasonable until someone bumps a transitive dependency via without...
Your CircleCI pipeline is green. Every job passes. And yet your app is running against a dependency version that hasn't shipped in a month — nobody committed it, nobody bumped it, it just quietly showed up in production. If you've chased a bug like this, the culprit is almost never your code. It's your cache key. This is a five-minute read and a fifteen-minute fix. Quick Win Friday, deployed to your . The failure mode CircleCI's dependency caching works on a simple contract: you compute a key from something that changes when your dependencies change (usually a lockfile checksum), and you save/restore a cache tied to that key. The contract breaks in three specific, extremely common ways: You checksum the wrong file. looks reasonable until someone bumps a transitive dependency via without touching . The checksum doesn't move. CircleCI happily hands back last week's . does prefix matching, and people think it does exact matching. CircleCI tries your primary key first, then falls through in order, and the first one is a prefix match against existing cache entries — not "give me the newest exact match." If your restore_keys list is too coarse (e.g. just ), you can restore a cache built from a completely different branch, with a completely different lockfile, and the job won't fail. It'll just quietly install nothing (cache hit, sees the modules are "there") or run against the wrong versions. There's no version escape hatch. When you inevitably need to force everyone's cache to invalidate — a corrupted cache entry, a package manager migration, a lockfile format change — there's no cheap way to do it, because the key format was never designed with a manual buster in mind. Each of these fails silently. No red X. No error in the logs. Just a build that ran with stale state, and a bug report three days later that nobody can reproduce locally because local is fine. The fix Replace whatever your current cache block looks like with this shape: Four specific changes, each fixing one of the failure modes above: Checksum the lockfile, not the manifest. / / / — whatever actually pins your resolved versions. That's the only file where "nothing changed" is a true statement about your dependency tree. (or , or ) as the install command, always. This is the safety net for the failure modes you haven't fixed yet: if the cache did restore something stale, a frozen install refuses to silently proceed with a mismatched lockfile instead of quietly reconciling it. You want that job to go red, not go green with the wrong tree. Order from most to least specific, and stop one level short of "matches anything." Branch-scoped exact match first, branch-scoped prefix second, global prefix last as a genuine last resort for a brand-new branch. Don't just have as your only fallback — that's the line that lets a branch restore a cache from with a different lockfile entirely. Bump the leading version token ( → ) whenever you need a clean slate. This is your manual cache-buster. Because it's baked into the key itself, forcing invalidation for everyone is a one-line PR, not a support ticket to CircleCI or a trip through the project settings UI to nuke caches by hand. Verifying it actually worked Don't just ship the YAML change and trust it. Add a one-line assertion job step for a week while you confirm behavior: Adjust the package name to whatever dependency has bitten you before, or whatever your team would most want to know shipped the wrong version. If that grep ever fails, your cache and your lockfile have diverged — and now it fails loud, in CI, instead of quiet, in production. The monorepo trap If you're on a Yarn/npm workspace or a monorepo with multiple lockfiles, the checksum function only hashes what you tell it to. at the repo root won't catch a change to a workspace package's own dependencies unless your package manager writes that resolution back into the root lockfile (most do, but verify it for yours). If you have nested lockfiles that aren't supposed t