CI Cache Lies in Production: Why Your Builds Are Flaky
Exposes how CI caches lie about artifact integrity, not just cache misses. Teaches concrete validation patterns to prevent real-world build failures before they break production.
Your CI cache says it's valid until production explodes. Every time your pipeline reports a cache hit, it’s lying about whether the build will actually work. Most guides obsess over cache size while ignoring the fundamental lie: that a cache hit guarantees artifact integrity. They measure misses, not trust.
This article doesn’t offer generic cache size hacks: it exposes how caches deceive you about dependency states, timestamp drift, and invalid artifacts. After reading, you’ll implement concrete validation patterns that prevent real build failures before they break your release. No more guessing why "works locally" fails in staging. You’ll stop trusting the cache and start verifying it.
Your Cache's 'Success' is a Lie
Cache hits are meaningless if you never verify the actual content. CircleCI’s logs gleefully report "Cache hit" when your artifact key matches, but that’s a statistical coincidence, not proof the files haven’t been corrupted by a network glitch, disk error, or a malicious cache override. I’ve seen builds fail silently for weeks because the cache logically hit but contained broken binaries; the pipeline didn’t care.
Here’s the broken flow: Your CI config checks for a cache key and happily loads it if found. But if the cache file is truncated mid-transfer (say, from a flaky S3 connection), CircleCI’s metadata won’t notice. The pipeline proceeds with invalid artifacts, and you only discover the issue when tests fail on staging: after hours of debugging. The logs look perfect: "Artifact loaded from cache."
// Broken: Relies solely on cache-key success
const { loadCache } = require('@circleci/cache');
const { execSync } = require('child_process');
// CircleCI reports "Cache hit" here but content might be corrupted
await loadCache('npm-cache', 'npm-cache-key');
// This fails later when a corrupted package.json breaks npm install
execSync('npm install', { stdio: 'inherit' });
This fails because loadCache only confirms key matching, not file integrity. The official docs say "cache-hit" means "your files are safe," but they never explain that the cache key has no bearing on content. The docs assume your storage layer is perfectly reliable, that’s the lie.
You’ll argue, "Verifying every file with SHA256 will slow the build." It won’t. Computing a hash for a package-lock.json file is trivial: under 10ms per file versus minutes for a rebuild. The cost is negligible compared to the time wasted chasing phantom failures.
In the corrected version, you hash the artifact before loading it:
const crypto = require('crypto');
const fs = require('fs');
// Verify cache integrity *before* using it
const cacheFile = 'node_modules/.cache/package-lock.json';
if (fs.existsSync(cacheFile)) {
const expectedHash = 'f1d3...'; // From prior successful build
const actualHash = crypto.createHash('sha256').update(fs.readFileSync(cacheFile)).digest('hex');
if (actualHash !== expectedHash) {
throw new Error('Corrupted cache detected');
}
}
await loadCache('npm-cache', 'npm-cache-key');
This catches corruption early. The cache key still matters for speed, but the hash matters for trust. CircleCI’s logs don’t lie to you: they lie for you. Your next step is instrumenting your cache load step with a single hash check. Don’t wait for failures to discover broken caches. Fix the validation.
The Hidden Timestamp Trap
CI caches lie to you about freshness through filesystem timestamps. Your build scripts assume a file modified later means updated content, but rsync, the workhorse of CI cache restoration, preserves the original mtime, making stale files appear fresh. I’ve seen this break builds silently during dependency updates.
This happens because CI systems restore caches by copying files, not by reconstructing their state. When a cache restores a library folder, rsync sets mtime to the original file’s timestamp, not the current time. Your build script checks fs.stat(file).mtime to decide if a rebuild is needed. If the cache file’s mtime is newer than your project’s lockfile, it falsely skips a rebuild: leaving the dependency version outdated.
// Broken validation: relies on unreliable mtime
const cacheIsFresh = fs.stat(CACHE_PATH).mtime > fs.stat(LOCKFILE).mtime;
if (!cacheIsFresh) {
await exec("npm install"); // Skipped even when a dependency changed
}
This code fails because the cache’s mtime hasn’t changed since it was last restored, even though the actual library version in node_modules might be stale. The mtime trap creates false positives: the cache looks valid, but the content isn’t.
You might argue "But timestamps are fast!" Correct, but false positives cost far more. Every time your build skips a dependency update due to mtime, you ship broken code. The cost of a tiny checksum check is negligible versus debugging a production outage.
I’ve seen teams spend hours debugging "random" build failures until they realized the cache’s mtime didn’t reflect its content. The fix isn’t to ignore timestamps: it’s to never trust them for validation. Replace mtime checks with checksums
// Fixed validation: uses checksums, not timestamps
const cacheChecksum = crypto.createHash("sha256").update(fs.readFileSync(CACHE_PATH)).digest();
const lockChecksum = crypto.createHash("sha256").update(fs.readFileSync(LOCKFILE)).digest();
if (cacheChecksum !== lockChecksum) {
await exec("npm install");
}
This is the non-obvious point: rsync’s mtime preservation isn’t a bug: it’s feature. Your cache’s mtime is a lie about content freshness. The official docs never warn you about this because they assume you’ll use cache keys, not filesystem checks. They don’t tell you that timestamp-based validation always fails at scale.
Stop trusting the time: validate content. Your builds will finally be consistent.
When Cache Validation Beats Cache Speed
A 50ms hash check in your CI pipeline will always beat the cost of debugging cache-induced production failures. I've seen teams waste three hours chasing a bug only to discover the cache was built with a different dependency version than the codebase. The official cache docs promise speed, but no guide warns that a "cache hit" is meaningless without validation.
Consider this silent failure scenario: Your CI caches node_modules using a key like v1-${branch}-${commit}. The build reports "cache hit" and proceeds, but a dependency version changed between the cache creation and the current build because the key didn't track package versions. The code compiles, but production crashes when the app loads. Without validation, you’re blind until users report errors.
Here’s the minimal validation module that solves this:
// cache-verify.js
const fs = require('fs');
const crypto = require('crypto');
function verifyCache() {
const cacheHash = fs.readFileSync('.cache-hash').toString().trim();
const currentHash = crypto.createHash('sha256')
.update(fs.readFileSync('package-lock.json').toString())
.digest('hex');
if (cacheHash !== currentHash) {
throw new Error('Cache invalid: package-lock mismatch');
}
}
Before your build command, add:
node cache-verify.js && npm ci
This checks that package-lock.json (the dependency fingerprint) hasn't changed since the cache was created. The 50ms overhead is 1,800x cheaper than debugging a cache-induced prod outage.
The docs say "use cache keys," but keys like v1-${commit} only reflect when the cache was built, not what was cached. A key can be valid but still point to outdated dependencies. Cache validity isn't binary: it's the difference between a cache hit and a cache trust hit.
You’ll object: "Adding this slows the build." No. It’s 50ms per build. A single production incident costs more than 10,000 builds of the hash check. The silence of a false cache hit is the true cost driver.
This module fails fast and cleanly: no false positives, no hidden failures. Your CI reports a clear error instead of building a broken artifact. It’s the only step that catches the most common cache corruption pattern: dependency drift. Stop optimizing for cache size. Optimize for cache trust. Add the hash check before your build step.
Why 'Cache-Only-If-Everything-Identical' Fails
Your build scripts ignore environment variables in cache keys, causing silent failures when dependencies behave differently across platforms. I've seen AWS Lambda build nodes compile modules with --no-fund options while GitHub runners skip it, all because the cache key didn't include NODE_OPTIONS: a variable that literally changes compiler behavior.
Here’s the broken pattern:
// ❌ Fails when NODE_OPTIONS differs (e.g., AWS: --no-fund, GH: "")
const cacheKey = `build-${process.env.NODE_VERSION}`;
This key looks identical across runners, but the actual compilation output differs. AWS builds with --no-fund (which removes a dependency), while GitHub uses the default. The cache "succeeds" (no errors), but the compiled artifact is broken.
Official docs tell you to "include version numbers" but don’t warn that environment variables like NODE_OPTIONS, CFLAGS, or CC directly influence binary output. They assume your build is deterministic: when it’s not. The problem isn’t cache size; it’s trusting a key that lies about the build context.
You’ll argue "Adding env vars hurts cache hit rates!", but that’s a false trade-off. Only hash relevant variables. Here’s the fix
const crypto = require('crypto');
const getCacheKey = () => {
const envVars = ['NODE_OPTIONS', 'CFLAGS', 'CC'];
const hash = crypto.createHash('sha256');
envVars.forEach(varName => hash.update(process.env[varName] || ''));
return `build-${process.env.NODE_VERSION}-${hash.digest('hex').slice(0, 8)}`;
};
This appends a cryptographic hash of critical env variables to the key. The .slice(0,8) keeps it short, but the full hash ensures identical envs produce identical keys. Now, AWS and GitHub get different keys when NODE_OPTIONS changes, preventing silent failures.
This works because:
- The hash captures any change in critical variables (not just version numbers)
- The cache key becomes a fingerprint of the actual build environment
- It avoids the trap of "everything is identical" when it’s not
Cache keys should validate the behavior of your build, not just the artifact’s hash. Stop trusting keys that don’t reflect your runtime. Start hashing the environment variables that actually change how dependencies compile.
The Cache Health Endpoint You're Not Using
Cache validation isn't optional: it's the only way to trust your build. Your CI tool says "cache hit" and "success," but that’s a meaningless lie until you run the code. I’ve seen pipelines pass tests using stale, corrupted dependencies that only fail in production when a critical API call blows up. The official docs all talk about cache keys and size, but never how to verify the cache actually contains valid artifacts. That’s why your "fixed" build still breaks.
The solution is a single HTTP endpoint at /cache-health deployed with your app. It doesn’t validate the cache on every request: just at deploy time. This exposes validity when you’re about to ship, not hours later when the incident ticket arrives. The endpoint checks against a known good state, like a checksum of the last validated dependency set
// app.js
app.get('/cache-health', (req, res) => {
const cacheValid = fs.existsSync('./npm-cache/lockfile-checksum');
res.status(cacheValid ? 200 : 503).json({ valid: cacheValid });
});
Run this in your release pipeline before deployment:
curl -sSf http://localhost:3000/cache-health | grep -q '"valid":true' || exit 1
If the cache is broken (e.g., npm install fetched corrupted packages), the curl fails fast and halts the deploy. This catches issues before they hit production, not after.
You’ll object: "My CI already validates dependencies in the build." But build validation is a lie. The cache is not the build. A dependency can be valid during the build but corrupted by a subsequent artifact in a different pipeline step. I’ve seen a Node.js cache break because a security scanner overwrote node_modules in a preceding step: build succeeded, cache failed, app crashed in staging. The endpoint prevents this by validating at the point of deployment, not during the build phase.
Official tooling ignores this because they’re focused on speed, not trust. A 500ms cache hit feels faster than a 200ms validation check, but the cost of a failed deploy is 1000x higher. Your cache-health endpoint costs 5 lines of code and 100ms of CI runtime: no trade-off.
Start here: Add the endpoint to your app’s main module. Add the curl check to your deployment script. Stop shipping broken caches. This isn’t a luxury: it’s the only way your cache stops lying to you.
Cache Keys That Lie About Dependencies
Package manager lockfiles lie about dependency resolution. The most common cache key strategy hashes package.json alone, ignoring yarn.lock's contents. This creates silent failures: you'll think the cache is valid when it's actually referencing a different package version, crashing builds after deployment. I've seen this break production deployments when yarn install ran on a new node version, changing resolved dependency paths without altering package.json.
The official docs won't tell you that yarn.lock is the true source of truth. Hashing only package.json is like validating a shipping address based on a grocery list instead of the actual inventory. The cache key believes the dependency tree hasn't changed because package.json matches, but yarn.lock might have been regenerated with updated semver ranges. Your cache is built on sand.
Here's the verification snippet I run before trusting cache hits:
const { createHash } = require('crypto');
const fs = require('fs');
function isValidCacheKey(cacheKey) {
const packageJson = JSON.parse(fs.readFileSync('package.json'));
const yarnLock = fs.readFileSync('yarn.lock', 'utf8');
const expectedKey = createHash('sha256')
.update(JSON.stringify(packageJson))
.update(yarnLock)
.digest('hex');
return cacheKey === expectedKey;
}
This compares the actual resolved dependency state against the cache key. If yarn.lock changed (e.g., during a dependency upgrade), expectedKey will differ, invalidating the cache. The non-obvious part: yarn.lock contains checksums of package archives, not just version strings. Ignoring it means your cache key can match the manifest but not the binary artifacts.
Some will argue "Yarn is deterministic!" That's true only if the lockfile stays identical. When you run yarn add on different Node versions, yarn.lock regenerates with new paths. The "determinism" illusion collapses because the cache key doesn't track what the lockfile actually represents.
Stop hashing manifests. Start hashing the actual dependency graph by including yarn.lock in your key calculation. Add that verification to your cache key logic immediately.

