Turborepo · beginner

Turborepo build is slow despite a cache: identify the uncached task boundary

Turborepo builds that ignore cache are almost always bounded by a single uncached task graph boundary. This guide shows how to identify which task is missing a cache hit, why its inputs/outputs are untracked, and how to prove the fix with a HIT/MISS trace.

The symptoms

  • Total turbo run duration stays roughly the same on repeated runs even when CI shows cache restoration lines for other tasks.
  • Turbo reports a full HIT for sibling packages but the slowest task logs MISS or 'cache bypass' on every invocation.
  • Wall-clock time of the build pipeline is dominated by one task, while task count and source diff are small.
  • Local turbo run --summarize output shows one task with 'cache': false in the task summary JSON, while neighbors show 'cache': true.

Likely causes

  • The slow task's pipeline definition omits outputs, so Turbo cannot store or restore a tarball for it.
  • Inputs glob for the task includes volatile files such as lockfiles, env files, or generated directories that change every run.
  • dependsOn references a downstream task whose outputs are not declared, breaking the cache key chain.
  • A global dependency like an environment variable or globalFile is changing the hash without a corresponding code change.
  • The task is configured with 'cache': false or 'outputs' is set to an empty array in turbo.json for that pipeline entry.

First ten minutes

  1. 01Confirm the symptom by running the same turbo command twice with --summarize and comparing the duration waterfall; the unchanged task is your suspect boundary.
  2. 02Inspect turbo.json for the slow task and verify that 'outputs' is a non-empty array of path globs that match the artifacts the task actually writes.
  3. 03Diff the task's 'inputs' against the files it actually reads; mark any generated, lock, or env files that are not part of the source identity.
  4. 04Capture the per-task log lines and look for MISS, 'cache bypass', or 'cache miss, outputs are invalid' markers to confirm the uncached boundary.
  5. 05Check whether the slow task lists 'env' or 'globalEnv' entries that the shell actually exports, since each declared key widens the hash.

Evidence to collect

  • One turbo run with --summarize=/path/to/summary.json so the per-task timing and cache decision are persisted.
  • Raw log output of the slow task showing the exact cache decision string emitted by Turbo.
  • The current turbo.json pipeline entry for the slow task, including pipeline, outputs, inputs, dependsOn, env, and globalEnv.
  • Hash inputs for the task: the resolved file list and any non-file global deps that contributed to the hash.
  • A second run of the same command with no source change, to confirm whether the task is reproducible or has unstable inputs.

Where to look

  • turbo.json at the repository root, focusing on the pipeline entry whose task name matches the slow step.
  • The package.json scripts field of the package that owns the slow task, to confirm the exact command string Turbo executes.
  • The output directory the task writes to on disk, to verify that the path glob in 'outputs' actually matches real artifacts.
  • Workspace packages listed in dependsOn; their own pipeline entries must also declare outputs for the cache key chain to hold.
  • CI log section for the slow task, where cache decision strings are emitted before the command body runs.

Diagnostic steps

  1. 01Run the pipeline with --summarize and grep the summary JSON for the slow task name; record 'cache', 'duration', and 'hash' fields.
  2. 02From the summary, isolate the task whose 'cache' value is false on a clean repo with no source changes; that is the boundary.
  3. 03Hash the task inputs manually by running turbo run <task> --dry=json and inspecting the resolved dependencies and globalDependencies lists.
  4. 04Compare the dry-run hash of the slow task against the same task on a previous green run; a difference confirms the input drift.
  5. 05Re-run the task standalone with no extra flags and observe whether the cache decision flips to HIT; if it does, the boundary is a dep, not the task itself.
  6. 06Validate the 'outputs' glob by checking which files exist after the task finishes; any file written outside the declared glob is invisible to the cache.

Common mistakes

  • Assuming a cache hit on a sibling task means the whole pipeline is cached; per-task cache decisions are independent.
  • Adding 'src/**' to inputs without excluding generated files, causing the hash to change whenever the build emits a file under src.
  • Setting 'cache': false globally on a pipeline to silence a flapping task instead of fixing the unstable inputs.
  • Declaring outputs as a relative path that does not match the task's actual working directory or the package's root.
  • Forgetting that dependsOn on a workspace package uses that package's pipeline outputs, not its filesystem tree, for the cache key.

Safe fixes

  • If 'outputs' is empty or missing for the slow task, add the exact path glob that the task produces, validated against the on-disk artifacts after one run.
  • If inputs include volatile files, restrict 'inputs' to source-only globs and move lockfile or env-file tracking to globalDependencies or env.
  • If env or globalEnv is overdeclared, narrow it to the precise variables the task reads, so each declared key only widens the hash when it actually changes.
  • If a depended-on workspace task has no outputs declared, add outputs at that boundary so the upstream hash is stable.
  • After any change, re-run with --summarize twice in a row; the slow task should report 'cache': true on the second run with no source diff.

Prove the fix

  1. 01Second turbo run with no code change shows the previously slow task with 'cache': true in the summary JSON and a near-zero duration entry.
  2. 02turbo run <task> --dry=json returns an identical hash for the task across two consecutive invocations against the same working tree.
  3. 03Cache decision logs for the task show 'cache hit' on the second run, and the output tarball path is materialized from cache rather than re-written by the task body.
  4. 04End-to-end pipeline duration on the second run drops by approximately the duration of the previously uncached task, holding all other variables constant.

Prevention and next steps

  • Adopt a rule that every task in turbo.json must declare outputs, and review that rule in code review for any new pipeline entry.
  • Keep env and globalEnv entries minimal and audited; treat each declared key as a hash amplifier that must be justified.
  • Run --summarize in CI on a periodic job so a regression in cache hit rates surfaces before it is blamed on the build itself.
  • Treat 'cache': false as a temporary quarantine with a tracking ticket, not a permanent configuration, to keep uncached boundaries visible.

Safe commands and checks

turbo run <task> --summarize=/path/to/summary.json
turbo run <task> --dry=json
turbo run <task> --filter=<package-name> --summarize=/path/to/summary.json
turbo run <task> --force
cat /path/to/summary.json
grep -E 'cache (hit|miss|bypass)' /path/to/task.log