The Monorepo Tax and When It Pays Dividends
We run eight products out of one Bun + Turborepo monorepo. It made a lot of things trivially easy and it made our CI bill a real line item. Here's the honest ledger, including the diet that cut billed minutes by roughly 70%.
We put every product we build in one repository. Wryte, OurTrail, envpilot.dev, LUMO, Crisp, Vitals, Porter, and one more that's not public yet — one bun install, one Turborepo pipeline, one main branch that everything eventually merges into. This was a deliberate call two years ago, not an accident of "we never got around to splitting it up," and I still think it was right. I also think the honest version of that opinion has to include what it costs, because the polyrepo-vs-monorepo debate online is mostly two camps talking past each other's tradeoffs instead of pricing them.
What got easier
Shared config, once, for real. ESLint, Prettier, TypeScript base configs — packages/eslint-config, packages/tsconfig, packages/prettier-config — are each one package that every app extends. When we decided React Compiler should be on everywhere, that was one config change, not eight PRs across eight repos, each needing review, each risking drift where two of them get updated and six don't. Config drift across repos is a real, slow-burning cost that never shows up as an incident; it shows up as "why does this one repo lint differently," six months later, and nobody remembers why.
One CI, one set of secrets, one place to look. There's a single ci.yml. A single set of GitHub Actions secrets. When something's broken in CI, there's one place to check, not a rotation across eight repos' Actions tabs trying to remember which one owns which secret. For a five-person team, the cognitive overhead of "which repo has the thing" is not a small cost — it's the kind of tax that's invisible per-instance and enormous in aggregate.
Cross-product code reuse without publishing packages. envpilot.dev's CLI and the VS Code extension both need the same auth-token refresh logic. In a monorepo, that's an internal package, imported directly, refactored atomically across every consumer in the same PR. In separate repos, that's either duplicated code (drift risk) or a published npm package (versioning overhead, a release step, a "did the consumer bump the dependency" question that someone has to actually check). We use the internal-package pattern constantly and it's the single biggest velocity unlock the monorepo gives us.
Atomic cross-cutting changes. When we did the Convex feature-registry migration — moving every tier-gated feature to a dynamic DB-driven registry instead of hardcoded flags — that touched the web app, the CLI, and the extension in one PR, reviewed once, merged once, deployed in the correct dependency order by CI. Coordinating that same change across three separate repos would have meant three PRs, three review cycles, and a real risk of the backend and a client landing out of sync for however long it took someone to notice.
What got harder
CI minutes are not free, and a monorepo makes that obvious fast. Every push, naively, could trigger typecheck + lint + build + test for all eight products, even if the diff touched one file in one app. Turborepo's caching and affected-package detection should prevent this, and mostly does, but "mostly" is doing work in that sentence — early on, our pipeline had jobs that weren't scoped tightly enough, and a one-line docs change in apps/web was triggering a full rebuild of the CLI and the extension because a shared type package technically changed its build hash even though nothing consumer-visible changed.
Schema coupling across products that share a backend. Wryte, OurTrail, envpilot.dev, and LUMO's web companion all run against one dev Convex deployment during active development, and Convex validates schema whole-table, not per-feature. Two engineers touching the schema on two different branches at once is a real coordination cost that doesn't exist if those products are in separate repos with separate backends. We've had to adopt a rule — schema-touching branches must stay supersets of each other or merge fast — that a polyrepo team never has to think about, because their schemas are already isolated by construction.
Blast radius on broken tooling. When a shared config package breaks — a bad ESLint rule, a tsconfig path alias typo — it breaks CI for all eight products simultaneously, not one. This has happened to us twice. Both times it was fixed within the hour, but "all eight products red at once" is a specific kind of stressful that a polyrepo setup structurally can't produce, because the blast radius of a bad shared config is capped at the repos that opted into that exact version.
The CI minutes diet, with real numbers
This is the part I actually want to write about, because "monorepo CI is expensive" is a complaint everyone makes and "here's what we did about it, with numbers" is rarer.
Before the diet, a typical PR push was triggering roughly eight separate CI jobs — one per app, each running its own install, typecheck, lint, and build steps, largely redundantly, because each job was reinstalling the same node_modules tree and rebuilding shared packages it depended on from scratch. That was landing around 15 billed minutes per push on GitHub's per-minute Actions pricing, on top of the free 2,000 minutes a month GitHub gives every account — minutes that were disappearing fast across a team pushing dozens of times a day.
The fixes, roughly in the order we made them:
- Consolidated eight jobs into one. Instead of a job per app, one job runs Turborepo's pipeline directly —
turbo run typecheck lint build --filter=[HEAD^1]— and lets Turborepo's own dependency graph and affected-package detection decide what actually needs to run. Turborepo already knows the dependency graph; running it once instead of shelling out to it eight separate times in eight separate job contexts was most of the win by itself. - Turborepo remote caching, actually wired up. A cache hit on an unchanged package means the task's outputs get restored instead of rebuilt — for a typical push where five of eight apps are untouched, those five packages resolve to cache hits in seconds instead of full rebuilds.
bun installwith a locked, cached~/.bun/install/cachekeyed on the lockfile hash. Install used to be a meaningful fraction of every job's runtime, repeated eight times. Cached and run once, it's now a rounding error.- Concurrency cancellation on superseded pushes. If you push twice in quick succession, the first run gets cancelled instead of running to completion. Obvious in hindsight, absent for longer than I'd like to admit, and worth real minutes on a team that pushes fixups constantly.
The result: a typical PR push now runs in roughly 4 billed minutes, down from 15 — about a 70% cut. That's not a one-time cleanup either; it compounds every push, every day, across a team that pushes a lot. The free-tier budget that used to feel tight now has real headroom.
When the tax is worth paying
The honest rule I'd give another small team: a monorepo is worth it when your products share infrastructure — a backend, an auth system, internal packages that get imported across products — because that's exactly the axis where a polyrepo makes you pay in coordination overhead what a monorepo makes you pay in CI minutes and blast radius. CI minutes are a bill you can optimize down 70% with an afternoon of work, like we did. Coordination overhead across repos — the kind where a backend contract change needs three separate PRs merged in the right order across three separate teams' attention — is a tax you pay in calendar time and in the things that fall through the cracks between repos, and that tax doesn't respond to caching.
For eight products built by five people who all context-switch across all of them in a given week, the monorepo's dividends — shared config, atomic cross-cutting changes, one CI to reason about — are worth more than the tax. If we were eight separate teams each owning one product with no shared backend, I'd give the opposite advice without hesitating. The mistake is picking one structure as a philosophy instead of pricing it against how your specific team actually works.
The Syntax Lab Technology monorepo runs Bun workspaces + Turborepo across eight products, with Convex as the shared backend for four of them.
Ask AI