If Your Turborepo Cache Hit Rate Is Low, the Cause Is Almost Always `dependsOn`, `inputs`, or `outputs` in `turbo.json`
If you've adopted Turborepo in a monorepo but the cache isn't hitting properly, the cause is almost always one of three things in turbo.json: inputs, outputs, or dependsOn. The whole build re-running after editing a single line in README.md, or the cache showing a hit but the dist/ folder being empty — I spent a long time struggling with both of these when I first integrated Turborepo. Getting the execution order right with dependsOn, narrowing the hash calculation scope with inputs, and explicitly specifying the build artifact paths with outputs — these three things determine your Turborepo cache hit rate.
Turborepo is a build system for JavaScript/TypeScript monorepos developed by Vercel. Its core feature is task caching — in a structure where multiple packages live in a single repository, it avoids rebuilding what has already been built. Before executing a task, it calculates a hash of the inputs, and if the same hash already exists in the cache, it skips re-execution. You specify the hash calculation scope and artifact storage paths directly in turbo.json.
This article examines what each of the three settings does, what symptoms appear when a value is wrong, and configuration patterns you can use directly in production. At the end, you'll find a turbo.json template you can copy and apply today.
Core Concepts
How Turborepo Determines Cache Hits
Before executing a task, Turborepo creates a "fingerprint" for that task. It calculates this fingerprint by combining the contents of files specified in inputs, the values of environment variables declared in env, and the hash of upstream tasks connected via dependsOn. If a cache entry with the same hash exists from a previous run, it restores the files specified in outputs and skips execution.
Input file hash + Environment variable values + Dependent task hash
↓
Cache Key
↓
Hit → Restore artifacts / Miss → Actually executeThe three settings each handle a different stage of this flow.
| Setting | Role | Symptom When Wrong |
|---|---|---|
dependsOn |
Defines execution order and dependencies | Current task runs before dependent package builds |
inputs |
File scope included in hash calculation | Cache misses on unrelated file changes |
outputs |
Artifact paths to store in cache | Shows as a hit but build files are missing |
dependsOn — Execution Order Must Be Correct for the Cache to Mean Anything
dependsOn is used in two forms. With the ^ prefix, it runs the same-named task in the packages the current package depends on (dependencies/devDependencies in package.json) first. Without the prefix, it refers to a different task within the same package.
{
"tasks": {
"build": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"]
}
}
}
^build— Runs thebuildtask of all packages the current package depends on first. This prevents the current package's build from starting before the build artifacts of its dependency packages are ready.
Here, test.dependsOn: ["build"] (no caret) means the same package's build must complete first. This pattern is needed when tests reference build artifacts like type declaration files (.d.ts). For simple unit tests that don't depend on build output, you can omit this entry.
If you want to explicitly specify only a particular package, you can also use the "pkg-a#build" format. This is useful in situations where explicit specification is clearer — such as when a specific shared library must be built first, rather than relying on automatic dependency graph traversal.
inputs — The Narrower the Hash Scope, the Higher the Hit Rate
If you leave the default as-is, all Git-tracked files inside the package directory are included in the hash calculation. Git-tracked files means all files that Git detects changes in — those not excluded by .gitignore. This is why the build cache gets invalidated when you only touch documentation or test fixtures.
{
"tasks": {
"build": {
"inputs": ["src/**", "package.json", "tsconfig.json"]
},
"lint": {
"inputs": ["src/**", ".eslintrc.*"]
},
"test": {
"inputs": ["src/**", "tests/**", "jest.config.*"]
}
}
}There's one important caveat here. The moment you explicitly specify inputs, the automatic exclusion logic based on .gitignore is disabled. If you want to maintain the default behavior while excluding only specific files, it's much safer to use the $TURBO_DEFAULT$ microsyntax.
{
"tasks": {
"lint": {
"inputs": ["$TURBO_DEFAULT$", "!README.md", "!CHANGELOG.md"]
}
}
}
$TURBO_DEFAULT$— Including this ininputspreserves the default behavior of "all files in the package tracked by Git." You can then append!patternto selectively exclude files, making it much safer than completely replacing the default behavior.
outputs — Omitting This Makes Cache Hits Meaningless
Honestly, I think this is where I wasted the most time. If you don't configure outputs, Turborepo caches execution logs but does not store the build artifact files. This means even when a cache hit occurs, the dist/ folder is empty, and downstream package builds that need those files will ultimately fail.
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": [
"dist/**",
".next/**",
"!.next/cache/**"
]
},
"test": {
"inputs": ["src/**", "tests/**"],
"outputs": ["coverage/**"]
}
}
}There's a reason .next/cache/** is excluded with !. If Turborepo stores the internal build cache directory that Next.js manages on its own, the cache store grows unnecessarily large, and the hit rate can actually drop as this large directory is serialized and deserialized every time.
Practical Application
Example 1: Full turbo.json Configuration for a Typical Monorepo
This is the configuration you'll most commonly see in production, reproduced as-is. Starting with Turborepo 2.x, the recommended approach is to define tasks directly under tasks rather than the old pipeline key (pipeline still works for backward compatibility, but new projects should use tasks).
In this configuration, the item I made mistakes with most often is test.dependsOn. I recommend only including ["^build"] when the build result must come first — such as for tests that reference type declaration files. In projects where this isn't the case, removing this entry can reduce unnecessary serial execution.
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": ["NODE_ENV", "CI"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tests/**", "jest.config.*"],
"outputs": ["coverage/**"]
},
"lint": {
"inputs": ["$TURBO_DEFAULT$", "!README.md", "!*.md"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}The intent behind each setting is summarized below.
| Setting | Value | Intent |
|---|---|---|
build.dependsOn |
["^build"] |
Start the current build after dependent package builds are complete |
build.inputs |
src/**, package.json, tsconfig.json |
Prevent cache invalidation on non-source file changes |
build.outputs |
dist/**, .next/** (excluding internal cache folder) |
Store build artifacts in cache |
test.dependsOn |
["^build"] |
Support tests that depend on build output such as type declaration files |
lint.inputs |
$TURBO_DEFAULT$ + markdown exclusions |
Prevent lint re-runs on documentation file changes |
dev.cache |
false |
Caching is inherently meaningless for the dev server |
Example 2: Reflecting Environment Variables in the Cache Key
If environment variables like API_URL or NEXT_PUBLIC_* change but old build artifacts are still being restored, those variables are not declared in env or globalEnv. This is the most dangerous case — it silently deploys incorrect artifacts.
In particular, Next.js's NEXT_PUBLIC_* variables are inlined directly into the bundle at build time, not at runtime. If these values change, the build artifact itself changes, so they must be reflected in the cache key. If staging and production use different NEXT_PUBLIC_API_URL values, without this variable in env, both environments will reuse the same cache.
{
"globalEnv": ["NODE_ENV", "CI"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"env": ["API_URL", "NEXT_PUBLIC_APP_VERSION"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": ["dist/**"]
}
}
}
globalEnvvsenv—globalEnvis for global environment variables that affect the cache key of all tasks. Variables likeNODE_ENVorCIthat can change results in any task belong here. Variables only relevant to a specific task should be declared in that task'senvarray for greater precision.
Starting with Turborepo 2.2, a warning is printed when a task uses an environment variable not declared in turbo.json. Paying close attention to these warnings when first introducing Turborepo is a big help for finding missing environment variables.
Example 3: Tracing Why Cache Misses Are Occurring
A workflow for tracking which files are changing the hash when the cache hit rate is lower than expected. This is the sequence I follow whenever cache misses are frequent in CI.
# 1. Check which tasks will run without actually executing
turbo run build --dry=json
# 2. Run twice to check if the hash changes
turbo run build --summarize
# Compare the inputs section in the JSON files in the .turbo/runs/ folder
# 3. Run only tasks affected by changed packages (useful for PR build optimization)
turbo run build --affectedUsing the --summarize option generates a run summary JSON in the .turbo/runs/ folder. If the hash values differ between two runs of the same task, you can compare the inputs entries in the JSON files to immediately find which files changed.
--affected is more of an optimization option for "running only tasks affected by changed packages" than a cache debugging tool, so its role is slightly different. It can be used in situations with limited change scope — like PR builds — to run only the necessary tasks instead of the full build.
Pros and Cons Analysis
Pros
| Item | Details |
|---|---|
| Fine-grained cache control | Cache scope can be adjusted down to individual files using glob patterns |
| Remote Cache sharing | When integrated with Vercel Remote Cache, cache is shared between team members and CI, reducing repeated build costs |
| Environment variable hash inclusion | Build environment differences are included in the cache key, preventing incorrect artifact restoration |
| Incremental execution | The --affected flag allows running only tasks affected by changed packages |
Cons and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Initial setup cost | Getting inputs/outputs right for each package takes time |
Can be validated incrementally with --dry=json and --summarize |
.gitignore disabled when inputs is explicit |
With explicit inputs settings, auto-excluded files may be included in the hash |
Can use the $TURBO_DEFAULT$ microsyntax |
| Missing environment variable declarations | Old cache is restored even when undeclared variables change | Can actively use warning messages in version 2.2 and above |
| Remote Cache costs | Vercel Remote Cache is provided without limits on Team plans and above | Can explore self-hosted Remote Cache solutions |
Most Common Mistakes in Production
If the cons table is a summary of "what the problems are," the following is about what these mistakes actually lead to in the field.
- Not using
outputsat all —FULL TURBOis printed in the build log, but thedist/folder is empty, causing the next package build to fail entirely. This is a classic pattern where you spend hours wondering why the build is breaking when the cache clearly seems to have hit. - Leaving
inputsat the default — Updating a single line inCHANGELOG.mdcauses the entire monorepo build to re-run. When you open theturbo.jsonof teams with low CI cache hit rates, theinputsentry is usually missing entirely. - Not declaring environment variables in
env— Staging and production use differentAPI_URLvalues, but the same bundle is restored for both environments. The problem is that discovery itself is delayed because incorrect artifacts are deployed without any errors.
Closing Thoughts
Explicitly specifying outputs to store artifacts in the cache, narrowing the hash scope with inputs to focus on source files, and declaring environment variables in env or globalEnv — just getting these three right will bring a noticeable improvement in your Turborepo cache hit rate.
There are 3 steps you can start with right now.
- You can examine your current task configuration with the
turbo run build --dry=jsoncommand. If there are tasks with emptyoutputs, those are the top priority for improvement — just filling in this field will start restoring build artifacts correctly after cache hits. - You can run
turbo run build --summarizetwice in a row and compare the hash values in the JSON files in the.turbo/runs/folder. If the hash changes, check theinputssection for which files changed, then exclude those files frominputsor narrow them down with a$TURBO_DEFAULT$and!patterncombination to reduce unnecessary cache misses. - You can check whether environment variables referenced via
process.envin a task are declared inenvorglobalEnv. If you're using Turborepo 2.2 or above, the warning messages printed at build time will greatly reduce the work of finding missing variables, and this check alone can prevent incorrect cache restoration issues between staging and production.