Let's Look at Monorepo Task Runner Build Caching

Peter Vera May 2, 2026
Source
I spent a few years working at Google, and one of the things that made an impression on me was Blaze (published externally as Bazel), the build system that Google uses to build its codebase. After having left, I've made a number of attempts at getting the sort of incremental builds that Bazel provided. Bazel has a reputation for being difficult to set up, so there is a number of other tools in the ecosystem that attempt to fill the gap. At work, I have bought us in to using Turborepo, but I increasingly find that the more packages we have the more likely we are for a small change to trigger a build that flows itself through all of the dependant packages. For example, if I have one package produces files, changing the source of that package in a way that doesn't change the type signatures shouldn't result in its dependant packages having to have it's TypeScript reevaluated. Intuitively, the more stuff that triggers a downstream rebuild the more downstream rebuilds will be triggered and the less benefit you'll get from caching at all. It's sort of hard to find the details for each task runner's caching behavior, so I decided it was time for me to build out a minimal example to test it out for myself. I explored as many of the "easy drop in at least it's not Bazel-level buy-in" task runners as I could find. I've published the results of my investigation on GitHub, feel free to take a look and play around with the code yourself. Methodology I created two sample packages: a JavaScript package whose build is just a script that removes the comments from it's one source file, and a package that depends on it that has a build that copies the output of the package and its own directory to a directory. These are intended to be analogous to a something like building a library package, and then the build of an application package that depends on that library. The task runners I tested were: - Lage - Moon - NX - Turborepo - Vite Plus - Wireit My test is: 1. Build all packages 1. Modify the source of the package and rebuild all packages (cache replay the unchanged dependant) 1. Undo that modification and rebuild all packages (cache replay the previous builds) 1. Modify the source of the package in a way that doesn't change the output and rebuild all packages (cache replay the unchanged dependant) 1. Modify the source of the package in a way that does change the emitted JavaScript and rebuild all packages (correct full rebuild, mostly as a smoke test to make sure I'm not missing something obvious) Observing what is built and what is replayed from cache at each step. Results & Analysis | Test | lage | moon | nx | turborepo | vite-plus | wireit | |------|------|------|------|------|------|------| | Replay Unchanged Package | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Replay N-1st Build | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Cache Unchanged Dependant | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | Most of the task runners I tested rebuilt the package, even though the built version of the package didn't change, I think this corresponds to whether the task runner calculates the input hashes before the entire task tree is run, or just before each task is run, and whether they consider a task having changed inputs to be an indicator that downstream builds should be invalidated. I was expecting there to be higher variance in the results here. I'm surprised to find that the only runners that did were NX and Vite Plus, the latter of which seems to have a problem replaying a build that wasn't the most recent build. I've sort of shied away from NX historically because it's configuration felt a little too unfamiliar, but maybe that's a tradeoff for this type of correctness. Feel free to reach out with any input!

Discussion in the ATmosphere

Loading comments...