· monorepo / turborepo / pnpm

Turborepo monorepo pitfalls we learned the hard way

5 production pitfalls that consistently bite mid-size teams 3–12 months into a Turborepo + pnpm monorepo migration — and exactly how to fix each one.

By toolchew

1,919 words · 10 min read

The setup is always smooth. You run npx create-turbo@latest, your monorepo packages install cleanly, turbo build finishes in 12 seconds. You announce the migration to the team.

Three months later, CI takes 4 minutes per PR. TypeScript misses errors in one package while producing 47 false positives in another. Your lockfile generates a conflict on one in five merges. None of these problems announce themselves — they accumulate quietly, then surface all at once.

These are not beginner mistakes. They are timing mistakes — problems that require a certain codebase mass and certain team habits to trigger. Here are the five that consistently hit teams hardest, with the exact fix for each.

Who this is for

Teams of 5–20 developers who are 3–12 months into a monorepo built on Turborepo and pnpm workspaces. If you’re still evaluating whether to adopt a monorepo, that’s a separate question. If you’re running a custom Bazel or Pants setup, these specifics won’t apply.

If you’re still in the setup phase, How to Set Up a pnpm + Turborepo Monorepo from Scratch covers the initial configuration before these pitfalls have time to develop.

All version numbers below are pinned to May 2026:

  • Turborepo 2.x (pipelinetasks rename landed in 2.0, June 4, 2024)
  • pnpm 10.x (Catalogs in v9.5 Jul 2024; catalogMode in v10.12.1; cleanupUnusedCatalogs in v10.15.0)
  • TypeScript 5.8.x

Pitfall 1: Phantom cache invalidation

Turborepo’s remote cache is the whole value proposition for CI. When it works, a cold build on an untouched package drops from 8 minutes to 15 seconds. When it silently fails, you’re paying for CI time you thought you had optimized away.

The failure mode: turbo build logs FULL TURBO — the checkmark that means a cache hit — but downstream tasks can’t find the output artifacts. The build succeeds because Turborepo restored nothing and re-ran everything, which also “succeeded.” No error. No warning. You only notice by watching build times.

The cause is almost always the outputs field in turbo.json.

Turborepo uses a content hash of your task’s inputs to generate a cache key. When restoring a hit, it uses outputs to know which files to write back to disk. If outputs is wrong or missing, the restoration writes nothing. The next task in the graph proceeds against whatever stale state is already on disk.

How to diagnose: run with --summarize:

turbo build --summarize

This writes .turbo/runs/<run-id>.json for each run — a machine-readable record of every task’s input hash, output paths, and cache status. Open it and verify that the globs in outputs match the files your build tool actually produces.

The most common wrong configuration:

{
  "tasks": {
    "build": {
      "outputs": ["dist"]
    }
  }
}

["dist"] hashes the directory entry, not the directory contents. It matches the existence of the dist folder, not what’s inside it. Use ["dist/**"] instead.

The correct configuration for a TypeScript library package:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    },
    "lint": {}
  }
}

Note tasks, not pipeline. Turborepo 2.0 (June 4, 2024) renamed the key. If your config still uses pipeline, you’re on an old version and the cache optimizations introduced in 2.0 aren’t applying.

If cache is correct but cold builds are still slow: that’s a separate problem — runner startup time, not cache misses. Depot offers arm64 CI runners. Worth measuring against your baseline, but only after your cache hit rate is healthy — otherwise you’re paying for fast hardware to run slow unnecessary rebuilds.

Pitfall 2: Shared dependency version drift

Six months in, react exists at three versions across your 12 packages. typescript is at two. zod is at four because one early package pinned to 3.20.0 before the breaking changes in 3.22.0 and nobody noticed when the rest upgraded. You have parallel module instances in your bundle and TypeScript errors that only reproduce in certain packages.

The classic fix — resolutions in the root package.json — forces a single version at resolution time. It doesn’t prevent someone from adding "react": "^18.3.1" to a new package. The drift returns.

The correct fix is pnpm Catalogs, introduced in pnpm v9.5 (July 2024).

Setup: define all shared versions once in pnpm-workspace.yaml:

packages:
  - 'packages/*'
  - 'apps/*'

catalog:
  react: ^19.1.0
  react-dom: ^19.1.0
  typescript: ^5.8.0
  zod: ^3.24.0
  vitest: ^3.1.0
  '@types/react': ^19.1.0

Reference them in each package:

{
  "name": "@acme/ui",
  "dependencies": {
    "react": "catalog:",
    "react-dom": "catalog:",
    "zod": "catalog:"
  },
  "devDependencies": {
    "typescript": "catalog:",
    "@types/react": "catalog:"
  }
}

catalog: without a suffix pulls from the default catalog:. You can define named catalogs for parallel version lines during migrations:

catalog:
  react: ^19.1.0

catalogs:
  react18:
    react: ^18.3.1
    react-dom: ^18.3.1

Then packages in the legacy tier reference catalog:react18 until migration is complete.

Enforce it: if you’re on pnpm v10.12.1+, add catalogMode: strict to pnpm-workspace.yaml:

catalogMode: strict

With strict, pnpm install fails when any package declares a pinned version instead of using the catalog. You catch drift at install time. Before catalogMode, the catalog was advisory — overlooked in code review.

If you’re still deciding whether pnpm is worth switching to, pnpm vs npm covers what concretely changes when you make the move.

On v10.15.0+, cleanupUnusedCatalogs removes catalog entries with no references, keeping pnpm-workspace.yaml accurate as packages are deleted or renamed.

Pitfall 3: Circular package graphs

Circular dependencies produce no error messages. They manifest as:

  • TypeScript project references rebuilding from scratch on every run instead of using incremental output
  • turbo build caching nothing between two packages that import each other
  • tsc --build running for 90 seconds on a 5-file change

The packages in a cycle compile as a single unit. That defeats both incremental TypeScript compilation and Turborepo’s task graph caching. Turborepo cannot build half of a cycle and cache the result.

How to detect: use dpdm, not madge:

pnpm add -D dpdm
dpdm --circular './packages/*/src/index.ts'

Madge fails in pnpm workspaces because it follows node_modules symlinks inconsistently. dpdm walks the actual TypeScript import graph.

Output when a cycle exists:

Circular Dependencies!
[0] packages/auth/src/index.ts
    -> packages/utils/src/token.ts
    -> packages/auth/src/index.ts

The fix is always structural. One of the two packages in the cycle is doing too much. In the example above, packages/utils should not know about packages/auth. The fix is to extract token.ts into a third package — packages/crypto or packages/primitives — that both auth and utils can depend on without creating a cycle.

There is no in-place fix for a cycle. You have to change the dependency graph.

Prevent recurrence: add dpdm --circular to CI as a required check:

pnpm dpdm --circular './packages/*/src/index.ts'

Cycles don’t appear on day one. They accumulate from small, individually reasonable imports. Catching one at PR time (when a single import was added) costs 5 seconds. Catching three months of accumulation costs a refactor.

Pitfall 4: The typecheck task graph lies

This pitfall is the hardest to see because everything looks green. TypeScript runs. No errors. CI passes. But downstream packages are consuming type declarations from three weeks ago, and nobody has noticed because the stale types happened to be compatible.

The failure mode: packages/ui uses tsc --noEmit as its typecheck task. --noEmit is correct for application packages — you want error detection without emitting files. For library packages that other packages import, it’s wrong. --noEmit emits nothing to dist/. When packages/app imports from packages/ui, TypeScript resolves against whatever .d.ts files are already in packages/ui/dist/ — which may be from the last full local build, or may be missing entirely.

TypeScript’s incremental compilation and project references both require .d.ts output files to be present. Without them, TypeScript re-analyzes source on every run instead of using precompiled declarations. On large packages, this adds minutes to tsc.

Fix part one: change library packages to emit declaration files:

{
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "emitDeclarationOnly": true,
    "declaration": true,
    "declarationMap": true
  }
}

emitDeclarationOnly writes .d.ts files to dist/ without writing .js. For a library package consumed by other TypeScript packages in the same monorepo, this is the correct mode. The bundler (Vite, esbuild, tsup) handles the .js output; TypeScript handles the types.

Fix part two: update turbo.json so typecheck runs after upstream builds:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    }
  }
}

"dependsOn": ["^build"] means “build all my dependencies before running my typecheck.” Without this, Turborepo may run typecheck on packages/app before packages/ui has written its .d.ts files.

On TypeScript Project References: Turborepo’s official TypeScript guide explicitly recommends against combining Project References with Turborepo’s task graph. Project References create a second dependency graph that can contradict turbo.json’s dependsOn. If you’ve already set up composite: true and references: [] in your tsconfig.json, you can leave them — they don’t hurt. But don’t rely on them for build ordering. Let turbo.json own that.

Pitfall 5: The lockfile merge-conflict tax

On a team of 10 developers with 6 open PRs, you’re resolving lockfile conflicts on roughly 20% of merges. Each resolution takes 5–15 minutes, requires understanding the pnpm-lock.yaml format, and carries a non-zero chance of introducing a subtle dependency regression. Over a quarter, this is a meaningful tax.

pnpm-lock.yaml conflicts happen because every pnpm install rewrites the entire file, and the format doesn’t merge cleanly when two branches have each added different packages.

pnpm introduced gitBranchLockfile to address this. Enable it in pnpm-workspace.yaml:

gitBranchLockfile: true

With this setting, pnpm writes a branch-specific lockfile named pnpm-lock.<branch-sanitized>.yaml alongside the default pnpm-lock.yaml. Feature branch work uses the branch lockfile. The main lockfile is only updated on merge.

When you pull main into your feature branch:

pnpm install --merge-git-branch-lockfiles

This merges your branch lockfile with the updated main lockfile automatically. The common case — two branches independently adding packages that don’t interact — becomes a no-op. Conflicts narrow to situations where two branches genuinely changed the same dependency version.

One caveat: gitBranchLockfile adds files to your repository. You’ll see pnpm-lock.feature-xyz.yaml files in git status. Add a .gitignore rule for them if you don’t want to commit per-branch lockfiles, or commit them to make CI reproducible on that branch. Both work; choose one approach and document it.

Monorepo production-readiness checklist

After 6–12 months with a Turborepo + pnpm monorepo, these three checks distinguish a setup aging well from one quietly accumulating technical debt.

1. Verify your cache outputs are correct

turbo build --summarize
# Open .turbo/runs/<run-id>.json
# Confirm outputs globs match actual build artifacts on disk

If FULL TURBO appears but build time hasn’t dropped, your outputs configuration is wrong.

2. Add circular dependency detection to CI

pnpm dpdm --circular './packages/*/src/index.ts'

Run this as a required CI step. Zero cost when there are no cycles; catches the expensive ones before they compound.

3. Switch library packages to emitDeclarationOnly and enable gitBranchLockfile

// tsconfig.json in library packages
{ "compilerOptions": { "emitDeclarationOnly": true } }
# pnpm-workspace.yaml
gitBranchLockfile: true

These two changes eliminate the two highest sources of daily friction at 10+ person team size.

None of this requires switching tools or a significant refactor. It requires reading the docs at the right moment — which is 3 months after you needed to.

References