I went to audit build caching in a pnpm + Turborepo monorepo and found two things. The root build task had not executed once in 71 days, and after fixing that, the cache reported success while writing zero files to disk.
Neither failure produced an error. That is why both survived so long.
The root task in Turborepo was never running
The root package.json had the script every Turborepo repo starts with.
{ "scripts": { "build": "turbo run build" } }
Running it gave this:
$ npx turbo build --filter=blog
x Failed to add workspace "dreamstore" from "apps/dreamstore/package.json",
| it already exists at "apps/dreamstore-app-master/package.json"
Two workspaces declared the same name field. One was the live app, the other was legacy code kept around as a migration reference. The commit that pulled the legacy tree into the workspace glob was dated June 1. I measured this on August 11, which makes 71 days.
Three things kept it invisible.
pnpm resolves workspaces by path, not by name, so pnpm install worked fine throughout. Individual builds were being run directly as pnpm --filter blog build, which never touches turbo. And the failure happens during graph construction, before any task runs, so there is no partial output to notice.
The .turbo cache directory did not exist at all. That is the tell: a single successful run would have created it.
Turborepo only caches what outputs lists
Fixing the name collision made the graph build. Then a warning appeared:
WARNING no output files found for task blog#build.
Please check your `outputs` key in `turbo.json`
The config said this:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
That is the create-turbo default for Next.js. Here is what the repo actually builds:
| Workspace | Build | Emits |
|---|---|---|
| blog | astro build | dist/ |
| landing | vite build | dist/ |
| shared package | tsc | dist/ |
| one side project | next build | .next/ |
The glob matched one workspace out of four, and that one was a side project nobody ships.
This is what makes the trap durable. A config that matches nothing looks obviously broken during review. A config that matches one thing looks plausible, so you skim past it.
A 62ms build that produced no files
I wanted to see the actual consequence rather than reason about it, so I deleted the output directory and rebuilt.
$ rm -rf apps/blog/dist
$ npx turbo build --filter=blog
blog:build: cache hit, replaying logs 7ee581b375db050c
Cached: 1 cached, 1 total
Time: 62ms >>> FULL TURBO
$ ls apps/blog/dist
(nothing)
Cache hit. FULL TURBO. Sixty-two milliseconds. Zero files.
Turborepo caches two things: the task logs, and whatever paths you declared in outputs. The logs were stored, so they replayed. The files were never candidates for storage, so there was nothing to restore.
The cache was not broken. It did exactly what it was configured to do, which was store nothing and therefore restore nothing.
The real problem is that this is indistinguishable from a working cache. cache hit and FULL TURBO are the same strings you see when everything is correct.
What changed after the fix
I pointed outputs at the paths the repo actually emits.
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
Then I deleted every output directory across all four workspaces and reran.
| Cache hit time | Files restored | |
|---|---|---|
| Before | 62ms FULL TURBO | 0 |
| After | 132ms FULL TURBO | 412 |
The 412 breaks down as 79 for the blog, 122 for the landing page, 26 for the shared package, and 185 for the side project. The warning disappeared.
That 70ms difference is the cost of writing 412 files to disk. Put the other way around: the earlier 62ms was fast because it was doing nothing.
Telling “fast” apart from “working”
The takeaway is that logs are the wrong place to verify a build cache.
A cache exists to reproduce a result, not to finish quickly. Logs only show you the finishing part. Speed and correctness point in the same direction right up until the moment the cache is empty, and then they point in opposite directions.
The check is one line:
rm -rf <output dir> && <build command> && ls <output dir>
If the tool claims a cache hit and the directory is still empty, the cache is a shell. Turborepo’s own Configuring tasks documentation states that files not listed in outputs are not cached, so this is documented behaviour rather than a bug.
One more thing worth checking on day one. If the repo was scaffolded with create-turbo, turbo.json ships assuming Next.js. Astro, Vite, tsc, and Expo all emit somewhere else, and nothing warns you until a task happens to produce no matching files.
What I cannot claim
I do not know how much CI time this saved. Deployment in this repo does not go through turbo at all, since the hosting platform builds each project independently.
I also do not know what the 71 days cost. Nobody was running the root task, so the practical loss may well have been zero.
What I measured is narrower than the story wants to be. A build orchestrator sat broken for 71 days, then sat useless for a while longer after being repaired, and neither state was distinguishable from a healthy one by reading the output.
Wiring up a tool and having the tool do work are separate claims. The config file proves the first one. Only the artifacts prove the second.
Measured as of 2026-08-11 on turbo 2.9.14.