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 inputs if 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, and turbo.json itself.
  • Files listed in top-level globalDependencies.
  • The values of variables named in globalEnv and in the task’s env.

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.

Task hash composition Six declared inputs funnel into a single task hash token that decides cache hit or miss, while four undeclared inputs sit outside the hash and are the usual sources of drift. What Turborepo folds into a task hash In the hash by default package files (git-tracked) package.json + lockfile entry internal dependency hashes task definition in turbo.json globalDependencies files globalEnv + task env values task hash 3f9c21ab7d0e hit or miss decides reuse Not in the hash by default undeclared env vars files outside package untracked / ignored Node + toolchain ver.
The six declared inputs decide cache reuse; the four undeclared ones can still change the bundle's bytes, which is precisely how drift happens.

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"]
Hash drift decision tree A root node for a cache hit that produced wrong hashes branches into four symptoms, each resolving to a specific turbo.json change. Diagnosing drift after a cache hit cache hit, wrong hashes start here manifest missing from restored dist bundle differs between CI and dev stale output after a config edit hashes move with no source change widen outputs add dist/.vite/** to the glob declare env list VITE_* in the task env use $TURBO_DEFAULT$ inside inputs, not a bare list pin the lockfile and Node version in globalDeps Each branch is the same bug: an input that changes output is missing from the task hash
Four symptoms, four distinct missing inputs — the cure is always to widen what the hash sees, never to disable the cache.

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:

  • env versus passThroughEnv. env values enter the hash; passThroughEnv values 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: false on release. 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.json in globalDependencies is 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 .env files for you and does not hash them unless you list them. If your bundler loads .env.production at 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 to inputs, or move the values into declared env variables. 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.

Restored versus forced build checksums Three JavaScript chunks carry identical sha256 digests in both trees, while the Vite manifest is absent from the cache-restored tree, revealing an outputs glob that never captured it. Restored artifact vs a forced rebuild cache hit — restored dist/ turbo build --force output main-a1b2c3d4.js sha256 9f2c41d8b7a0 main-a1b2c3d4.js sha256 9f2c41d8b7a0 vendor-7d10ba93.js sha256 4e88c0a1f23d vendor-7d10ba93.js sha256 4e88c0a1f23d polyfill-51ce0f7a.js sha256 b3170de9c4e5 polyfill-51ce0f7a.js sha256 b3170de9c4e5 dist/.vite/manifest.json not restored dist/.vite/manifest.json sha256 c72a9b04e6f1 The outputs glob missed dist/.vite — the restore is partial
Chunks match byte for byte, so the cache is not corrupt; the manifest is simply absent, which points straight at the outputs declaration.

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.