The workspace has four packages (analytics, users, ui, shared) and a dashboard app, but there is no build orchestration. Running pnpm -r build rebuilds everything every time, even unchanged packages. You’re going to create a turbo.json configuration and wire up Turborepo so builds are cached, dependency-aware, and fast.
Why It Matters
Without a build orchestrator, every CI run and every local build starts from scratch. In a monorepo with 5 packages that takes a few seconds; in a monorepo with 50 packages it takes minutes. Turborepo’s content-aware hashing means a build that has already succeeded with the same inputs is never repeated. The second build is free. Changing one leaf package only rebuilds its dependents, not the entire graph. This is the tool that makes monorepos viable at scale.
At this point the workspace has packages/users — a feature package for user management. It follows the same pattern as packages/analytics: its own package.json, tsconfig.json, and src/index.ts with an explicit public API.
Feel the Problem
Run the build without Turborepo to understand what you’re fixing.
Run the build across all workspace packages:
pnpm -r buildNote the output — every package builds, sequentially. Note how long it takes.
Now run it again immediately, without changing anything:
pnpm -r buildSame time. Same output. Every package rebuilt even though nothing changed. pnpm’s --recursive flag has no caching — it just runs the build script in every package that has one.
Why pnpm alone isn’t enough
pnpm workspaces handle package resolution and dependency linking — they ensure that @pulse/analytics can import @pulse/ui through the workspace protocol (workspace:*). But pnpm has no opinion on task orchestration. It doesn’t know that @pulse/analytics depends on @pulse/ui and therefore @pulse/ui must build first. It doesn’t cache outputs. It doesn’t skip unchanged packages. That’s what a build orchestrator like Turborepo adds on top.
Checkpoint
You’ve run pnpm -r build twice and both runs took roughly the same time. There is no caching, no dependency ordering, no parallelization.
How Turborepo Orchestrates Builds
Turborepo reads the dependency graph from your package.json files and uses the ^ prefix in dependsOn to determine build order. Packages at the same depth build in parallel. The cache stores outputs keyed by a hash of each package’s inputs.
graph TD
Shared["@pulse/shared"]
UI["@pulse/ui"]
Analytics["@pulse/analytics"]
Users["@pulse/users"]
Dashboard["@pulse/dashboard"]
Dashboard --> Analytics
Dashboard --> Users
Dashboard --> UI
Dashboard --> Shared
Analytics --> UI
Analytics --> Shared
Users --> UI
Users --> Shared
UI --> Sharedflowchart LR
subgraph Inputs
Src["Source Files"]
Deps["Dependency Hashes"]
Env["Environment Variables"]
Lock["Lockfile Entries"]
Config["turbo.json Task Config"]
end
Hash["Content Hash"]
Cache{{"Cache Lookup"}}
Hit["Cache Hit → Restore dist/ + replay logs"]
Miss["Cache Miss → Execute build → Store in cache"]
Src --> Hash
Deps --> Hash
Env --> Hash
Lock --> Hash
Config --> Hash
Hash --> Cache
Cache -->|"match"| Hit
Cache -->|"no match"| MissInstall Turborepo and Create turbo.json
Install Turborepo as a dev dependency at the workspace root:
pnpm add -Dw turboCreate turbo.json at the root of the repository:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
Note The ^ prefix builds dependencies first. "outputs" tells Turborepo what to cache. "typecheck": {
"dependsOn": ["^typecheck"]
},
"lint": {},
"test": {
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
}
Note Long-running dev servers should never be cached. }
}What Each Field Means
"dependsOn": ["^build"]: The^prefix means “run this task in my dependencies first.” Before@pulse/analyticsbuilds, Turborepo ensures@pulse/uiand@pulse/sharedhave already built. Without the caret,dependsOn: ["build"]would mean “run build in this package first” (a self-dependency, usually not what you want).`“outputs”: [“dist/”]`**: Tells Turborepo which files to cache. After a successful build, it hashes the inputs (source files, dependencies, env vars) and stores the outputs. On the next run with the same inputs, it replays the cached outputs instead of running the build again.
"lint": {}: NodependsOn, nooutputs. Linting each package is independent — you don’t need to lint dependencies first, and there’s nothing to cache beyond the pass/fail result."cache": false: Thedevtask should never be cached. It’s a long-running process (a dev server), not a one-shot command."persistent": true: Tells Turborepo thatdevis a long-running process that doesn’t exit. Without this, Turborepo would wait for it to finish before running dependent tasks (which would hang forever).
The ^ prefix is the most important concept in Turborepo
It encodes the dependency graph into your task pipeline. When you run turbo build, Turborepo reads every package’s package.json to construct the dependency graph, then uses ^build to determine execution order. @pulse/shared has no dependencies, so it builds first. @pulse/ui depends on @pulse/shared, so it builds second. @pulse/analytics depends on both, so it builds third. The dashboard depends on everything, so it builds last. Turborepo runs packages at the same depth in parallel — @pulse/ui and @pulse/users can build at the same time because neither depends on the other.
Update Root package.json Scripts
Open the root package.json and replace the existing scripts section. The old scripts ran pnpm commands directly—the new ones delegate to Turborepo:
{
"scripts": {
"build": "turbo build",
"typecheck": "turbo typecheck",
"lint": "turbo lint",
"test": "turbo test",
"dev": "turbo dev",
},
}Note All scripts now delegate to Turborepo instead of running pnpm -r directly.Now pnpm build delegates to Turborepo instead of running scripts directly.
You can still use pnpm filters alongside Turborepo. Running pnpm turbo build --filter=@pulse/analytics builds only @pulse/analytics and its dependencies. Running pnpm turbo build --filter=@pulse/analytics... (with the ... suffix) builds @pulse/analytics and everything that depends on it. These filters are how CI pipelines build only what a pull request changed.
Run Turborepo for the First Time
Run the build through Turborepo:
pnpm turbo buildWatch the output. Turborepo prints each task as it runs, showing the dependency ordering:
@pulse/shared:build: cache miss, executing
@pulse/legacy:build: cache miss, executing
@pulse/ui:build: cache miss, executing
@pulse/analytics:build: cache miss, executing
@pulse/users:build: cache miss, executing
@pulse/dashboard:build: cache miss, executingEvery task says “cache miss” because this is the first run — there is no cache yet.
Run it again immediately:
pnpm turbo buildNow the output is different:
@pulse/shared:build: cache hit, replaying logs
@pulse/legacy:build: cache hit, replaying logs
@pulse/ui:build: cache hit, replaying logs
@pulse/analytics:build: cache hit, replaying logs
@pulse/users:build: cache hit, replaying logs
@pulse/dashboard:build: cache hit, replaying logs
Tasks: 6 successful, 6 total
Cached: 6 cached, 6 total
Time: 103ms >>> FULL TURBOEvery task shows “cache hit.” The total time drops to under a second. FULL TURBO means every single task was served from cache.
How Turborepo’s cache works
For each task, Turborepo computes a hash from:
- The source files in the package
- The hashes of all dependency packages
- The task definition in
turbo.json - Relevant environment variables
- The lockfile entries for external dependencies
If the hash matches a previous run, Turborepo restores the cached outputs (the dist/ directory) and replays the logged stdout/stderr instead of executing the command. The cache is local by default (stored in node_modules/.cache/turbo), but can be shared across CI runners with remote caching.
Checkpoint
pnpm turbo build on a clean cache builds everything. The second run shows FULL TURBO and completes in under a second.


Observe Partial Rebuilds
Now change something and watch Turborepo rebuild only what’s necessary.
Open packages/ui/src/button.tsx and make a small change — add a comment at the top of the file:
// Updated button stylingSave the file and run the build again:
pnpm turbo buildLook at the output carefully:
@pulse/shared:build: cache hit, replaying logs
@pulse/legacy:build: cache hit, replaying logs
@pulse/ui:build: cache miss, executing
@pulse/analytics:build: cache miss, executing
@pulse/users:build: cache miss, executing
@pulse/dashboard:build: cache miss, executing@pulse/shared and @pulse/legacy are cache hits — they didn’t change and have no dependency on @pulse/ui. But @pulse/ui is a cache miss (you changed it), and everything that depends on @pulse/ui also rebuilds: @pulse/analytics, @pulse/users, and @pulse/dashboard.
Turborepo rebuilds dependents, not just the changed package
This is because the hash includes dependency hashes. When @pulse/ui changes, its hash changes. @pulse/analytics depends on @pulse/ui, so its input hash includes @pulse/ui’s hash — which changed. The cascade continues up the graph. This is correct: a change in @pulse/ui could affect the build output of any package that imports it. The only safe optimization is to skip packages that provably cannot be affected — packages with no dependency path to the changed package.
Revert your change to packages/ui/src/button.tsx (remove the comment) and run pnpm turbo build again. It should be FULL TURBO — the files are back to the cached state.
Checkpoint
After changing packages/ui, only @pulse/shared shows a cache hit. Everything downstream of @pulse/ui rebuilds. After reverting, the cache is restored.
Examine the Dependency Graph
Turborepo can visualize the task graph it constructs.
Generate the graph:
pnpm turbo build --graph=graph.htmlThis generates an HTML file with a visualization of the dependency graph. Open graph.html in your browser. You should see:
@pulse/sharedat the bottom (no dependencies)@pulse/uione level up (depends on@pulse/shared)@pulse/analyticsand@pulse/usersat the next level (both depend on@pulse/uiand@pulse/shared)@pulse/dashboardat the top (depends on everything)
The graph is the architecture
This visualization is not just a debugging tool — it’s a map of your system’s coupling. If you see a package at the bottom of the graph with edges going to every other package, that’s your most critical shared dependency. If you see two packages with no edges between them, they can build and test in parallel. The graph tells you where parallelism is possible, where bottlenecks are, and what the blast radius of a change will be.
Now try the filter with the graph:
pnpm turbo build --filter=@pulse/analytics... --graph=graph-analytics.htmlThis generates a graph showing only the subgraph relevant to @pulse/analytics and its dependencies.
Checkpoint
You can visualize the dependency graph and identify which packages are upstream and downstream of any given package.
graph TD
Dashboard["@pulse/dashboard#build"]
Users["@pulse/users#build"]
Analytics["@pulse/analytics#build"]
UI["@pulse/ui#build"]
Shared["@pulse/shared#build"]
Legacy["@pulse/legacy#build"]
Mocks["@pulse/mocks#build"]
Codemods["@pulse/codemods#build"]
Root(("Root"))
Dashboard --> Users
Dashboard --> Analytics
Dashboard --> UI
Dashboard --> Shared
Users --> UI
Users --> Shared
Analytics --> UI
Analytics --> Shared
UI --> Shared
Legacy --> Mocks
Mocks --> Shared
Codemods --> Shared
Codemods --> Root
Shared --> RootSolution
If you need to catch up, the completed state for this exercise is available on the 04-typescript-start branch:
git checkout 04-typescript-start
pnpm installStretch Goals
- Filter builds: Run
pnpm turbo build --filter=@pulse/analytics...to build only analytics and its dependencies. Compare the task count to a full build. - Dry run: Run
pnpm turbo build --dry-run=jsonto see exactly what Turborepo would execute without running anything. Inspect the JSON output to understand the hash computation. - Environment variables: Add an environment variable to the
buildtask’senvkey inturbo.jsonand observe that changing the variable invalidates the cache.
What’s Next
You have cached, dependency-aware builds. But TypeScript still checks each package independently — there’s no incremental type checking across package boundaries. Changing a type in @pulse/shared forces a full recheck of every package. In the next exercise, you’ll add composite: true and project references so TypeScript can do incremental cross-package type checking.