Stopping Hash Drift Between Turborepo Cache Hits
turbo build prints cache hit, replaying logs, the task finishes in 300 ms, and the fingerprinted filenames sitting in dist/ are not the ones a fresh build produces. Sometimes it shows up as main-a1b2c3d4.js on a developer’s laptop and main-9f8e7d6c.js on the CI runner from the identical commit. Sometimes the restored tree contains every JavaScript chunk but no manifest.json, so the deploy step reads a manifest left over from an earlier run and publishes HTML pointing at filenames that were never uploaded. All three are the same defect wearing different clothes, and this page is about finding which input caused it.
Two hashes, and why conflating them causes the bug
Turborepo and your bundler both compute hashes, but they answer different questions and are computed at different times.
The task hash is an input fingerprint. Turborepo builds it before running anything, from the files and variables it believes can affect the task’s result. Equal task hash means “I already have this output, skip the work”. The content hash is an output fingerprint, computed by Vite or Webpack from the finished bytes of each chunk and written into the filename. Eight hex characters is the sensible default; workspaces emitting thousands of chunks into one namespace should move to 12–16.
Hash drift is the state where those two disagree: the task hash asserts the inputs are unchanged while the content hash proves the output changed. That is never a bug in either hashing algorithm. It always means an input that genuinely affects the bundle’s bytes is not part of the task hash, so Turborepo cheerfully reuses an artifact built under different conditions. The fix is never “clear the cache” — that only hides it until the next cache hit.
What is actually in the task hash
Turborepo 2.x hashes a specific, enumerable set of things per task:
- The contents of the package’s git-tracked files, filtered by
inputsif you declared one. - The package’s own
package.json. - The resolved dependency set for that package — internal workspace dependencies via
dependsOn: ["^build"], and external packages via their entries in the lockfile. - The task’s own definition inside
turbo.json, andturbo.jsonitself. - Files listed in top-level
globalDependencies. - The values of variables named in
globalEnvand in the task’senv.
Everything else is invisible to it. In particular: environment variables you never declared, files that live outside the package directory, files that are untracked or .gitignored, and the version of Node, pnpm, or any system toolchain running the build.
Turborepo 2.x defaults to --env-mode=strict, which unsets everything not declared before spawning the task. That is a large improvement: an undeclared variable is now usually absent rather than silently different, which turns a wrong build into a loud failure. It is not complete protection, because a variable that is absent on CI and absent locally still produces a matching hash while a variable that is declared in passThroughEnv reaches the process without entering the hash at all. passThroughEnv is correct for credentials — an upload token has no business changing a content hash — and wrong for anything the bundler inlines.
The four drift sources, and how to tell them apart
| Symptom | Missing hash input | Fix |
|---|---|---|
Restored dist/ has JS but no manifest.json |
Nothing — outputs never captured the manifest |
Add "dist/.vite/**" alongside "dist/**" |
| Same commit, different hashes on CI vs laptop | An inlined VITE_* / NEXT_PUBLIC_* value |
Add the variable to the task’s env array |
Editing tsconfig.json changes nothing |
inputs was narrowed and dropped it |
Prefix the array with $TURBO_DEFAULT$ |
| Hashes move with no source change at all | Lockfile or Node version resolved differently | Pin both; add the lockfile to globalDependencies |
| Cache hit built against a stale sibling package | dependsOn missing the ^build caret |
Declare "dependsOn": ["^build"] |
Outputs that quietly skip the manifest
"outputs": ["dist/**"] reads like it covers everything under dist/, and for ordinary files it does. Vite 5 writes its manifest to dist/.vite/manifest.json, and a leading dot makes that directory hidden to a great many glob implementations, which by convention refuse to let * match a leading .. The result is a cache entry containing the chunks but not the file that names them.
Partial restore is worse than a cache miss. A miss costs you a rebuild; a partial restore hands the deploy step a manifest from whichever build last ran on that machine, which on a CI runner with a warm workspace is a different commit entirely. The HTML then references filenames that were never uploaded, and every user gets a 404 on the entry chunk. Because the manifest is the contract between build and deploy, treat it as a first-class output — the reasoning is laid out in asset manifest generation, and the choice of where it lives in a workspace in per-package vs global asset manifests.
Environment variables that become bundle bytes
Any variable a bundler inlines is not configuration at build time — it is source code. Vite substitutes import.meta.env.VITE_API_BASE with a string literal, Next.js does the same for process.env.NEXT_PUBLIC_*, and Webpack’s DefinePlugin does it for whatever you hand it. The substituted literal lands in the chunk, changes its bytes, and therefore changes its content hash. If the variable is not in the task’s env, Turborepo’s task hash is identical across two builds that produced materially different bundles, and the cache will serve you the wrong one.
The tell is that a --force build changes hashes while nothing in git changed. turbo build --dry=json shows what each task actually hashed, and --summarize writes the same detail to disk for comparison across machines. The related phantom-change patterns that are not Turborepo’s doing at all are catalogued in debugging phantom hash changes in CI.
Inputs narrowed too far
Declaring inputs replaces the default set rather than adding to it. A package that declares "inputs": ["src/**"] has told Turborepo that tsconfig.json, vite.config.ts, postcss.config.js and index.html cannot affect the build — so editing any of them produces a cache hit on the pre-edit artifact. $TURBO_DEFAULT$ is the escape hatch: it expands to the default input set so the rest of the array refines it instead of replacing it.
A turbo.json that closes all four holes
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["pnpm-lock.yaml", ".nvmrc", "turbo.json"],
"globalEnv": ["NODE_ENV"],
"globalPassThroughEnv": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": [
"$TURBO_DEFAULT$",
"vite.config.ts",
"tsconfig.json",
"index.html"
],
"outputs": ["dist/**", "dist/.vite/**"],
"env": [
"VITE_API_BASE",
"VITE_SENTRY_DSN",
"VITE_FEATURE_FLAGS"
],
"outputLogs": "new-only"
},
"release": {
"dependsOn": ["build"],
"cache": false,
"passThroughEnv": ["TURBO_TOKEN", "CDN_UPLOAD_KEY"]
}
}
}
The distinctions worth internalising in that file:
envversuspassThroughEnv.envvalues enter the hash;passThroughEnvvalues reach the process without entering it. Credentials belong in the second list, because rotating a deploy key must not invalidate every build artifact in the cache. Anything the bundler inlines belongs in the first.cache: falseonrelease. A task that talks to the outside world has no reproducible output to cache. Marking it uncacheable also stops it from ever being “replayed” from logs, which would skip the actual upload.outputLogs: "new-only"suppresses replayed logs, which makes a cache hit visually obvious in CI output instead of looking like a real build. That is a diagnostic aid, not a correctness setting, but it is the difference between noticing drift on day one and noticing it after a bad deploy.turbo.jsoninglobalDependenciesis belt-and-braces: the file is already hashed, but listing it makes the intent explicit for anyone editing the config later.- Dotenv files. Turborepo does not read
.envfiles for you and does not hash them unless you list them. If your bundler loads.env.productionat build time and that file is git-ignored, its contents are invisible to the task hash. Either commit an.env.example-shaped, non-secret file and add it toinputs, or move the values into declaredenvvariables. The former is safer for values that are already public in the bundle anyway.
Proving a restored artifact is byte-identical
Do not trust the absence of symptoms. Verify the restore against a forced rebuild:
#!/usr/bin/env bash
# scripts/verify-cache-fidelity.sh — run from the workspace root.
set -euo pipefail
PKG="${1:?usage: verify-cache-fidelity.sh <package-dir>}"
# 1. Force a real build and snapshot every emitted byte.
pnpm turbo run build --filter="./$PKG" --force >/dev/null
( cd "$PKG/dist" && find . -type f -exec sha256sum {} + | sort -k2 ) > /tmp/forced.txt
# 2. Blow away the outputs and let Turborepo restore them from cache.
rm -rf "$PKG/dist"
pnpm turbo run build --filter="./$PKG" >/dev/null
( cd "$PKG/dist" && find . -type f -exec sha256sum {} + | sort -k2 ) > /tmp/restored.txt
# 3. Any difference at all is drift — including a file present on one side only.
if diff -u /tmp/forced.txt /tmp/restored.txt; then
echo "cache restore is byte-identical"
else
echo "DRIFT: restored artifact differs from a forced build" >&2
exit 1
fi
sort -k2 sorts by path rather than by digest, so a missing file shows up as a clean one-line deletion instead of scrambling the whole diff. That is exactly how the missing-manifest case presents.
To compare across machines rather than across runs, use the run summaries. --summarize writes a JSON file per run into .turbo/runs/, and the interesting fields are the global hash, each task’s hash, and the resolved external dependency hash:
# Produce a summary, then extract the fields worth comparing between CI and local.
pnpm turbo run build --summarize
jq -r '
{ global: .globalCacheInputs.hashOfExternalDependencies,
globalHash: .globalHashSummary.globalCacheKey,
tasks: [ .tasks[] | { package, task, hash, envMode,
env: .environmentVariables.specified.env } ]
}
' .turbo/runs/*.json
Run that on both machines and diff the output. A differing hashOfExternalDependencies means the lockfile resolved differently and belongs in the pinning conversation covered by pinning lockfiles and Node versions for stable hashes. Identical task hashes on both sides while the emitted filenames differ is conclusive proof of an undeclared inlined variable.
When to reconsider
Disable remote caching for the release build. If a release must be provably built from source on a trusted runner, turbo run build --force --no-cache removes the cache from the trust path entirely. The cost is a full cold build per release; the benefit is that a poisoned or merely stale remote artifact cannot reach production. Signing the remote cache is the cheaper middle ground.
Force the build only for the deploy job. A reasonable compromise is cached builds for pull requests and --force on the branch that deploys. Pull-request feedback stays fast, and the artifact that reaches the CDN is always freshly derived from source. This pairs naturally with the upload-order discipline described in CI/CD asset pipeline integration.
Drop content hashing for a build id. If drift keeps recurring and the team cannot keep the input declarations honest, naming assets main.<buildid>.js makes filenames trivially stable per release. You lose the property that unchanged files keep their URL, so every deploy invalidates every asset at the edge — a real cost, and usually the wrong trade. Treat it as a temporary measure while the env and outputs declarations get fixed, not as a destination. If the underlying problem is the bundler rather than the task graph, the id-generation settings in Webpack output hashing setup are the place to look.
Related
- Monorepo and micro-frontend asset hashing — the parent guide covering namespaced output, workspace dependency propagation, and retention
- Lockfile and Node version pinning — removing the toolchain from the set of things that can move a hash
- Manifest scope in a workspace — where the manifest should live once you have made the cache restore it reliably
- Module Federation remote chunk sharing — what a stale cached artifact costs when apps are composed at runtime
- CI/CD asset pipeline integration — where a verified artifact goes next, and the upload ordering that keeps it safe