Technology Sep 03, 2026 · 5 min read

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 y...

DE
DEV Community
by Mukesh
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 .circleci/config.yml.

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:

  1. You checksum the wrong file. {{ checksum "package.json" }} looks reasonable until someone bumps a transitive dependency via package-lock.json without touching package.json. The checksum doesn't move. CircleCI happily hands back last week's node_modules.

  2. restore_keys does prefix matching, and people think it does exact matching. CircleCI tries your primary key first, then falls through restore_keys 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 v1-deps-), 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, npm ci sees the modules are "there") or run against the wrong versions.

  3. 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 node_modules is fine.

The fix

Replace whatever your current cache block looks like with this shape:

- restore_cache:
    keys:
      - v3-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}
      - v3-deps-{{ .Branch }}-
      - v3-deps-

- run:
    name: Install dependencies
    command: yarn install --frozen-lockfile

- save_cache:
    key: v3-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}
    paths:
      - node_modules
      - ~/.cache/yarn

Four specific changes, each fixing one of the failure modes above:

  • Checksum the lockfile, not the manifest. yarn.lock / package-lock.json / poetry.lock / Cargo.lock — whatever actually pins your resolved versions. That's the only file where "nothing changed" is a true statement about your dependency tree.

  • --frozen-lockfile (or npm ci, or poetry install --no-update) 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 restore_keys 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 v3-deps- as your only fallback — that's the line that lets a feature/rewrite-auth branch restore a cache from main with a different lockfile entirely.

  • Bump the leading version token (v3-v4-) 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:

- run:
    name: Confirm lockfile/cache agreement
    command: |
      yarn list --pattern "your-critical-package" | tee /tmp/resolved.txt
      grep -q "$(grep 'your-critical-package' yarn.lock | head -1 | cut -d'@' -f2)" /tmp/resolved.txt

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. checksum "yarn.lock" 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 to exist, that's usually the actual bug — but if they're intentional, checksum all of them:

- restore_cache:
    keys:
      - v3-deps-{{ checksum "yarn.lock" }}-{{ checksum "packages/api/yarn.lock" }}

What this actually costs you

Fifteen minutes: five to edit the config, five to push a branch and watch the cache key change in the CircleCI job output, five to add and then remove the verification step once you trust it. No infrastructure change, no new tooling, no dependency on anything outside .circleci/config.yml. And it closes off the specific class of bug that's hardest to debug precisely because it never announces itself — the build that lies to you by saying nothing at all.

DE
Source

This article was originally published by DEV Community and written by Mukesh.

Read original article on DEV Community
Back to Discover

Reading List