Build-Time Composition: The Middle Ground

The middle ground between runtime federation and monolith — one build catches everything, but every team ships together.

March 21, 20265 min read1 / 3

Runtime module federation is the most autonomous option on the spectrum. But autonomy has a cost I keep coming back to: there's no shared build time, which means errors that would be caught by a type checker or a test suite can only be caught in the user's browser.

Build-time composition trades some of that autonomy for safety. One build, one deploy, but everything gets verified together before it ships.

The Core Trade-off

With runtime composition, each team deploys when they're ready. Nobody waits for anyone else. The cost: if Team A ships a breaking API change, the first place that's discovered is production.

Build-time composition says: let's have one build time. All the pieces get assembled, type-checked, and tested together before anything ships. If something breaks, it breaks the build — which is much better than breaking the user experience.

The cost of this is the thing people usually cite first: one build time means one deploy moment. If Team A is ready but Team B has a broken test, nobody ships. That's the coordination problem runtime composition was designed to escape.

Whether that trade is worth it depends entirely on where your actual pain is. If the pain is bad deploy coordination, build-time composition makes it worse. If the pain is runtime errors from incompatible module versions, build-time composition eliminates them.

The Real-World Case That Keeps Coming Up

Here's a scenario I've seen play out at multiple companies: you have a docs site, a marketing site, and the main application. Three separate properties, probably three separate repos — and nobody thinks of this as "microfrontends."

Then someone — product, marketing, a senior stakeholder — asks the very reasonable question: shouldn't these all have the same navigation?

Now you have a shared component that needs to be deployed to three different places. The options are either: coordinate three separate deploys every time the nav changes, or extract the nav into a shared package that all three properties pull in and get updated automatically.

That shared nav problem is build-time composition. You might not call it that. But you're solving the same problem the pattern was designed for, regardless of what you call your architecture.

How It Works: Workspaces

The mechanism is package manager workspaces. npm, pnpm, and Bun all support this concept — local packages that are resolved as if they were on npm, without actually being published.

Plain text
monorepo-root/ apps/ dashboard/ ← full app, references local packages marketing/ packages/ ui/ ← shared component library analytics/ ← shared analytics logic shared/ ← shared utilities, types pnpm-workspace.yaml

In the dashboard's package.json:

JSON
{ "dependencies": { "@pulse/ui": "workspace:*", "@pulse/shared": "workspace:*" } }

workspace:* tells pnpm: don't go to the npm registry for this. Find it in the workspace and link it. The package behaves exactly like an installed npm package from the consuming app's perspective, but it's resolved locally.

This is the part I find elegant about this approach: the tooling you already know (npm, package.json, imports) works exactly the same way. There's no new configuration paradigm, no manifest files, no remote URLs to manage. Just packages that happen to live in the same repo.

The Problems It Creates

Two issues surface almost immediately once you start using this pattern at any real scale.

Build ordering. If dashboard depends on ui, and ui has TypeScript that needs to be compiled, you need to build ui before you build dashboard. When Vite bundles the app, it can transpile TypeScript inline — so for a simple case, you get away without a separate build step. But the moment you have multiple apps, nested dependencies, or a mix of frameworks (Svelte components that need to be compiled before TypeScript that depends on them), the order matters.

Writing a manual build script that says "compile ui, then shared, then analytics, then dashboard" is fine until it isn't — until the dependency graph gets more complex, until someone adds a new package, until you need to parallelize.

Running everything on every change. If you have 1000 Playwright tests across all your packages, and you make a CSS change to one component, the naive approach runs all 1000. That's the worst of both worlds — you've split the code across packages but you're still paying the full CI cost on every change.

The fix for both of these problems is a build system that understands the dependency graph. That's what the next section covers.

The Positioning

Build-time composition sits in an interesting place on the spectrum:

Plain text
Runtime Federation → Build-Time Composition → Monolith Full autonomy Middle ground Full coupling No shared build One build time One codebase Runtime errors Caught at build Caught at build

It gives you the code ownership and team boundaries of a distributed system — separate packages, separate concerns — while retaining the safety of a shared build. What it doesn't give you is deploy independence.

For teams where the pain is coordination and code ownership, not deploy coupling, this is often the right spot on the spectrum to land. The monorepo tooling section covers how to make the build fast enough that the "one build" constraint stops feeling like a limitation.

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.