Parallelizing Vite Builds in a Monorepo with Turborepo
A monorepo with a dozen Vite apps and libraries builds slowly when each vite build runs in sequence, even though most are independent. Turborepo runs them in parallel, respects the dependency order where it matters, and caches the ones that did not change. This guide wires that up correctly, with the outputs and dependsOn that make caching sound. It combines the chunking-and-caching focus of Vite build performance and caching with the task-graph model from remote caching and distributed build coordination.
The problem exists because the naive way to build a workspace — a root package.json script that loops pnpm -r run build, or a hand-written shell script that cds into each package — is inherently serial and inherently forgetful. Serial means the sixth vite build does not start until the fifth exits, so a workspace with ten independent apps pays for ten Rollup passes back to back even on a 16-core machine that could run most of them at once. Forgetful means every invocation re-runs every package, because a plain script has no record of what the inputs were last time and no way to know that nothing under packages/ui/src changed. On a real monorepo those two costs compound: a two-minute cold build that should have been fifteen seconds of actual work becomes the tax you pay on every branch, every commit hook, and every CI job.
Turborepo attacks both costs with one mechanism: a content-addressed task graph. It reads the workspace dependency graph to decide what can run concurrently and what must wait, and it hashes the declared inputs of each task so a task whose hash it has seen before is replayed from cache instead of executed. The place this sits in the build pipeline matters — Turborepo does not replace Vite, Rollup, or esbuild, and it does not know or care what vite build does internally. It is a layer above the per-package build tool that decides whether and in what order to invoke it. Get the outputs, dependsOn, and inputs right and that layer is nearly free and always correct; get them wrong and it silently ships stale or broken artifacts, which is worse than the slow serial build you started with.
Prerequisites & reproducible setup
# Turborepo 2.x, Vite 5.4.x, pnpm 9, Node 20+
pnpm dlx create-turbo@latest vite-mono
cd vite-mono
# Each package's build script is `vite build`; the shared ui lib builds first.
pnpm add -Dw turbo@^2
Each package’s package.json has "build": "vite build", and apps declare the shared library as a workspace dependency so Turborepo can infer the order. The declaration is the load-bearing part. Turborepo does not scan your import statements or read your vite.config.ts to work out that apps/web consumes packages/ui; it reads the dependencies field of each package manifest and matches workspace protocol entries ("@acme/ui": "workspace:*") against the packages it discovered from pnpm-workspace.yaml. If apps/web imports from @acme/ui but forgets to list it as a dependency, the build still works locally — the pnpm symlink resolves — but Turborepo will happily build web and ui in parallel, and on a cold cache web’s Rollup pass can start before ui’s dist exists. That is the single most common cause of “works on my machine, fails in CI” in a Turborepo setup, and it has nothing to do with turbo.json.
For the reproduction below, assume a workspace with packages/ui (a Vite library-mode build) and apps/web, apps/admin, apps/docs (three app builds), where each app lists @acme/ui as a workspace:* dependency and none of the apps depend on each other. That shape — one shared library, several independent leaf apps — is the canonical case where parallelism pays off, and it is what every diagram on this page assumes.
How it works under the hood
Before you can trust the config, it helps to know exactly what Turborepo computes when you type turbo run build. The run happens in three distinct phases, and every gotcha later in this guide is a failure in one of them.
First, graph construction. Turborepo builds a task graph, not a package graph. Each node is a concrete <package>#<task> pair — ui#build, web#build, docs#build — and the edges come from expanding the dependsOn entries of the matching task definition. A bare task name like "lint" in dependsOn means “this package’s own lint task”; the caret prefix in "^build" means “the build task of every package this one depends on, transitively”. So web#build depends on ui#build is derived, at graph-construction time, by taking web’s workspace dependencies and, for each, adding an edge to that package’s build node. The graph is a DAG; Turborepo topologically sorts it and then schedules any node whose dependencies are all complete, up to the concurrency limit. This is why independent apps run at the same time and dependents wait — the scheduler is just draining a DAG.
Second, hashing. For each task node, Turborepo computes a hash from the task’s declared inputs (defaulting to all non-git-ignored files in the package when inputs is unset), the resolved dependency hashes of its dependsOn parents, the task’s env variables, the global globalDependencies and globalEnv, and the command string itself. The hash is content-based, not timestamp-based, so touching a file without changing its bytes does not invalidate anything, and checking out an old commit produces the same hash it did the first time. Critically, a parent’s hash feeds into a child’s hash: change ui/src/button.tsx and ui#build’s hash changes, which changes web#build’s hash even though nothing under apps/web moved. That transitive propagation is what makes “rebuild only the affected packages” correct rather than approximate.
Third, execution and replay. With the hash in hand, Turborepo checks its cache — local (.turbo/cache under node_modules) first, then remote if configured — for an entry keyed by that hash. On a miss it runs the command, captures stdout/stderr, and after the command exits it copies everything matching outputs into the cache alongside the captured logs. On a hit it does the reverse: it restores the cached outputs into the package directory and replays the captured logs to the terminal, without running Vite at all. This is the step where an undeclared outputs bites — the log replay makes the run look cached, but because nothing was stored under outputs, the restore step has nothing to put back, so dist is whatever happened to be on disk.
Diagnosis workflow
- Confirm the builds are actually independent. Run
turbo run build --dry=json | jq '.tasks[].dependencies'and read the dependency arrays. The symptom of a phantom dependency is a build that is slower than it should be despite plenty of idle cores — the scheduler is serializing tasks because the graph says it must. The usual root cause is a straydependsOnentry (a"build"that should have been"^build", which makes a package wait on its own earlier task instead of its dependencies’) or an app that lists a sibling app as a dependency by mistake. Independent app builds should show onlyui#build— or nothing — in theirdependencies. Confirm the fix by re-running the dry run and watching the previously-serialized tasks appear with no cross-edge between them. - Check
outputsare declared. A build task with nooutputskey caches only the captured logs, never thedistdirectory, so a “cache hit” replays green output while leaving the real artifact stale or absent. The symptom is insidious: local builds look fine becausedistis already on disk from a previous run, but a fresh clone or a CI job that starts with an empty working tree gets a cache hit and then adistthat either does not exist or belongs to an older hash. Root cause is the missingoutputs: ["dist/**"]. Confirm the fix by deletingdist, runningturbo run build, and checking that a cached run restores the directory —rm -rf apps/web/dist && turbo run buildshould leaveapps/web/distfully populated even on a cache hit. - Verify concurrency is not the bottleneck. Turborepo’s default concurrency is 10. On a runner with fewer cores than that, ten simultaneous Vite builds each spawn their own Rollup and esbuild worker pools, and the resulting oversubscription means every build competes for the same CPUs and finishes slower than a smaller batch would have. The symptom is total wall-clock time that rises as you add packages even though they run “in parallel”. Root cause is a concurrency limit set above the core count. Confirm by pinning
--concurrencyto the number of cores (--concurrency=100%does this automatically) and comparing the run summary’s total duration against the oversubscribed run.
outputs is the silent one — the run looks cached but the real work still happens.Annotated solution config
The whole configuration is three keys on one task definition, and each does exactly one job. dependsOn: ["^build"] handles ordering: the caret tells Turborepo to wait for the build task of upstream workspace dependencies, which is what keeps ui#build ahead of the apps without you ever naming ui explicitly — add a second shared library next year and the ordering follows automatically. outputs handles caching correctness: it is the manifest of what to save and restore, and for a Vite build that is dist/**. inputs handles cache scope: it narrows the set of files whose contents feed the hash, so a README edit or a change to an unrelated test fixture does not invalidate a build that could not possibly be affected by it.
A subtlety in the inputs glob is worth stating plainly: the moment you set inputs, you opt out of the default (all non-ignored files) and take full responsibility for listing everything that affects the output. Omit vite.config.ts from inputs and a change to your Rollup manualChunks strategy will produce a false cache hit with the old chunking. Omit package.json and a dependency version bump will not invalidate the build. The list below is deliberately conservative; when in doubt, widen it rather than debug a phantom cache hit later.
// turbo.json — Turborepo 2.x, monorepo root
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": {
// Build internal dependencies (the shared ui lib) before dependents.
"dependsOn": ["^build"],
// Declare Vite's output so the cache captures the real artifact,
// not just logs. Exclude the report file if you emit one.
"outputs": ["dist/**"],
// Narrow inputs so an unrelated README edit does not bust the cache.
"inputs": ["src/**", "index.html", "vite.config.*", "package.json"]
}
}
}
# Turborepo 2.x — run all Vite builds, capped at the runner's core count.
turbo run build --concurrency=100% # one task per available core
# Or an explicit cap on a shared CI runner:
turbo run build --concurrency=4
The two concurrency forms answer two different questions. --concurrency=100% reads the machine’s logical core count at run time and uses that as the limit, which is the right default for a self-hosted runner or a developer laptop where the whole machine is yours. The explicit --concurrency=4 is for shared CI where the container advertises 8 cores but is throttled to a fraction of them, or where you deliberately want to leave headroom for a test task running alongside the build. Note that Vite’s own internal parallelism — Rollup’s worker threads and esbuild’s goroutine pool — is not counted by Turborepo’s limit; the concurrency number is how many vite build processes run at once, and each of those spawns its own workers. Four parallel Vite builds on a four-core box already saturates it, which is exactly why matching the limit to the core count, rather than exceeding it, is the rule.
Verification
# Turborepo 2.x — build twice; the second run should be near-instant.
turbo run build # first run: builds everything
turbo run build # second run: >>> FULL TURBO
# Change one app, rebuild: only that app and its dependents re-run.
touch apps/web/src/main.tsx && turbo run build --summarize
The second full run prints >>> FULL TURBO (all cached). After touching one app, only that app rebuilds while the others report a cache hit — proof that parallelism and caching are both working and scoped.
There is a deliberate trap in the verification above. touch updates the file’s mtime but not its bytes, and Turborepo hashes contents, so touch apps/web/src/main.tsx on its own will not invalidate anything — the second turbo run build after a pure touch still prints >>> FULL TURBO. That is not a bug; it is the content-addressing working as designed. To actually exercise invalidation you have to change bytes. The --summarize flag is what makes the outcome legible either way: it writes a JSON run summary under .turbo/runs/ recording, for every task, its hash, whether it was a cache hit or miss, and its duration. Reading that file is how you prove scoping rather than infer it from terminal color.
# Turborepo 2.x — force a real byte change, then read the run summary.
echo "// cache-bust $(date +%s)" >> apps/web/src/main.tsx
turbo run build --summarize
# Inspect exactly which tasks re-ran vs. replayed from cache:
jq '.tasks[] | {task: .taskId, status: .cache.status, ms: .execution.endTime - .execution.startTime}' \
"$(ls -t .turbo/runs/*.json | head -1)"
# Expect: web#build -> "MISS", admin#build/docs#build/ui#build -> "HIT"
Because none of the apps depend on ui’s source here — only on its built output, whose hash did not change — editing apps/web leaves ui#build, admin#build, and docs#build as cache hits. Had you instead edited packages/ui/src, the summary would show ui#build and all three apps as misses, because ui’s hash feeds every dependent’s hash. Seeing that transitive invalidation in the summary is the strongest confirmation that dependsOn: ["^build"] is wired correctly.
Gotchas & edge cases
- Undeclared
outputs. Withoutoutputs: ["dist/**"], a cache hit replays logs but downstream consumers rebuild against a missingdist. The failure is delayed and off-site: the package that forgot itsoutputslooks healthy, and the breakage surfaces one hop away in whatever step reads itsdist— a deploy that uploads an empty directory, or a dependent app whose Rollup pass cannot resolve the shared library’s built entry. Because the logs replay green, nothing in the run output points at the real culprit. Always declareoutputs, and if Vite emits a bundle-analysis report you do not want cached, exclude it with a negated glob ("outputs": ["dist/**", "!dist/stats.html"]) rather than dropping the key. - Concurrency vs cores. On a 2-core CI runner,
--concurrency=10runs ten Vite builds fighting for CPU; cap it at the core count. The counter-intuitive part is that the parallel run can be slower than the serial one, because context-switching between ten memory-hungry Rollup processes thrashes cache and, on a memory-limited container, can push the box into swap or trip the OOM killer mid-build. If builds are dying with exit code 137 in CI, concurrency is the first thing to lower, not memory to raise. - Dev servers do not parallelize.
turbo run devwith shared ports collides; parallelizebuild, rundevwithpersistent: true. Adevtask is long-lived and never exits, so it cannot participate in the cache-and-replay model at all — marking itpersistent: truetells Turborepo not to let any other task depend on it and not to try to cache it. Two Vite dev servers that both default to port 5173 will race for the socket and the loser either picks a random port or fails outright; give each app an explicit--portif you must run several dev servers under oneturboinvocation. - Env vars in the hash. A
VITE_*var that affects output must be declared under the task’senv, or two environments share a stale cache entry. Vite inlinesimport.meta.env.VITE_*values into the bundle at build time, so a production build and a staging build with differentVITE_API_URLvalues produce genuinely differentdistoutput — but if that variable is not in the task’senvarray, both builds hash identically and the second one restores the first one’s bundle pointing at the wrong API. The fix is"env": ["VITE_API_URL"]on the task (or a wildcard like"VITE_*"); confirm it by building with one value, changing the variable, rebuilding, and checking that the second run is a MISS in the run summary.
Related
- Vite build performance and caching — the parent cluster on pre-bundling and chunk memory.
- Remote caching and distributed build coordination — sharing these Turborepo caches across machines.
- Configuring remote cache with Turborepo and Vercel — the CI-token setup for the same task graph.