Running Workspace Scripts in Topological Order
When packages depend on each other, the order in which their scripts run matters: @acme/web cannot build until @acme/ui has produced its dist/, and @acme/ui needs @acme/tokens first. Running build in every package in parallel, or in alphabetical order, produces intermittent Cannot find module and missing-type errors that disappear on the second run. Topological ordering — running each package only after the packages it depends on — fixes that. This guide shows how npm, pnpm, Yarn and dedicated task runners order scripts, and how to choose between them.
Exact symptoms and error messages
Out-of-order builds fail with errors that look like missing files rather than ordering problems:
apps/web build$ vite build
apps/web build: [vite]: Rollup failed to resolve import "@acme/ui" from "src/App.tsx".
apps/web build: Error: Failed to resolve entry for package "@acme/ui". The package may have incorrect main/module/exports specified in its package.json.
packages/ui build$ tsc -p tsconfig.build.json
packages/ui build: src/Button.tsx(2,24): error TS2307: Cannot find module '@acme/tokens' or its corresponding type declarations.
The telltale sign is that running the same command a second time succeeds, because the first run happened to finish building the dependency before failing. In CI, where every run starts clean, the failure repeats every time.
Why order matters
Workspace packages that point their exports at build output (./dist/index.js) are unusable until that output exists. Running scripts across packages therefore needs the dependency graph: an edge from @acme/web to @acme/ui means "run @acme/ui's task first". A topological sort of that graph gives an order in which every package runs after all of its dependencies, and packages at the same depth can run in parallel. How dependencies between packages are declared is covered in Cross-Package Dependency Management.
Ordering scripts with each tool
npm workspaces run npm run build --workspaces in the order packages are listed in the workspaces field (expanded globs are alphabetical). There is no topological mode. You can list packages explicitly in dependency order, but that list must be maintained by hand:
{
"workspaces": ["packages/tokens", "packages/utils", "packages/ui", "packages/api-client", "apps/*"]
}
pnpm sorts topologically by default for recursive runs. pnpm -r run build runs packages after their dependencies, in parallel where possible, respecting --workspace-concurrency (default 4):
pnpm -r run build # topological, concurrent
pnpm -r --workspace-concurrency=8 run build
pnpm --filter "@acme/web..." run build # web and everything it depends on, in order
Cycles are reported as a warning and the order inside a cycle is arbitrary — a reason to remove cycles, as described in Debugging Circular Dependencies in Monorepos.
Yarn Berry needs an explicit flag:
yarn workspaces foreach -A --topological-dev --parallel run build
--topological considers regular dependencies; --topological-dev also considers dev dependencies, which is usually what you want because build-time packages such as a shared ESLint or tsconfig package are dev dependencies.
Task runners (Turborepo, Nx) model the order per task rather than per package. In Turborepo, "dependsOn": ["^build"] means "run build in my dependencies first":
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"test": { "dependsOn": ["build"] },
"lint": {}
}
}
lint has no dependencies, so it runs in every package immediately, while build waits on upstream builds. Task runners also cache results, so an unchanged package is skipped entirely. See Turborepo Pipeline Configuration for the full model.
Avoiding the ordering problem altogether
Some teams sidestep build ordering by not building internal packages at all during development: workspace packages point exports at their TypeScript sources, and each application's bundler compiles them. That approach, covered in Using Internal Packages Without a Build Step, removes most ordering requirements for dev and build in applications. Type-checking still benefits from order, which TypeScript project references handle with tsc -b.
Ordering also matters for tasks other than build. Code generation — GraphQL types, Prisma clients, OpenAPI clients — often has to run before both build and typecheck in dependent packages. Model it as its own task ("generate") and make build and typecheck depend on ^generate and generate, rather than hiding generation inside a prebuild script where no tool can see it.
Parallelism limits and resource contention
Topological order says what may run concurrently; it does not say what should. A wave with twelve packages whose builds each spawn a TypeScript compiler and a bundler can exhaust memory on a CI runner with 7 GB of RAM, producing JavaScript heap out of memory failures that look unrelated to ordering. Every tool has a knob for this: pnpm's --workspace-concurrency, Yarn's --jobs, Turborepo's --concurrency and Nx's --parallel. Start with the number of CPU cores minus one, then lower it for memory-heavy tasks.
Long chains are the other performance issue. If @acme/tokens → @acme/ui → @acme/forms → @acme/web must build strictly in sequence, the critical path is the sum of four builds no matter how many cores you have. Visualise the graph and look for packages that sit on the critical path only because of a type-only or development-only import; breaking such an edge — or letting the downstream package consume sources instead of build output — shortens every build.
Finally, remember that "topological" in these tools refers to package dependencies, not task dependencies within a package. If test needs build in the same package, a recursive pnpm -r run test will not build first. Either chain them ("test": "pnpm run build && vitest run") or move to a task runner where "test": { "dependsOn": ["build"] } expresses the relationship directly.
Worked example: flaky CI that was really an ordering bug
A team's CI fails roughly one run in five with Failed to resolve entry for package "@acme/ui". The root script is "build": "npm run build --workspaces --if-present". With globs expanded alphabetically, apps/admin builds before packages/ui; it only passed when a stale dist/ from a restored cache happened to be present. Switching the script to pnpm -r run build (the repository already used pnpm) fixed the order, and removing dist/ from the CI cache made the failure deterministic instead of intermittent — the right trade, because deterministic failures get fixed.
Validation commands
# Print the order pnpm will use, without running anything
pnpm -r exec -- node -p "require('./package.json').name"
# Show the dependency graph Turborepo computes for build
npx turbo run build --graph=graph.html
# Run from a clean state to prove ordering (no stale dist)
git clean -xdf -e node_modules && pnpm -r run build
Prevention and CI/CD guardrails
- Never rely on list or alphabetical order for builds of interdependent packages; use a topological runner.
- Build from clean in CI, without restoring
dist/from caches, so ordering bugs fail deterministically. - Declare internal dependencies explicitly, including dev-only ones, so the graph is complete.
- Model code generation as a task with its own dependencies.
Frequently Asked Questions
Is topological order slower than running everything in parallel? Slightly, in the worst case, because later waves wait. In practice it is faster overall, because nothing fails and reruns, and packages in the same wave still run concurrently.
What happens with circular dependencies? No valid topological order exists. pnpm warns and picks an arbitrary order inside the cycle; Turborepo and Nx report the cycle as an error. Break the cycle by extracting shared code into a new package.
Do I need topological order for tests? Only if tests import built output of other packages. If tests resolve workspace dependencies from source, they can run fully in parallel.
Does --filter preserve the order? Yes. Filtered recursive runs in pnpm are still sorted topologically among the selected packages.
How do I run a script in reverse topological order?
Some tasks, such as cleaning or deploying consumers before providers, need the opposite direction. pnpm supports --reverse for recursive runs, and in task runners you express it by making the upstream task depend on the downstream one explicitly. Reverse order is rare; if you find yourself needing it often, check whether the dependency edges are pointing the right way.
Can I see why a package waited for another?
In pnpm, pnpm why on the dependency shows the edge. In Turborepo, turbo run build --dry=json prints each task's resolved dependencies, and --graph renders the whole task graph. Nx's nx graph offers an interactive view with the same information.
What about packages that depend on each other only through types? A type-only import is still an edge if the downstream package type-checks against the upstream package's emitted declarations. Either keep the edge and build in order, or have the downstream package resolve the upstream sources directly so the build order no longer matters for types.
Related
- Root-Level vs Package-Level Scripts explains how root scripts orchestrate package scripts.
- Running Scripts Across Workspaces with pnpm covers pnpm's recursive and filter flags in depth.
- Turborepo Pipeline Configuration models ordering per task with caching.
- Running a Monorepo with Plain pnpm Scripts shows how far topological scripts go without a task runner.