← All posts
11 min readThe Cachely team

Before breaking the monolith, break up the build

A small change starts a full build. Someone suggests breaking the monolith into services. Before choosing that migration, ask which work the change actually requires. An application can ship as one unit without rebuilding every part from scratch.

Our position at Cachely: build boundaries should follow useful units of change and reusable work. Start with the smallest boundary that improves your feedback loop, and keep it only when measurement justifies its cost. This applies across application frameworks and build tools; installing another orchestrator is not a prerequisite for examining the work you repeat.

We put that position to a small test. Separating one build into five cacheable tasks let a catalog edit reuse three compiler results, but did not establish a latency improvement. That finding matters: a cache can successfully avoid work while the developer still waits longer. Below is a way to choose boundaries, followed by the measured case study and its reproducible source.

Three boundaries, three decisions

Repository boundaries decide where source lives. Deployment boundaries decide what ships independently. Task boundaries decide which work can execute or be reused independently. Changing one does not automatically improve the others. A monorepo can contain several services; a single deployed application can contain independently compiled modules.

This is an established architectural option. Shopify describes its move toward a modular monolith. Service extraction remains useful when independent deployment, scaling, or isolation is the actual requirement. Zhamak Dehghani's migration guide explains the business boundaries and operational readiness that journey needs. A build cache does not provide those properties.

Slow feedback alone is insufficient evidence for a service migration. Diagnose the delay first. If most of it is repeated compilation or deterministic checks, examine build boundaries. If it comes from coordinated releases, runtime contention, or a need for independent scaling, a build cache cannot settle the architectural decision.

A useful boundary earns its place

Consider a catalog, checkout, and reporting module that share a tax policy. A catalog implementation edit should not require recompiling independent checkout code. A tax-policy change may legitimately affect all three. The useful distinction is which inputs each result depends on, regardless of where the files live or how many services you deploy.

  • It isolates work that can stay unchanged. Look at representative changes. If two modules almost always change together or consume the same volatile inputs, separating their build tasks may buy little reuse.
  • It produces a result another step can use. A compiled library, generated client, or deterministic test result can be a useful unit. Moving source into another folder helps only if the build can act on that boundary.
  • Its inputs are explicit and its outputs are reproducible. Include relevant dependencies, configuration, toolchain, and environment. A task that consults an untracked live service needs that dependency modeled or must run without result caching.
  • The avoided work is worth the overhead. Compare execution cost with scheduling, hashing, transfer, and restoration costs. Splitting a cheap task into many smaller tasks can make the pipeline slower.

We would start with one expensive, cohesive unit that stays unchanged across common edits. We would keep cheap, tightly coupled work together. The number of packages is a consequence of that decision, not a performance target.

Measure the wait, not just the hit rate

Record the time from starting a change's checks to receiving a usable result, alongside executed work and cache transfer time. A high hit rate can hide one expensive miss on the critical path. Conversely, avoiding CPU work can reduce compute consumption without shortening the longest chain of dependent tasks. Decide which outcome matters for your team and measure it directly.

Use the same small set of probes before and after changing a boundary: a cold build, identical inputs on a clean runner, an isolated implementation edit, a shared dependency edit, a toolchain or configuration change, and an unrelated file edit. Check the resulting application as well as the cache logs. A relevant change must invalidate its consumers; an irrelevant change should preserve reusable results.

Keep dependency installation, queue time, final bundling, and deployment visible in the measurement. Cached compilation cannot remove every other source of delay. If restoring a result costs more than recomputing it, leave that task uncached or reconsider its granularity. If the bottleneck is elsewhere, work there first.

The contract travels across tools; the configuration does not

Gradle describes task-output caching, Bazel caches action results, and Turborepo fingerprints tasks and restores their outputs. Nx also hashes and restores task results. These systems differ in how they discover dependencies, determine cacheability, and package results. The common question is whether a previous result is valid for the work requested now.

Use the dependency model your tool provides, and verify it against real changes. A JavaScript bundler may still rebuild the final application after a library changes; a compiler may already support finer incremental work inside a task. Inspect those behaviors before adding another layer. Selecting only affected work and reusing results for selected work are separate optimizations; measure each without assuming either makes the other unnecessary.

Cachely's role is to store and share the results your supported build tool identifies. The tool and its configuration determine the reusable boundaries. A remote cache cannot repair missing dependencies or turn an opaque build command into independently reusable steps.

A measured case study: one application, two build graphs

We used Nx and TypeScript to make one implementation of this question reproducible. This is a case study of task granularity, not a comparison of tools or a recommendation to migrate to Nx. We have not run this workload with other build systems; the timings and invalidation behavior below belong to this implementation.

The example has five small TypeScript programs. A shared module applies a tax policy. Catalog, checkout, and reporting consume it. The application imports those three domains and prints their totals. The final build copies the emitted JavaScript and declarations into one runnable deployment directory. There are no network services, databases, or application bundler in this example.

shared:build
  +-- catalog:build ----+
  +-- checkout:build ---+--> application:build --> one deployment directory
  +-- reporting:build --+
  • Coarse layout: one Nx task compiles the five programs in dependency order and packages the deployment. Any relevant source change invalidates that task.
  • Modular layout: five Nx tasks compile the same programs, with explicit project dependencies. The application task also packages all outputs into the deployment directory.

Both layouts invoke the same TypeScript compiler with the same settings and produce byte-identical deployment files for each scenario. Neither uses TypeScript incremental state. We select every build task with run-many and set --parallel=1, so this experiment isolates cache granularity. It does not measure affected selection, parallel scheduling, distributed execution, or test caching.

The source is already modular in both layouts. This is a controlled change in Nx task granularity; it does not measure the effort or benefit of extracting modules from an existing application. The coarse task deliberately wraps the same five compiler invocations so the underlying work stays constant.

Every sample starts in a new temporary workspace without outputs or a local Nx cache. A fresh namespace makes each repetition's first build cold remotely too. We then reuse that namespace across the changes in that repetition. Each change is made independently from the baseline, so the shared policy scenario does not also contain the catalog edit.

What actually executed

Changing the catalog price executed the coarse build in full. With the modular graph, catalog and application executed; shared, checkout, and reporting were remote hits. That is three compiler invocations avoided while retaining the same deployment shape. Changing the shared tax policy or the compiler configuration executed all five modular tasks. Those misses were necessary.

An identical-input replay executed no build tasks in either layout. Nx reported remote hits and restored files matching the cold deployment's SHA-256 digest. An unrelated README change also reused every task: these programs do not consume that file, so it is outside their inputs. Documentation generators would need different inputs.

Measured Nx build times and cache behavior for coarse and modular task graphs. Times are medians with observed minimum and maximum ranges.
ScenarioCoarse median (range)Modular median (range)Executed tasks, coarse / modularRemote hits, coarse / modular
Cold cache7.86s (6.44s-30.74s)11.35s (10.03s-14.55s)1 / 50 / 0
Identical inputs, fresh workspace3.38s (2.75s-12.88s)5.89s (4.55s-8.70s)0 / 01 / 5
Catalog implementation change7.64s (6.54s-20.63s)9.27s (5.36s-11.16s)1 / 20 / 3
Shared tax policy change9.54s (6.76s-12.55s)8.85s (7.88s-16.19s)1 / 50 / 0
Compiler configuration change11.76s (6.52s-12.30s)9.30s (6.56s-18.01s)1 / 50 / 0
Unrelated README change3.64s (3.14s-6.05s)3.45s (2.81s-6.91s)0 / 01 / 5

These are 5 repetitions per layout and scenario, measured on 2026-10-21, using Node v24.16.0, Nx 23.3.0, TypeScript 6.0.3, and Apple M1 Pro on darwin/arm64. Each cell reports the median and the observed minimum-maximum range. Layout order alternates between repetitions; scenario order is fixed. The complete measurement record includes every sample, task durations, output digests, script digest, and system load.

What these numbers can tell you

The modular cold median was 11.35s, compared with 7.86s for the coarse task. Even a replay with no executed build tasks took 5.89s in the modular graph and 3.38s in the coarse graph. More reusable results did not give us an automatic end-to-end win.

The task counts establish selective reuse and correct invalidation for this graph. The timings describe this small experiment on a shared development machine, with other workloads running. The ranges overlap, so this run does not establish a causal speed difference between layouts. These are descriptive samples, not confidence intervals or an isolated-machine comparison. Compiler and Nx process startup form a large part of this workload; we added no sleeps or artificial CPU load.

The backend was the unmodified Cachely Worker in local workerd with local R2 and static-token development authentication, using Wrangler 4.147.0. It had no D1 accounting, production network, TLS, or hosted R2 latency. These are real HTTP remote-cache round trips across separate processes on one machine. They do not measure hosted Cachely performance or predict customer CI savings.

Wall time includes Nx startup, graph discovery, hashing, cache reads and writes, and task execution. It excludes fixture creation, Worker startup, and independent output checks after the command. A remote hit still has overhead. Reducing executed tasks does not guarantee an equal reduction in pipeline duration, especially when setup, downloads, packaging, or one long task dominates.

Each domain compiles against the shared module's emitted declarations. The final packaging task still copies every module's output after a domain change. We avoided some compilation work while retaining assembly and one deployment. This compiler-and-copy example cannot establish what an application bundler or another tool's dependency model would do.

Reproduce the Nx case study

Download the standalone experiment script. It contains the complete generated example, both task graphs, all six scenarios, and the assertions. Use a disposable directory and a disposable Nx-compatible cache workspace with permission to read and write the synthetic artifacts. It needs no access to your application source.

mkdir nx-build-boundaries && cd nx-build-boundaries
npm init -y
npm install --save-exact nx@23.3.0 typescript@6.0.3 @types/node@26.6.4
curl -fsSLo nx-build-boundaries.mjs https://cachely.dev/examples/nx-build-boundaries.mjs

# Supply the cache host and token through your existing secret mechanism:
# NX_SELF_HOSTED_REMOTE_CACHE_SERVER
# NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN
node --env-file-if-exists=.env nx-build-boundaries.mjs > results.json

Use Node v24.16.0 to match our run. The script lends the installed toolchain to fresh temporary workspaces, runs five repetitions, and removes its temporary source, local caches, and deployment outputs afterward. Your remote cache retains the uploaded synthetic entries under their isolated hashes. The experiment fails if task execution, remote-hit counts, restored files, or deployed totals differ from expectations. It does not print the token. The command loads a local .env file if present; variables supplied by your secret manager take precedence. Keep that file gitignored and do not commit credentials with the result.

The downloadable workload runs against the endpoint you supply. Our published timing record uses the local Cachely setup described above, so hosted runs will have different network and storage costs. Keep those new results separate rather than treating our local timings as your baseline.

Try one boundary in your application

Take one slow, frequently repeated check in your current build. Find a result that can be reused independently, verify it on a clean compatible runner, then change a real dependency and confirm it invalidates. Compare the wait and compute cost before expanding. Our CI performance guide can help locate the bottleneck; Repo Check can help review repository, deployment, and task boundaries. Runtime measurements decide whether a proposed boundary earns its place.

When that result is worth sharing across machines, connect your build tool to Cachely. Cachely provides managed remote caching for Gradle, Bazel, Turborepo, and Nx, including Lerna through Nx. Choose the setup for the tool you already use, keep writes in trusted CI, and test one remote hit on a fresh runner. Each tool keeps its own cache format and rules; sharing a service does not make their artifacts interchangeable.

The useful outcome is faster, trustworthy feedback at a cost you can justify. Keep the monolith when it serves the application. Split services when delivery and runtime needs call for them. In either architecture, give the build enough information to avoid repeating work that is already done.

Read the full guideMonorepo build performanceWhy monorepo builds get slow and the four cache layers that fix it.

Related posts

Put a shared build cache behind your builds
Free tier, no credit card - connect Nx, Turborepo, Gradle, or Bazel in about five minutes.
Start freeSee pricing