The package everyone imports
In a Turborepo monorepo, CI time is set by the few packages that change often and are imported everywhere: a task's cache key includes every dependency's hash. Find and split them.
Every monorepo team reaches the point where the remote cache is on, the runners are fast, and CI still takes twenty minutes on a one-line change. The usual response is to buy a faster cache or bigger runners. The actual cause is structural: in a Turborepo, a task's cache key includes the hash of every package it depends on, so a change to a package that many others import invalidates the cache for every one of them, and the whole tree rebuilds. CI time is therefore decided by a small set of packages that change often and are imported everywhere, and the fix is not a faster cache but moving churn out of those packages. They can be found from git history and the package graph in an afternoon.
Why one change rebuilds everything
Turborepo hashes each task's inputs to decide whether it can replay a cached result, and the inputs include the outputs of the tasks it depends on, transitively, through the package graph. That is the correct design: if a shared package's output changed, its dependents might build differently, so their cache entries cannot be trusted. The consequence is that the blast radius of a commit is not the number of files it touched but the number of packages that transitively depend on the packages it touched. A change to an application's own code rebuilds that application. A change to a package imported by ninety others rebuilds ninety.
The number to measure, then, is not how big a package is or how slow its build is. It is two numbers per package: how often it changes, and how many packages transitively depend on it. Their product is the expected cache invalidation the package causes per unit time, and the packages where both are high are where the CI minutes go.
The churn-reach matrix
I call the two numbers churn and reach. Churn is the count of commits that touched the package in the last ninety days, from git history. Reach is the number of packages that transitively depend on it, from the package manifests.
Plot every workspace package on those two axes and the picture has four quadrants. Low churn, low reach: leaf packages that rarely change, harmless. High churn, low reach: applications and feature packages that change constantly and are imported by nobody, harmless too, because their rebuilds are their own. Low churn, high reach: stable foundations, configuration, type definitions, the packages everyone imports and nobody touches, harmless as long as they stay untouched. And high churn, high reach: the hot hubs, the packages that change often and are imported everywhere, and every commit to one of them is a tree-wide rebuild.
To see the shape on a real repository I ran the measurement on cal.com, a public Turborepo monorepo with 112 workspace packages, using the default branch's history over the ninety days to 18 September 2026 and the dependency declarations in each package manifest. Reach is dominated by six packages: the shared type definitions, TypeScript configuration, internationalisation, shared configuration, the date library wrapper and the general library package, each imported transitively by between 83 and 91 other packages. Churn is dominated by the application and the features package, which nobody imports. The two packages that sit in both lists are the internationalisation package, seven commits in the window and 89 dependents, and the library package, five commits and 83 dependents.
The matrix says something specific about this repository that no cache metric would. The foundations, types and configuration, have enormous reach and almost no churn, so they cost nothing: a commit to them rebuilds everything, and there was one such commit in ninety days. The application changes constantly and costs only itself. The internationalisation package is the one to look at: it changes about as often as the application does, because adding a string to the interface means touching it, and it is imported by nearly everything, because nearly everything shows a string. Seven commits in ninety days, each invalidating 89 packages' cache keys, is the largest single source of rebuilds in the repository, and it comes from a package whose changes are individually trivial.
One caveat about the window is worth stating, because it changes the reading. Ninety days on cal.com's default branch, as cloned, contained 37 commits, which is a quiet period for that repository or a reflection of how its work is merged; a busier window would scale every churn number up but would not change the ranking, since the packages that get touched when strings and helpers change are the same ones. The matrix is insensitive to the window's length and sensitive to which packages are hubs, which is the property you want.
The split rule
The rule that follows is that a hot hub gets split until it leaves the quadrant. Splitting means separating the part of the package that changes from the part that is imported. For an internationalisation package, that usually means the runtime, the functions every component calls, which is stable, and the message catalogue, the strings, which is what changes; if the catalogue is its own package that only the applications import, then adding a string invalidates the applications and not the ninety packages that merely call the translation function. For a general library package, it means splitting by domain, so that a change to a date helper does not invalidate the packages that import only a string helper, and the packages that import everything are made to import the pieces they use.
The split is not free, and the matrix says when to pay for it. A package with high reach and one commit a quarter is not worth splitting, whatever its size, because it invalidates the tree once a quarter. A package with ten commits a week and two dependents is not worth splitting either. The product of churn and reach is the number to rank by, and the top of that ranking is a short list, two packages in cal.com's case, which is why the exercise fits in an afternoon rather than a quarter.
Running it on your own repository
The measurement is two scripts. The first walks the package manifests, reads each package's declared dependencies, keeps the ones that are workspace packages, inverts the graph and counts transitive dependents per package. The second runs the git log for the window with file names, maps each touched file to the package directory that contains it, and counts commits per package. Join on the package name, multiply, sort. On a repository the size of cal.com the whole thing runs in under a minute once the history is cloned, and the output is a ranked list with two numbers beside each name.
The platform I build on is a Turborepo of the same shape, a Next.js application and a set of shared TypeScript packages, and the matrix is how the shared packages have been split over time: not by size, and not by what felt tidy, but by which package's commits were rebuilding the tree. The rule, hot hubs get split until they leave the quadrant, is simple enough that a reviewer can apply it to a pull request that adds a dependency: does this import make a hot hub hotter, or a foundation churnier? A faster remote cache answers neither question. The matrix answers both, from data the repository already has.
Get new posts by email
Occasional essays on engineering, AI, and building for the people technology leaves behind.
Subscribe with RSS