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.

Independent Vite builds run in parallel, dependents wait Turborepo builds the shared ui library first, then runs the independent app builds that depend on it concurrently up to the concurrency limit, and skips any package whose inputs are unchanged by replaying its cached output. Order where it matters, parallel where it does not ui#buildruns first web#build admin#build docs#build (cache hit) parallel up toconcurrency limit
Figure: the shared library gates the apps that import it; everything else fans out to the concurrency limit or replays from cache.

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.

Workspace dependencies let Turborepo infer build order Because each app declares the shared ui library as a workspace dependency, Turborepo can infer that ui must build before the apps and that the apps are independent of each other, without any manual ordering. ui (workspace dep)declared by apps web admin the dependency graph is the order — no manual list
Figure: declaring the shared library as a workspace dependency is what lets Turborepo derive the order automatically.

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

  1. 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 stray dependsOn entry (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 only ui#build — or nothing — in their dependencies. Confirm the fix by re-running the dry run and watching the previously-serialized tasks appear with no cross-edge between them.
  2. Check outputs are declared. A build task with no outputs key caches only the captured logs, never the dist directory, so a “cache hit” replays green output while leaving the real artifact stale or absent. The symptom is insidious: local builds look fine because dist is 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 a dist that either does not exist or belongs to an older hash. Root cause is the missing outputs: ["dist/**"]. Confirm the fix by deleting dist, running turbo run build, and checking that a cached run restores the directory — rm -rf apps/web/dist && turbo run build should leave apps/web/dist fully populated even on a cache hit.
  3. 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 --concurrency to the number of cores (--concurrency=100% does this automatically) and comparing the run summary’s total duration against the oversubscribed run.
Three checks before trusting the parallel build Confirm builds are independent with a dry-run of the task graph, confirm each build declares its dist outputs so caching is real, and confirm the concurrency limit matches the runner's core count so parallel Vite builds do not oversubscribe CPU. 1 --dry=jsonindependence 2 outputs: dist/**real caching 3 concurrency= core count
Figure: undeclared 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.

dependsOn orders, outputs caches, inputs scopes dependsOn caret build orders internal dependencies before dependents, outputs dist captures the Vite artifact so the cache is real, and a narrow inputs glob keeps unrelated edits from busting the cache. dependsOn ^buildorder the graph outputs dist/**cache the artifact inputs globscope invalidation three keys, three jobs
Figure: the three keys are independent — leave any one out and either ordering, caching, or scoping breaks.

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.

One touched app, one rebuild After editing one app only that app and any dependents re-run while every other package reports a cache hit, confirming that the task graph scopes invalidation correctly and the rest replay from cache. web: rebuildedited admin: cache hitunaffected docs: cache hitunaffected scoped invalidation is the proof the graph is correct
Figure: only the edited package and its dependents re-run — the rest replaying is the correctness signal.

Gotchas & edge cases

Four monorepo parallel-build edge cases Undeclared outputs cache only logs so downstream still rebuilds; over-high concurrency oversubscribes CPU and slows each Vite build; a shared port in dev-style builds collides across parallel tasks; and an env var read at build time but not declared produces stale cache hits. undeclared outputs→ caches only logs concurrency too high→ oversubscribes CPU shared port collision→ only parallelize build, not dev undeclared env var→ declare under env for correct hash
Figure: undeclared outputs and undeclared env vars are the two that make a "cached" build subtly wrong.
  • Undeclared outputs. Without outputs: ["dist/**"], a cache hit replays logs but downstream consumers rebuild against a missing dist. The failure is delayed and off-site: the package that forgot its outputs looks healthy, and the breakage surfaces one hop away in whatever step reads its dist — 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 declare outputs, 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=10 runs 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 dev with shared ports collides; parallelize build, run dev with persistent: true. A dev task is long-lived and never exits, so it cannot participate in the cache-and-replay model at all — marking it persistent: true tells 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 --port if you must run several dev servers under one turbo invocation.
  • Env vars in the hash. A VITE_* var that affects output must be declared under the task’s env, or two environments share a stale cache entry. Vite inlines import.meta.env.VITE_* values into the bundle at build time, so a production build and a staging build with different VITE_API_URL values produce genuinely different dist output — but if that variable is not in the task’s env array, 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.