Your CI Runs Every Test On Every PR. That Is Not A Feature. That Is Waste.
Most teams run the full test suite on every PR and call it thorough. It is not thorough - it is expensive, slow, and quietly teaches everyone to distrust the results. Change-based test selection is a decade old. Frontend teams are still running 40 minutes of tests to validate a one-line change.
The PR That Sat In CI Longer Than It Took To Write
A friend messaged me a screenshot last month. A PR that changed one line in a CSS file. The change was updating a hex colour on a button. It took him about 40 seconds to write. It sat in CI for 47 minutes.
The CI run had done everything. Type-checked every file in the monorepo. Ran every unit test. Ran every integration test. Ran a full end-to-end suite in three browsers against a full-app deployment. Rebuilt every downstream package. Regenerated a translations bundle. All to prove that #c84b31 was safe to merge.
The maddening part is not that this happens. The maddening part is that most teams do not think of it as a problem. They think of it as thoroughness. "We run every test on every PR" gets said like a virtue at design reviews, like "we type-check strictly" or "we have 90 percent coverage". Nobody thinks to ask whether running every test is doing any work at all beyond what running the tests that touch the change would have done.
Because in this case - a CSS colour swap - the answer is unambiguous. Zero. Not one of those 47 minutes of test execution had a chance of failing because of the change. Zero of them exercised the code the PR modified. All of them were, in the most literal sense, waste. And the team spent real cloud money and real engineering wall-clock time to prove nothing they did not already know.
"Running every test on every PR is not thoroughness. It is a confession that the CI does not understand your codebase well enough to know which tests are relevant."
"But What If A Distant Test Would Have Caught It"
The first defence when this topic comes up in code review is a version of this: "But what if the CSS change somehow broke something in an unrelated module, and only that module's test caught it?" It sounds reasonable on the surface. It also gets waved around like a trump card that ends the conversation.
The honest response is two-parted. First: this concern is answered by change-based selection done properly, not by running everything blindly. If your build system can prove that module X does not depend on the file you touched - transitively - then module X's tests literally cannot fail because of your change. Any failure in module X's tests on your PR is either a flaky test or a pre-existing failure. Both need to be handled, but neither is a case for running module X's tests on your PR.
Second: the "but what if" framing has an opportunity cost that never gets counted. Every minute of CI wall-clock time on your PR is a minute the reviewer does not spend reviewing, and a minute the machine cannot spend running the actually-affected tests to a higher signal (say, more retries for flakiness, or against real infrastructure instead of mocks). Running the full suite has a cost, not just a benefit. Most teams pretend the cost is zero.
Bazel Has Done This Since Roughly 2015
The mildly humbling part of this whole story is that change-based test selection is not a novel idea somebody dreamed up in 2024. Bazel has been shipping it for something like a decade. So has Buck, Meta's build system. Nx has had affected-graph-based selection since its early days. Turbo does its own version. So does Pants. It is not new. Frontend teams are just very good at pretending build-system innovations from the last decade did not happen if they were not born in the npm ecosystem.
The core idea in all of these systems is the same, and it is boring once you see it:
- Every unit of code has a declared set of dependencies. A package, a target, a project - whatever the granularity - names the other units it consumes. Not "we all live in one repo, everything depends on everything". Explicit, tracked dependencies.
- Every test is attached to a unit. When you run tests, you are running the tests belonging to a set of units.
- Given a diff, the build system computes the reverse transitive closure. Which units directly changed, then which units depend on those, then which units depend on those, all the way out. That set is the affected set. Tests belonging to units outside that set cannot be affected.
That is the whole thing. Once you have that, "run every test that could plausibly be affected" is a single graph query, not a manually-maintained list of exceptions.
Frontend teams have every ingredient for this. Package.json dependencies. Import statements. Workspace manifests. What they usually lack is the discipline to keep the dependencies explicit and the willingness to trust a computed affected set. Both of those are cultural problems, not technical ones.
Nx, Turbo, And What Actually Works Today
You do not have to adopt Bazel to do this. The frontend ecosystem has caught up enough that there are two real, workable answers depending on the shape of your repo.
Nx - the "monorepo with a proper graph" answer
Nx computes a dependency graph across all projects in the workspace and exposes it via nx affected. On a PR, you run:
nx affected --target=test --base=origin/main
Nx diffs the PR against main, walks the graph, and runs the test target on only the affected projects. If your PR only touches a leaf package that nothing depends on, only that one test target runs. If your PR touches a shared utility that seventeen packages depend on, all seventeen run. The math is the reverse-transitive closure, done by the tool.
Turbo - the "npm workspaces with caching" answer
Turbo is less opinionated about the graph but wins the other side of the battle: aggressive, correct build caching. If test outputs for a package are cached against the exact input hash and the inputs have not changed, the tests do not re-run. Cache hit means instant success. Cache miss means run the tests, then cache the result. Cross-CI-run and cross-machine caching makes the "second CI run costs nothing" experience genuinely achievable.
You can combine both approaches. Nx for the graph, remote caching for the reruns. The point is not any specific tool. The point is that "the machine should know which tests to run" is a solved problem, and the tooling to do it is one config file and a mildly disciplined dependency layout away.
Why Teams Refuse To Ship This Even After They Understand It
Every time this comes up in a real team, the technical arguments end pretty quickly. Somebody demos nx affected or Turbo cache hits and the room agrees: yes, this is obviously the right shape. Then nothing happens for six months.
Because the actual blocker is not tooling. It is fear plus habit.
- Fear. Teams have been burned once by a green CI hiding a real bug. Somebody remembers a specific PR that was merged with a green tick and broke production, and the story got told at post-mortems. Now nobody wants to be the person who suggested running fewer tests. "I did not run the tests that would have caught it" is a career hit; "I ran every test we have, and it turned out we had a flakiness problem" is not.
- Habit. The current pipeline works, in the sense that it does not obviously break. Changing it is a project. Projects need staffing, buy-in, and a champion. Meanwhile the current pipeline is silently costing the team the equivalent of half an engineer a year in CI compute and much more in wall-clock time, but that cost is diffuse and easy to ignore.
- Tool inertia. "We are already on GitHub Actions" or "we already have a Buildkite pipeline" or "we are on Jenkins for historical reasons" becomes the reason not to consider a different shape, even though these tools are perfectly capable of running affected-only. The tool is not the blocker; the pipeline shape is.
The counter-move for each of these is not an argument. It is data.
Instrument your existing CI to log, per PR, two numbers: how long the run took, and what fraction of the tests that ran actually touched the changed files (via a simple reverse-graph query, run for analytics, not for gating). Look at the distribution. On a typical mixed-workload frontend monorepo, the number is often below 5 percent. That is the graph the room needs to see to move.
"We Already Split Tests Into Fast And Slow, Isn't That The Same Thing"
No. And this confusion is common enough to spell out.
Splitting the test suite into "fast" and "slow" - unit tests on every PR, integration tests only on main, or unit + integration on PR but E2E only nightly - is a different optimisation. It is a coarse-grained bucket approach. It says "we do not run the slow tests on your PR because slow is scary", but within each bucket it still runs everything.
The bucket approach and the affected-graph approach solve different problems. The bucket approach reduces wall time by dropping the slow tests. The affected approach reduces wall time by dropping irrelevant tests, fast or slow. A well-configured team does both. They run only the affected unit tests, and only the affected integration tests, and only the affected E2E flows. What is unaffected does not run.
Buckets by themselves have a nasty failure mode: they defer risk. Integration tests that only run on main mean the first time you learn about an integration break is after merge, when it costs more to fix. Affected-graph selection catches the break on the PR, if the PR could have caused it, without paying the full-suite cost.
The Two Things That Have To Be True For This To Be Safe
The reason "run every test" persists in the wild is that affected-graph selection is only safe if the graph is correct. If the graph says module X does not depend on file Y, and it turns out module X reaches file Y via some clever runtime import you forgot to declare, you can merge a PR that broke X without ever running X's tests. That is a bad day.
The two correctness conditions:
- Dependencies must be static and complete. Every dependency between two units must be visible to the graph-computing tool. In practice this means: package.json
dependenciesdeclared honestly, norequire(dynamicVar), no runtime module loading that bypasses the resolver. The typical frontend monorepo is 95 percent of the way there because bundlers already need this to work. The remaining 5 percent - a hand-written module loader, an unused-import that turns out to actually be used - is what breaks the affected-graph safety. - Non-code inputs must be tracked. Tests can fail because a fixture changed, because a snapshot needs updating, because a translation file was regenerated, because an environment variable flipped. If your affected-graph only considers .ts and .tsx files, you can miss real breakage from a JSON change. The good tools support declaring input globs for a target; use them.
Both of these are enforceable via CI checks. There are lint rules that catch runtime require patterns. There are tools that verify the declared dependency set matches the actual imports. The correctness bar is real, but it is enforceable, and the payoff for enforcing it is that you finally get to trust the graph.
"Correctness of the dependency graph is a code-quality investment. The reward is that you get to stop running tests you did not need to run."
The Shape You Should Be Aiming For
Concretely, what does a good CI pipeline look like once you take this seriously? Not one shape - it depends on your repo - but here is a template that works for most frontend monorepos I have seen.
# on every push to a PR branch stage 1 install (from cache) # ~seconds stage 2 static checks on changed files only # lint + typecheck stage 3 build affected packages # nx affected --build stage 4 unit tests for affected packages # nx affected --test stage 5 integration tests for affected surfaces # flow-tagged stage 6 E2E only for affected user journeys # by owner + tag # on merge to main - the full-suite safety net stage 7 full type-check, full unit + integration stage 8 full E2E across the full journey matrix stage 9 block deploy if red, alert on-call
Two things about that shape worth noting. The PR path runs only what the change could affect. The main path runs everything. That is the "buckets and affected graphs" combination working together - fast, precise feedback on PRs, thorough validation before anything reaches users.
And on the main path, if a test breaks, it breaks a merge that has already been reviewed and approved. That is a different failure mode than a broken PR; it is a "main is red" event, which pages an on-call, blocks further merges, and gets fixed in minutes. The full suite is not skipped; it is moved to the place where it can actually catch things that the PR path could not.
What "Run Everything" Actually Costs You
Skeptical readers may still be wondering if this is worth the investment. Here is the cost side that usually does not make it into the room.
The flake tax is the one that quietly destroys team velocity. Running every test on every PR means every PR has the odds of hitting at least one flaky test approaching 1. So the team gets used to red CI meaning "probably flaky, retry it". Which means when a red CI actually indicates a real regression, nobody believes it. The signal has been trained out of the pipeline.
Affected-graph selection improves this because the number of tests running is smaller, so the flake surface is smaller, so a red CI is more informative. It is not "we ran less to save money". It is "we ran less to make the results actually mean something".
"A CI that runs 40 minutes and is right 90 percent of the time is a worse tool than a CI that runs 4 minutes and is right 99 percent of the time. The second one you actually trust."
What To Do This Week If You Own A CI Pipeline
None of this needs a six-month project. Three things in decreasing order of effort, any one of which is a real step in the right direction.
- Instrument the "affected fraction" metric. On every existing CI run, compute what fraction of the tests that ran actually touched the changed files. Log it. Chart it. Do not gate anything on it yet - just make it visible. Once the room has seen the number, the conversation about selecting tests becomes an easy one, not a philosophical one.
- Turn on remote build caching for one test target. Even without affected-graph selection, remote caching alone gives you "second CI run costs nothing" for unchanged code. This is one config change and it is a strict improvement.
- Adopt affected selection on a single non-critical path first. Storybook builds, docs generation, ancillary test suites - anything where a wrong graph would be caught in review rather than in production. Get the team used to trusting the graph on low-stakes decisions, then extend it to unit tests, then integration tests. Do not try to convert the whole pipeline in one PR.
The larger point is that "we run every test on every PR" is not the safe default. It is a specific tradeoff that has costs, and those costs are underweighted in most conversations because they are diffuse. Change-based selection is not risky; running the whole suite badly is risky. It just does not feel that way, because the failure mode of the current approach is invisible to everyone except the people who watch the CI queue depth graph.
Related essays
- Vite · Frontend Infrastructure
We Blamed Vite For A 90-Second Local Load. The Real Bottleneck Was The 11,516 Modules We Asked It For.
A local Vite page took 90 seconds to load because eager route imports pulled 11,516 modules into one page load. Making routes lazy cut it to 54 modules and 10.54 seconds. The mechanism, how to count your own modules, and where the fix stops working.
- Web Performance · LCP
loading="lazy" On Your Hero Image Waits For Your CSS. Same Page, Same Attribute: 16ms Slower In One Test, 428ms In The Next.
loading="lazy" on an above-the-fold image does not wait for a scroll, it waits for layout. Measured in Chromium, Firefox and WebKit: the lazy hero request starts right after the stylesheet lands, costing 16ms on one network profile and 428ms on another, why fetchpriority cannot rescue it, and why making everything eager is worse.
- JavaScript · Browser Internals
setTimeout(fn, 0) Is Not Zero. In A Background Tab, Your 100ms Timer Runs Once A Minute.
The delay you pass to setTimeout is a minimum. Measured in Chromium, Firefox, WebKit and Node: the 4ms nesting clamp, hidden tabs throttled to 1 second, Chrome's once-a-minute intensive throttling after 5 minutes, a 30-day timer that fires instantly, and what to use instead.