Debugging Turborepo Cache Misses with --dry-run
A task that should hit the cache but re-runs is almost always a hash input you did not expect — an undeclared env var, an over-broad inputs glob, or a non-deterministic file. turbo run --dry=json prints exactly what went into each task’s hash without running anything, so you can diff two runs and find the one field that changed. This guide does that diff. It applies the hashing model from remote caching and distributed build coordination; the parent overview is esbuild & Turbopack Workflows.
The reason this problem is hard to eyeball is that Turborepo’s cache key is not one thing you can read off a config file. It is a composite hash folded together from the task’s own source files, the resolved hashes of every workspace it depends on, a declared set of environment variables, the global dependencies, and the tool’s own version. Any one of those inputs shifting by a single byte produces a different key, and a different key means a miss. When a build re-runs “for no reason,” what actually happened is that one of these contributors changed and the terminal output gave you no breadcrumb pointing at which. The --dry flag exists precisely to expose that breadcrumb: it materialises the full input set that would have been hashed, as data you can inspect, without paying for the task to run.
The cost of not fixing this is quiet but compounding. A task that misses on every CI run throws away the entire point of remote caching — you pay the full build time on every pipeline, the remote cache fills with entries nobody will ever hit again, and the miss usually cascades: because a downstream task depends on the upstream task’s hash, one spurious upstream miss forces every dependent task to rebuild too. In a monorepo with a deep task graph, a single undeclared CI_COMMIT_SHA at the root can turn a thirty-second incremental build into a ten-minute cold one. This guide treats a cache miss as a diff problem with exactly one answer, and walks the mechanical steps to isolate it.
Prerequisites & reproducible setup
# Turborepo 2.x, Node 20+, jq
# A monorepo where `build` unexpectedly misses on the second run.
turbo run build # first run: builds
turbo run build # second run: SHOULD be FULL TURBO but misses
jq --version # we will diff JSON hash inputs
--dry=json (or --dry-run for a text summary) computes the full hash for every task and prints it without executing, so it is safe to run repeatedly and cheap to diff. Because it does no work, you can run it on a dirty working tree, inside a failing pipeline, or against a task whose dependencies are broken — it only reports what the hash would be, so nothing downstream is triggered and no cache entry is written. That property is what makes it the correct first tool: you are inspecting the decision, not re-triggering it.
The one setup detail that trips people up is that the dry-run output is only comparable across two invocations if everything outside the fields you are studying is held constant. Run both captures from the same checkout with the same turbo binary, or you will see noise — the schema version and the tool version are themselves hash inputs, so a diff taken across two different turbo releases will differ everywhere and tell you nothing. Pin the version (via the packageManager field or a committed lockfile) before you start diffing.
--dry is read-only — it shows the hash it would use without doing the work.How the task hash is built under the hood
Turborepo computes a task’s cache key by hashing a set of contributors in a fixed, deterministic order and folding them into a single digest. Understanding the order matters because it tells you which field in the dry-run JSON maps to which contributor. The contributors are: the hashed contents of the task’s own inputs files (by default, all files tracked by git in that workspace, minus .gitignored paths); the resolved hashes of the workspaces this task dependsOn, pulled in transitively so an upstream change ripples down; the values of the environment variables named in env and globalEnv; the contents of any globalDependencies files; the task’s resolved definition from turbo.json; and the turbo version plus internal schema version. Each contributor is hashed independently, and the per-task digest is the hash of those sub-hashes.
The critical mechanical fact is that Turborepo hashes file contents, not mtimes — so touching a file without changing it does not bust the cache, but a one-character whitespace change does. Environment variables are hashed as name-plus-value pairs, which is why an unset variable and a variable set to the empty string are distinct inputs, and why a value that legitimately varies per run (a timestamp, a run ID) guarantees a permanent miss. Dependency hashes are transitive: if web depends on ui, then any input change inside ui changes ui’s hash, which is folded into web’s hash, so web misses even though nothing under web/src moved. When you diff, you are essentially asking the tool to show you its arithmetic for one task so you can find the single term that changed.
Diagnosis workflow
- Capture both runs.
turbo run build --dry=json > a.jsonon the run that hit, and again asb.jsonon the run that missed (or on the other machine). Capture from a clean checkout each time — an uncommitted edit in the working tree is a real input and will show up as a legitimate difference, muddying the diff. If the miss only appears in CI, the honest capture is onea.jsonfrom a known-good local hit and oneb.jsonpulled from the CI environment, because the whole point is that CI’s inputs differ from yours in a way you have not spotted. - Diff the task’s hash inputs. Compare
tasks[].hashOfExternalDependencies,tasks[].inputs, andtasks[].environmentVariablesbetween the two files. Sort each field before comparing (jq -S) so that map key ordering does not create a false diff — Turborepo’s own hashing is order-stable, but the JSON serialisation is not guaranteed to be, and an unsorteddiffwill flag reordered keys that hashed identically. Work one field at a time; comparing whole task objects at once buries the signal under fields likecommandanddirectorythat never affect the hash. - Classify the difference. A changed env value → declare it correctly; a changed file that should not matter → tighten
inputs; a file that legitimately changed → the miss is correct. The classification is the whole judgement call: the tool can tell you what differed but not whether it should matter to your build output. If the differing input genuinely changes what the task produces, the miss is the cache doing its job and you stop. Only inputs that have no bearing on the output — a stray lint cache, a log file, an ambient env var — are bugs worth fixing.
Annotated solution config
The two commands below capture and compare the fields that account for the overwhelming majority of spurious misses. Run the env diff first — an undeclared or drifting environment variable is the single most common cause, because env values change constantly (CI injects dozens) and, unlike files, they leave no trace in git for you to notice. If the env diff is empty, move to the inputs diff; a non-empty inputs diff points at a file that entered the hash that you did not intend to track. The select(.taskId=="web#build") filter narrows the JSON to one specific task so the comparison is not diluted by every other task in the graph — substitute your own workspace#task identifier.
# Turborepo 2.x — capture and diff the two hashes for the build task.
turbo run build --dry=json > a.json # from the run that HIT
turbo run build --dry=json > b.json # from the run that MISSED
# Compare the environment variables the task hashed:
diff <(jq -S '.tasks[] | select(.taskId=="web#build") | .environmentVariables' a.json) \
<(jq -S '.tasks[] | select(.taskId=="web#build") | .environmentVariables' b.json)
# Compare the file inputs:
diff <(jq -S '.tasks[] | select(.taskId=="web#build") | .inputs' a.json) \
<(jq -S '.tasks[] | select(.taskId=="web#build") | .inputs' b.json)
Once the diff has named the culprit, the fix lives in turbo.json and is almost always one of two edits. If an environment variable differed, the correct response depends on whether it actually affects the build: a variable that changes output (an API URL baked into the bundle, a feature flag) belongs in env so it hashes deliberately and misses are honest; a variable that has no effect on output (a CI run counter, a build timestamp) belongs in globalPassThroughEnv, which forwards it to the task’s environment without letting it enter the hash. Declaring it under env when it should be pass-through simply relocates the miss; the distinction is whether the value changes what the task writes to outputs.
// turbo.json — the two fixes the diff usually points to.
{
"tasks": {
"build": {
// Fix 1: declare the env var that differed so it hashes intentionally,
// or move a non-affecting token to globalPassThroughEnv.
"env": ["NEXT_PUBLIC_API_URL"],
// Fix 2: narrow inputs so an unrelated file no longer busts the hash.
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": ["dist/**"]
}
}
}
The second fix — narrowing inputs — is the answer when a file that has nothing to do with the build keeps busting the hash. By default a task hashes every git-tracked file in its workspace, so a change to README.md, a .storybook config, or a committed coverage report will miss build even though none of them reach dist. Setting an explicit inputs list flips the default from “everything” to “only these,” which is precise but sharp: once you declare inputs, a file you forgot to list will no longer bust the cache even when it should, so a stale build can hit. The safe pattern is to start from the default, add inputs only when the diff proves a specific stray file is the problem, and always include the files that genuinely drive the build (src/**, the manifest, the compiler config) plus package.json so dependency changes still register.
For a concrete worked example, suppose the env diff comes back empty but the inputs diff shows a single extra entry:
# Turborepo 2.x — inputs diff isolates one stray file.
diff <(jq -S '.tasks[] | select(.taskId=="web#build") | .inputs' a.json) \
<(jq -S '.tasks[] | select(.taskId=="web#build") | .inputs' b.json)
# < "coverage/lcov.info"
# ---
# (the run that missed re-ran the test suite, which rewrote coverage,
# and coverage/ was git-tracked, so it entered web#build's hash)
The output names coverage/lcov.info as the one input present in the missing run’s hash. It is a generated artifact from the test task, not a build source, so the correct fix is to stop tracking it — add coverage/ to .gitignore — or, if it must stay tracked, exclude it with a negated glob in inputs ("!coverage/**"). Either way the file leaves build’s hash and the next run hits.
Verification
A fix is not confirmed until two independent signals agree: the runtime signal (>>> FULL TURBO) and the structural signal (an empty dry-run diff on the field that previously differed). The runtime signal alone is weak, because a hit on your machine can hide a miss that only reproduces in CI where the env differs; the diff signal alone is weak, because an empty diff on one field says nothing about the others. Together they close the loop: the diff proves the input you changed now matches, and FULL TURBO proves the whole key resolves to an existing cache entry.
# Turborepo 2.x — after the fix, the second run should be a full cache hit.
turbo run build # populate
turbo run build # expect: >>> FULL TURBO
# Re-diff to confirm the previously-differing field now matches:
diff <(turbo run build --dry=json | jq -S '.tasks[].environmentVariables') \
<(turbo run build --dry=json | jq -S '.tasks[].environmentVariables')
After the fix, the second run prints >>> FULL TURBO and a fresh dry-run diff of the previously-differing field is empty. If the miss persists, re-diff the other fields — the actual culprit was a different input than you first suspected, or there were two of them and you have only fixed one. This is common: an undeclared env var and a stray tracked file can both be busting the same task, and fixing one leaves the miss in place, which reads as “the fix did not work” when in fact it worked and simply was not the whole story. Re-run the full three-field diff after every change and keep going until every field matches.
To confirm the fix survives the environment it was failing in, run the verification in the place that reproduced the miss. A cache that hits locally but misses in CI has not been fixed; it has been fixed for one environment. The reliable check is to compare a local dry-run against a dry-run captured inside CI (many CI providers let you print turbo run build --dry=json as a pipeline step) and confirm the diff is empty there too. If the remote cache is involved, verify that the CI run reports cache hit, replaying output rather than cache miss, executing — a local hit tells you the hash is stable, but only the remote hit tells you the key actually resolved against the shared cache.
Gotchas & edge cases
-
Some misses are correct. If a source file in
inputsreally changed, the miss is the cache working; only chase misses with no meaningful change. The symptom is a miss you cannot explain; the trap is assuming every miss is a bug. Before touching config, confirm from the diff that the differing input has no bearing on the output — ifsrc/index.tschanged, the task must rebuild, and no amount ofinputstuning should prevent it. Chasing a legitimate miss ends with an over-narrowedinputsglob that produces stale builds, which is a far worse failure than a slow one. Confirm by checking whether the differing file is actually consumed by the build; if it is, close the ticket. -
Ever-changing env values. A
BUILD_TIMESTAMPorCI_RUN_IDdiffers every run; move it toglobalPassThroughEnvso it does not enter the hash. The symptom is a task that misses on literally every invocation, including two back-to-back runs with no code change. The root cause is an environment variable whose value is different each time, so the env contributor to the hash is never the same twice. The fix is to identify it in theenvironmentVariablesdiff — it will be the entry whose value looks like a timestamp, UUID, or incrementing counter — and route it throughglobalPassThroughEnv(orpassThroughEnvon the task) so the task still sees it but it no longer contributes to the key. Confirm with two consecutive dry-runs: the env field should now be identical. -
globalDependenciesare global. A changedglobalDependenciesfile busts every task’s hash; check it when many tasks miss at once. The symptom is distinctive: not one task missing but the entire graph missing simultaneously after an innocuous change. The root cause is thatglobalDependenciesfiles (andglobalEnvvalues) are folded into every task’s hash by design, so editing a root.env,tsconfig.base.json, or anything else listed there invalidates everything at once. The fix is rarely to remove the global dependency — it is usually there for a reason — but to confirm the mass miss is expected. If it is not, the file should not be a global dependency; scope it to the tasks that actually consume it. Confirm by diffing theglobalHashfield, which is shared across all tasks. -
Cross-machine version pin. The
turboversion andturbo.jsonschema are in the hash; a version mismatch between machines causes universal misses. The symptom is a remote cache that never hits across machines even though every declared input matches — your laptop populates the cache and CI ignores it, or two developers never share entries. The root cause is that theturbobinary version is itself a hash input, soturbo@2.1.0andturbo@2.2.0compute different keys for byte-identical inputs. The fix is to pinturbothrough thepackageManagerfield and a committed lockfile so every machine resolves the same version. Confirm by comparing theturboversion reported at the top of--dry=jsonoutput on both machines before you bother diffing anything else — a version mismatch makes every other field’s diff meaningless.
CI integration
The environment where cache misses matter most is CI, and it is also where they are hardest to reproduce because the environment is disposable and its variables are injected by the provider, not by you. The practical technique is to make the dry-run output a first-class pipeline artifact: add a step that runs turbo run build --dry=json and uploads the result, so that when a run misses you can pull that JSON and diff it against a known-good local capture instead of guessing at what CI’s environment contained. This turns an unreproducible “it misses in CI” into the same one-field diff as everything else in this guide.
# Turborepo 2.x — emit the dry-run as a CI artifact for later diffing.
turbo run build --dry=json > turbo-dry.json # upload this as a build artifact
# In a follow-up investigation, diff the CI capture against a local hit:
diff <(jq -S '.tasks[] | select(.taskId=="web#build") | .environmentVariables' local-hit.json) \
<(jq -S '.tasks[] | select(.taskId=="web#build") | .environmentVariables' turbo-dry.json)
The most common CI-only cause is an environment variable that exists on the runner but not on your machine (or vice versa) and is declared in env. Provider-injected variables like VERCEL_GIT_COMMIT_SHA, GITHUB_RUN_ID, or CI frequently sneak into a task’s hash through a broad env wildcard such as "env": ["VERCEL_*"], which matches variables that change every commit. Scope wildcards tightly, and prefer passThroughEnv for anything the build reads but whose value must not gate the cache. A secondary CI cause is a shallow git clone: because the default inputs derives from git-tracked files, a clone that fetches different refs or a different depth can present a different file set, so ensure CI checks out the same tree state your local capture used.
When a legitimate miss is the right answer
It is worth stating plainly that the goal is not “zero misses.” A cache that never misses is either trivial or broken — it means either nothing ever changes or the inputs are so narrow that real changes are being ignored, which ships stale artifacts. The correct target is that every miss corresponds to a real change in what the task produces, and every unchanged task hits. The dry-run diff is how you enforce that invariant, not how you eliminate misses. If the diff shows the differing input is a genuine source file, a real dependency bump, or a build-affecting env value, the miss is the system working correctly and the investigation is over. Spend the effort on the misses that have no output-relevant explanation; leave the honest ones alone.
Related
- Remote caching and distributed build coordination — the parent cluster on the hashing model.
- Configuring remote cache with Turborepo and Vercel — the remote setup where cross-machine misses appear.
- esbuild & Turbopack version compatibility reference — the version pinning that keeps hashes consistent.