Speeding Up Turborepo Builds: Cache Outputs, Task Graph, Remote Cache and Filtering

Key takeaways

Five areas that decide how fast a Turborepo build is: cache outputs and inputs, the dependsOn task graph, environment variables in the hash, Remote Cache, and filtering to affected packages. Examples use the Turborepo 2.x `tasks` key, which replaced `pipeline`.

Turborepo does two things that make monorepo builds faster: it runs tasks in parallel according to a dependency graph, and it skips tasks whose inputs have not changed by restoring their previous outputs from a cache. Almost every “Turborepo is slow” problem I have looked at comes down to one of those two not working as intended: the cache misses when it should hit, restores the wrong thing, or the graph forces work to happen in sequence.

This post goes through the five places to look, in the order I would check them. The examples use Turborepo 2.x. If your turbo.json still has a top-level pipeline key, you are on the 1.x format: 2.0 renamed it to tasks, and npx @turbo/codemod migrate performs the rename along with the other 2.0 changes.

// Turborepo 1.x
{ "pipeline": { "build": { "dependsOn": ["^build"] } } }

// Turborepo 2.x
{ "tasks": { "build": { "dependsOn": ["^build"] } } }

Get outputs right

When a task hits the cache, Turborepo replays its logs and restores the files listed in outputs. If outputs is empty or wrong, the task still counts as cached, but the files your next task needs are not restored. The result is either a broken build on a fresh CI runner or a workaround where people disable caching altogether.

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [
        ".next/**",
        "!.next/cache/**",
        "dist/**"
      ]
    },
    "test": {
      "outputs": ["coverage/**"]
    },
    "lint": {}
  }
}

Two details matter here. First, exclude .next/cache/**: it is Next.js’s own build cache, it can be large, and storing it in Turborepo’s cache mostly costs upload and download time. Second, list only what the framework actually writes. Globs for frameworks you do not use are harmless but make the config harder to reason about.

A task like lint that produces no files does not need outputs; its cached result is just the log.

Keep the hash stable with inputs and env

The cache key for a task is a hash of its inputs: by default, all files in the package that git tracks, plus the resolved dependencies, the task configuration, and declared environment variables. Two things commonly break that:

Files that change every run. A generated file with a timestamp, a build-info file, or a changelog in the package will change the hash on every run. Narrow the task’s inputs so only files that affect the output count:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "inputs": ["$TURBO_DEFAULT$", "!README.md", "!**/*.test.ts"]
    }
  }
}

$TURBO_DEFAULT$ keeps the default set and lets you subtract from it, which is safer than listing everything by hand. A hand-written list that forgets tsconfig.json or a config file gives you the opposite problem: a cache hit when the output should have changed.

Environment variables. Declare every variable that changes the build output in env (per task) or globalEnv. Otherwise changing it does not change the hash, and Turborepo restores an output built with the old value.

{
  "globalDependencies": [".env"],
  "tasks": {
    "build": {
      "env": ["NEXT_PUBLIC_*", "API_URL"],
      "outputs": ["dist/**"]
    }
  }
}

In 2.x, strict environment mode is the default: variables that are not declared are not passed to the task at all. That is annoying the first time a build fails with a missing variable, but it is the behavior you want, because it makes an undeclared variable fail loudly instead of silently poisoning the cache. Variables that must reach the task without affecting the hash, such as tokens used only for fetching, go in passThroughEnv.

Keep the task graph lean

dependsOn decides what must finish before a task can start. ^build means “build of my dependencies”; build without the caret means “my own package’s build”. Adding more than necessary serializes work that could run in parallel.

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "lint": {},
    "check-types": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    }
  }
}

A common mistake is "build": { "dependsOn": ["^build", "lint", "test"] }. It reads as “don’t build unless tests pass”, but it means every build waits for linting and tests, even locally. If you want a gate, express it in CI by running turbo run lint check-types test build and letting the graph run them side by side.

Whether test should depend on build or ^build depends on your tests. If they import from the package’s own dist, they need build; if they run against source through a test runner that compiles on the fly, ^build (or nothing, if dependencies are consumed from source) is enough and lets tests start sooner.

About --parallel: it tells Turborepo to ignore the dependency graph. That is useful for long-running dev servers, but on build it can start a package before the packages it imports are built. For persistent tasks in 2.x, mark them "persistent": true and "cache": false instead:

{
  "tasks": {
    "dev": { "cache": false, "persistent": true }
  }
}

Use --concurrency if a machine runs out of memory with many tasks at once; the default already runs independent tasks in parallel.

Share the cache with Remote Cache

The local cache lives in the repository’s .turbo directory, so it only helps the machine that produced it. Remote Cache stores task outputs on a server so a build produced on one machine can be reused on another, which matters most in CI where every runner starts empty.

# Vercel-hosted Remote Cache
npx turbo login
npx turbo link

In CI, provide the credentials as environment variables:

- name: Build
  run: pnpm turbo run build
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

If you cannot send build artifacts to a third party, the Remote Cache API is documented and there are self-hosted implementations, for example the open-source ducktors/turborepo-remote-cache server. Point Turborepo at it with the TURBO_API environment variable (or remoteCache.apiUrl in turbo.json) along with a token it accepts.

Two cautions. Anyone who can write to the cache can influence what other machines restore, so use a token with write access only in trusted CI, and consider remoteCache.signature to sign artifacts. And Remote Cache amplifies mistakes from sections 1 and 2: a wrong hash now serves a stale output to the whole team, not just to you.

Build only what changed

On a pull request you rarely need to build every package. Turborepo’s filter syntax selects packages by name, directory, or git changes:

# Packages changed since main, plus everything that depends on them
turbo run build test --filter=...[origin/main]

# One app and all of its dependencies
turbo run build --filter=web...

# One package only
turbo run build --filter=web

# Everything under apps/
turbo run build --filter="./apps/*"

Recent 2.x releases also provide --affected, which does the same change detection against the default branch without writing the filter yourself. Git-based filters need history: with actions/checkout, set fetch-depth: 0 (or at least enough depth to reach the base commit), otherwise the comparison fails or selects everything.

Filtering and caching complement each other. Filtering avoids scheduling tasks for unaffected packages at all; caching makes the tasks that do run cheap when their inputs have been built before.


Putting it together

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [".env"],
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", "!README.md"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"],
      "env": ["NEXT_PUBLIC_*"]
    },
    "lint": {},
    "check-types": { "dependsOn": ["^build"] },
    "test": { "dependsOn": ["^build"], "outputs": ["coverage/**"] },
    "dev": { "cache": false, "persistent": true }
  }
}

A GitHub Actions job using it:

name: CI
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run lint check-types test build --filter=...[origin/main]
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Messages that point at a misconfiguration

A few warnings and errors show up repeatedly when a repository is first moved to Turborepo or upgraded to 2.x, and each one maps to one of the sections above.

WARNING no output files found for task web#build. Please check your outputs key in turbo.json. The task ran, but none of the globs in outputs matched anything. The usual cause is a framework writing to a directory you did not list (build/ instead of dist/, or a Vite library writing to lib/). The cache entry is still saved, just empty, so the next cache hit “succeeds” and restores nothing. Treat this warning as an error; it is exactly the broken-fresh-runner scenario from section 1.

Could not resolve workspaces together with Missing packageManager field in package.json. Turborepo 2.x uses the root packageManager field (for example "packageManager": "[email protected]") to know how to read workspaces and the lockfile. Adding it also lets Corepack pin the same package manager version for everyone, which removes a separate source of lockfile churn and cache misses.

A variable that is set in the shell is undefined inside the build. That is strict environment mode from section 2, not a Turborepo bug. Declaring the variable in env fixes it and adds it to the hash; passThroughEnv fixes it without affecting the hash. Note that Turborepo does not load .env files into the task environment itself; listing .env in globalDependencies only makes changes to the file invalidate the cache, while your framework or a tool like dotenv still does the loading.

The problem that tends to eat the most time in a migration is a cache that hits perfectly locally and misses on every CI run. A typical cause is a root-level file that CI rewrites before the build, such as a generated version file, pulled into globalDependencies by a broad glob. Comparing the --dry-run=json output from a local run with the one from CI shows the differing global hash input directly; guessing at it rarely does.

Measuring before and after

Don’t trust anyone’s percentage, including your own guess. Turborepo gives you the tools to see what it is doing:

# Show what would run, the hashes, and cache status, without running anything
turbo run build --dry-run

# Write a JSON run summary to .turbo/runs/
turbo run build --summarize

# Render the task graph
turbo run build --graph=graph.html

The dry run is the fastest way to answer “why did this miss the cache?”: run it twice and compare the hash inputs for the task. If a file or environment variable you did not expect shows up, that is your culprit. The run summary is useful for comparing total time and per-task time before and after a change, and the graph shows whether tasks you expected to run in parallel are actually chained.


Frequently Asked Questions (FAQ)

Q. Why does Turborepo replay a cached build with the wrong environment values?

A. Turborepo computes the cache hash from task inputs, and environment variables are only part of that hash if you declare them in env or globalEnv (and env files through globalDependencies or inputs). If a build reads API_URL but it is not declared, changing the variable still produces a cache hit, and the old output is restored. Declare every variable that affects build output so a change forces a real rebuild.