FE/26
MENU

2026-08-07 · 11 min read

Performance Is Making Gradle Less Gradle

ArchitectureBuild SystemsPerformanceComplexity

Gradle has always been interesting to me because the thing that made it good is the same thing that made it expensive.

You could do almost anything.

A build file was never really a build description. It was a program that happened to be about building. Projects could inspect each other, mutate each other, reach into global state, configure things three levels of indirection away, and generally perform tricks that look clever in a five-module demo and look like an incident in a four-hundred-module repository.

At small scale that flexibility was the selling point. At large scale it became rent.

Gradle 9.7 promotes Isolated Projects from experimental to incubating. The headline is a performance feature. The interesting part is what Gradle had to take away to get it.

The original flexibility was not a mistake

I do not mean that Gradle was badly designed. The flexibility was the product.

If you arrived from a tool where a build was a rigid XML incantation, having a real language and a real object model available during the build felt like being let out of prison. Want to configure every subproject from the root?

groovy
subprojects {
    tasks.withType(Test) {
        ...
    }
}

Done. Want project A to read project B? You could. Want a plugin to crawl the entire project model and mutate whatever it finds interesting? Nothing stopped you. Certainly not Gradle.

The trouble starts when Gradle tries to answer one deceptively boring question:

Can I configure these two projects at the same time?

At that point every piece of that freedom sends its invoice.

If configuring :vehicle-api might mutate :telemetry, Gradle cannot configure them concurrently. If a plugin can read arbitrary global state, Gradle cannot know what the configuration actually depends on. If configuring one project has invisible side effects on another, Gradle cannot cache either result independently.

The performance problem was never only a performance problem. It was an architecture problem holding a stopwatch.

Parallelism has a price, and the price is isolation

This is the part I want to write about.

With Isolated Projects enabled, build logic belonging to one project can no longer freely access the mutable state of another. Gradle can therefore configure projects concurrently without guessing.

The causal chain is almost suspiciously clean:

THE CHAIN · PERFORMANCE ASKING FOR ARCHITECTURE PERFORMANCE PRESSURE PARALLEL CONFIGURATION SAFE CONCURRENCY PROJECT ISOLATION STRICTER BUILD ARCHITECTURE nobody chose the boundary for its elegance the stopwatch chose it
Read left to right it is a performance roadmap; read the bar at the bottom and it is an architecture decision. Nobody at Gradle argued for the boundary on its merits — the schedule made it the only remaining move.

Nobody at Gradle woke up one morning moved by the inherent beauty of architectural boundaries. Gradle needs to configure large builds faster. Parallelism is the obvious lever. Parallelism requires independence. Independence requires removing shared mutable state.

And so something that used to be a polite suggestion in a best-practices document becomes a constraint the tool enforces.

Gradle 9.7 leans into that. Violations fail fast by default, several accesses to mutable build-scoped state are treated as incompatible, and the legacy org.gradle.unsafe.isolated-projects property names are deprecated in favour of org.gradle.isolated-projects — they still work as aliases, for now.

The .unsafe prefix is disappearing because the constraint is becoming the architecture. The maturity needs stating precisely, though: the feature is not enabled by default and is not yet recommended for production. Most builds and most plugins will need changes to satisfy it. That is not a footnote — it is the whole point. The tool is asking the ecosystem to give something up.

Gradle has been moving this way for years

Isolated Projects did not appear from nowhere. Line up Gradle's performance features in order and the pattern is hard to miss.

Incremental builds. Gradle needs to know inputs → task → outputs. If a task secretly reads or writes something Gradle does not know about, incrementality quietly lies. Freedom down, performance up.

Build cache. Now Gradle wants to reuse outputs on a different machine. That demands a stronger contract, roughly f(inputs) = outputs. Hidden dependencies stop being quirks and start being bugs. More freedom removed.

Configuration cache. Gradle asks the obvious follow-up: why configure the build from scratch every time? But caching configuration means the configuration phase can no longer behave like arbitrary application startup code. Gradle has to understand and serialize the resulting model, and plugins have to stop holding references to objects they do not own.

Isolated Projects. Now configuration itself should run concurrently:

CONFIGURATION · TWO LANES ON ONE WALL CLOCK PROJECT A CONFIGURATION PROJECT B CONFIGURATION no ordering constraint between them because neither can reach the other wall clock → serialised before · overlapped now
The overlap is the entire feature. Everything Isolated Projects takes away exists to make these two bars legal to draw starting at the same tick.

A project is supposed to describe itself, not wander through the build modifying its neighbours.

Every step is the same move:

FOUR OPTIMIZATIONS · EACH BUYS SPEED WITH FREEDOM INCREMENTAL BUILD CACHE CONFIG CACHE ISOLATED PROJECTS inputs → task → outputs f(inputs) = outputs the model must serialize no shared mutable state freedom the build logic keeps what Gradle is allowed to assume every column makes something illegal that used to be legal that removal is the optimization
The two bars are the same quantity measured from opposite ends. Gradle never found a faster algorithm for configuration — it kept buying knowledge about the build with freedom taken from the build.

The end state looks remarkably declarative for a tool whose original pitch was that your build script is just code.

Strictness is what buys the speed

The headline is: Gradle can configure projects in parallel.

The actual story is: Gradle can configure projects in parallel because it started making entire categories of previously legal behaviour illegal.

That distinction gets lost constantly. Performance work is usually sold as implementation wizardry — better algorithms, larger caches, more threads. But often the largest optimization is making the system possible to reason about.

Remove hidden dependencies. Restrict mutation. Draw boundaries. Turn implicit inputs into explicit ones.

Do that, and the machine suddenly has options. It can cache. It can reorder. It can parallelize. It can skip work entirely.

Strict architecture does not cost performance. It creates the space where performance becomes legal.

This trade is also available as a working model: add a cross-project reference to an eight-project build, then switch Isolated Projects on and configure. It needs the page's WebAssembly bundle, which has not loaded here — the argument is unchanged without it.

The trade, operable — and deliberately arranged so the speedup is the second thing you notice. Configure with cross-project access allowed and all eight projects reconfigure serially, every time, because any one of them may have mutated another. Switch to ISOLATED and edit a single project: one configures, seven come from cache. Then add a cross-project reference and configure again — the build does not get slower, it fails, on logic that was legal thirty seconds earlier. The wall-clock figures are arithmetic from a stated model, not a measurement; the real numbers are in the paragraph below.

The numbers Gradle reports from migrating its own build are not subtle. An IDE sync after adding a dependency dropped from roughly 45 seconds to 27. A sync after changing build logic went from about 2 minutes 57 seconds to 1 minute 16. Across months of real developer usage, median sync time fell from roughly 1:24 to 0:47, and the 95th percentile dropped from nearly seven minutes to a little over three.

GRADLE’S OWN BUILD · IDE SYNC, BEFORE AND AFTER SYNC AFTER ADDING A DEPENDENCY 45s → 27s SYNC AFTER A BUILD-LOGIC CHANGE 2:57 → 1:16 MEDIAN SYNC · MONTHS OF REAL USE 1:24 → 0:47 95TH PERCENTILE ~6:50 → ~3:10 drawn to scale · one second = 1.35 px pale bar: before · solid bar: after
The bottom row is the one that matters. Medians move for many reasons; a 95th percentile falling by half is the tail of the distribution — the sync that made someone stop and check their phone — being cut off at the source.

Seven minutes to three is a large amount of accumulated pressure finally finding an exit.

TypeScript is doing the same thing from the other end

There is a second example running on a parallel track, and it is almost too neat.

TypeScript started from the opposite corner of the design space. Its genius was gradual adoption: take JavaScript, add some types, keep going, do not frighten anyone. For years it kept a deliberately strange relationship with strictness — capable of very strong guarantees, and perfectly willing to let you off the hook.

Then the defaults moved. TypeScript 6.0 makes strict true by default, and TypeScript 7 carries those defaults into the native compiler. Individual options can still be relaxed, and "strict": false remains something you can write. Culturally, that is a footnote. What changed is the contract:

THE DEFAULT MOVED · TYPESCRIPT 6.0 BEFORE NOW PERMISSIVE STRICT optionally become strict explicitly ask for exceptions a project ends up unchecked by accident a project ends up unchecked by decision the capability never changed · both columns can express both states only who has to say so
Every configuration reachable on the right was reachable on the left. What moved is the cost of ending up unchecked: it used to be free and silent, and now it takes a line of config with your name on it.

The team's own reasoning is the interesting bit: with --strict off, a project that ends up unchecked usually got there by accident rather than by decision. That is a statement about scale, not about taste. TypeScript needed permissiveness to invade the JavaScript world. Once it became the infrastructure underneath enormous codebases, permissiveness stopped being purely an asset.

The pressure changed, so the default changed.

Mature systems tend to get less permissive

I think this pattern is consistently underrated.

Young technologies win by being flexible. They let you escape. They do not impose architecture. They tolerate the deeply unpleasant thing your existing system already does, because tolerating it is how they get adopted at all.

Then people actually use them. Repositories become enormous. Build graphs acquire thousands of nodes. Millions of developers start expecting IDE feedback to be instantaneous. Plugins interact in combinations nobody designed, tested, or imagined.

And flexibility acquires a measurable cost:

WHAT FLEXIBILITY COSTS AT SCALE FLEXIBILITY IMPLICIT DEPENDENCIES LARGER STATE SPACE HARDER REASONING SAFE OPTIMIZATIONS · what is left for the machine the cost is not the freedom · it is the state space the freedom opens and the optimizer pays it
Nothing in the top four bars is a defect. Each is a capability someone wanted, and each one widens the space of programs the tool must assume it might be running — which is drawn here as the thing that squeezes the bar at the bottom.

At that point restrictions become features. Not because someone finally discovered the One True Architecture, but because the system is under load and something has to give.

The rule has to earn its existence

This is really why I like the Isolated Projects story. It is the clearest recent example of the thing I keep writing about: architecture must follow pressure, and a boundary is only worth its cost when you can name the force that put it there.

"Projects should not mutate each other" reads like another clean-code commandment when it appears in a style guide. It becomes considerably more interesting when the causal chain is visible:

THE RULE, WITH ITS RECEIPT HUGE MULTI-PROJECT BUILDS SLOW CONFIGURATION AND IDE SYNC PARALLEL CONFIGURATION SHARED MUTABLE STATE IS UNSAFE PROJECTS MUST BE ISOLATED measured, not assumed the complaint everyone files the only lever left a fact about concurrency — and only here does the rule appear read it upward and it is a style guide read it downward and it is a force
The same sentence sits at the bottom of this figure and inside every clean-code checklist. The difference is the four blocks above it, and whether you can point at them when someone asks why the rule is there.

Now the rule has earned its keep.

That is a healthier way to think about strictness in general. I do not want isolation because isolation is elegant. I do not want declarative builds because declarative programming is fashionable. I do not want strict types because strict types are morally superior to loose ones.

I want to know what pressure made the restriction necessary.

Gradle is a particularly good specimen because the pressure accumulated in public, over years. It started as an extremely programmable build system. Builds became huge. Performance started hurting. And piece by piece, every optimization required Gradle to understand more about what the build was doing.

To understand it, Gradle had to constrain it. To parallelize it, Gradle had to isolate it.

Gradle is becoming stricter because reality is slowly taking away its options. In my experience, that is where most of the interesting architecture comes from.

References

Gradle

  1. Gradle 9.7.0 Release Notes — Isolated Projects graduates from experimental to incubating; legacy org.gradle.unsafe.* property names deprecated. https://docs.gradle.org/current/release-notes.html

  2. Gradle User Manual, Isolated Projects — the feature, its requirements, and the Isolated Projects Constraints chapter. https://docs.gradle.org/current/userguide/isolated_projects.html

  3. Gradle, How the Gradle Team Adopted Isolated Projects — the IDE sync measurements quoted above. https://blog.gradle.org/

TypeScript

  1. Announcing TypeScript 6.0 — strict enabled by default, esnext module default, floating target. https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/

  2. microsoft/TypeScript issue #62333, Enable --strict by default — the design discussion behind the change. https://github.com/microsoft/TypeScript/issues/62333

The ideas

  1. Fabio Ellena, "Architecture Must Follow Pressure," 2026. https://fblln.github.io/articles/architecture-must-follow-pressure/

  2. Fabio Ellena, "Risk, Complexity, and Pressure," 2026. https://fblln.github.io/articles/risk-complexity-and-pressure/

  3. Fabio Ellena, "Who Pays for the Pressure," 2026. https://fblln.github.io/articles/who-pays-for-the-pressure/