18 September 2026 · 5 min read

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.

How a changed hash in one shared package propagates to every dependent task's cache key A package graph drawn as layers. At the bottom, a shared package whose hash changed. Above it, four packages that import it, each with a cache key that now includes the new hash and therefore misses. Above those, three applications that import them, whose keys miss in turn. Arrows flow upward from the changed package, and every box it reaches is marked as a cache miss. One hash, ninety cache keys shared package hash changed miss miss miss miss app: miss app: miss app: miss Every task whose key includes the changed hash rebuilds, however small the change was.
Illustrative: cache-key propagation through a package graph, as Turborepo's dependency hashing implies.

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.

Churn against reach for the packages in the cal.com monorepo, with the hot hubs highlighted A scatter with churn, commits in ninety days, on the horizontal axis and reach, transitive dependents, on the vertical. Most of the 112 packages cluster at one commit and zero dependents. The types, tsconfig, config and dayjs packages sit at one commit and 84 to 91 dependents. The web app and features package sit at seven and ten commits with zero to five dependents. Two packages sit in the top right: i18n at seven commits and 89 dependents, and lib at five commits and 83 dependents, highlighted as the hot hubs. Two packages are where cal.com's cache misses come from Commits touching the package in 90 days against transitive dependents, 112 packages 100 75 50 25 0 0 5 10 hot hubs stable foundations leaves: most packages sit here apps and features types, 91 config, dayjs, 84 prisma, 35 about 90 packages here ui, 11 trpc, 7 web app, 0 features, 5 i18n: 7 commits, 89 deps lib: 5, 83 hot hub other packages
Source: computed by the author from the cal.com repository, commits on the default branch in the ninety days to 18 September 2026 and dependency declarations in each package manifest; positions are jittered where packages overlap.

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 four quadrants of the churn-reach matrix with the split rule applied to the hot-hub cell A two by two grid: churn on the horizontal axis, reach on the vertical. Bottom left, leaves: leave alone. Bottom right, apps and features: their rebuilds are their own. Top left, stable foundations: protect from churn. Top right, hot hubs: split the changing part from the imported part, and repeat until the package leaves the cell. An arrow shows a hot hub moving into the top-left and bottom-right cells after the split. Stable foundations protect from churn Hot hubs split the changing part from the imported part, then repeat Leaves leave alone Apps and features their rebuilds are their own Churn: commits in 90 days Reach: transitive dependents
Illustrative: the matrix and the split rule; the arrows show where the two halves of a split hub land.

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.

TurborepoMonoreposCI
All writing

Written by Mohd Shayan

Get new posts by email

Occasional essays on engineering, AI, and building for the people technology leaves behind.

One email per new post. Unsubscribe any time.

Subscribe with RSS