Where does Gradle build time go in a large project?
Before changing the build, find out which phase is slow. Gradle gives you two built-in views. --profile writes a local HTML report under build/reports/profile that splits wall time into configuration, dependency resolution and task execution, with per-task durations. --scan publishes a build scan with a timeline, which shows which tasks actually ran, which were up to date or loaded from cache, and how much ran in parallel. Use the scan only when your organisation allows publishing build data.
Profile two builds: a clean build, and a build after a typical one-line change in a commonly edited file. The second build matters more. In a monolith, total build time depends less on project size than on how much work each change invalidates. If one edited class forces the whole single module to recompile and its full test suite to rerun, the cost of every change is close to the cost of a clean build.
| Phase dominates | Likely cause | Fix it points to |
|---|---|---|
| Configuration | Heavy build logic, eager task creation | Configuration cache, lazy task APIs |
| Compilation | Large recompile set per change | Incremental compilation, ABI boundaries, modules |
| Tests | Whole suite reruns on any change | Module splitting, build cache, test partitioning |
This diagnosis helps backend and platform engineers most when they own one large module with long local and CI builds. For them, the fix should match the measured bottleneck. If you start by splitting modules when configuration time is the problem, you add structure without reducing the wait.
#!/usr/bin/env bash
# Profile a clean build and an incremental build to see how much work one edit invalidates.
set -euo pipefail
# Placeholder: a file your team edits often.
HOT_FILE="src/main/java/com/example/orders/OrderService.java"
# 1) Clean build: baseline cost of doing all the work.
# --profile writes an HTML report to build/reports/profile/
./gradlew clean build --profile
# 2) Simulate a typical one-line edit, keeping a backup to restore later.
cp "$HOT_FILE" "$HOT_FILE.bak"
trap 'mv "$HOT_FILE.bak" "$HOT_FILE"' EXIT
echo "// profiling probe" >> "$HOT_FILE"
# 3) Incremental build after the edit. This is the number that matters:
# if it is close to the clean build, each change invalidates almost everything.
./gradlew build --profile
# Optional: publish a build scan for the timeline view (task outcomes such as
# UP-TO-DATE or FROM-CACHE, and parallelism). Only if your organisation allows it.
# ./gradlew build --scan
# Compare the two newest reports: configuration, dependency resolution,
# and per-task execution times (compileJava vs test).
ls -t build/reports/profile/*.html | head -n 2
A shell script that profiles a clean build and a build after a one-line edit with --profile, so you can compare how much configuration, compilation and test work a single change triggers.
How does incremental compilation work in Gradle?
Gradle's Java compiler integration records which classes reference which other classes. When you edit a source file, Gradle recompiles that class and then only the classes that depend on what changed. A change confined to a method body leaves the class's signature intact, so dependents are usually untouched. The Kotlin Gradle plugin uses its own incremental compiler, but the principle is the same: track dependencies at a fine grain, and widen the recompile set only when a visible signature changes.
Several patterns push that set toward "everything":
| Pattern | Why it widens recompilation |
|---|---|
Public static final constants |
The Java compiler inlines compile-time constants into the classes that use them, so a value change cannot be traced through ordinary references. Gradle has to recompile broadly to stay correct. |
| Annotation processors not declared incremental | Gradle cannot see which outputs a processor derives from which inputs, so it falls back to a full recompile of the source set. |
| Code generation | Generators that rewrite every file on each run, even when the content is identical, look like changes to every consumer. |
| Widely used utility classes | Any signature change in a class that hundreds of files import correctly triggers recompilation of all of them. |
Incremental state can occasionally go stale, for example after an interrupted build or a generator that leaves orphaned outputs. Symptoms include missing-class errors or behaviour that does not match the source. A clean build resolves it, but this should be rare. If you need clean builds routinely, that points to a misbehaving plugin or generator.
Build-logic changes can quietly defeat incrementality. Editing compiler arguments, changing a plugin version, or touching shared convention code alters the compile task's inputs. Gradle then treats the task's previous state as invalid and recompiles the whole source set. The build still passes, so the lost incrementality usually goes unnoticed until someone compares build times.
Why do ABI boundaries and api vs implementation matter?
Gradle's Java compile task does not treat every change to an upstream module the same way. When a module's JAR or class directory changes, Gradle checks the ABI of the classes on the consumer's compile classpath. The ABI is everything a consumer's compiler can see: public and protected class, method, and field signatures, supertypes, annotations, and constant values. If you edit only a method body or a private member, the ABI is unchanged. The downstream compile task stays up to date because recompiling it would produce identical bytecode. Gradle calls this compile avoidance. Without it, a one-line fix in a utility module would recompile every module that depends on it.
Compile avoidance only works if the boundary is narrow, and that is the job of the java-library plugin's two main configurations:
apidependencies are part of your module's ABI. They leak onto the compile classpath of every consumer, transitively. A signature change in anapidependency forces consumers to recompile.implementationdependencies are internal. They appear on consumers' runtime classpath but not on their compile classpath. Consumers cannot reference those types, so changes inside them, including ABI changes, cannot invalidate a consumer's compilation.
Declare a dependency as api only when its types appear in your public signatures. Use implementation for everything else. This shrinks consumers' compile classpaths, which also makes their cache keys more stable.
This is the core reason module boundaries reduce build time. A module boundary only saves work when Gradle can see that a change stops at that boundary.
// core/build.gradle.kts (Gradle Kotlin DSL)
plugins {
`java-library`
}
dependencies {
// :model types appear in core's public signatures, e.g.
// public Order load(OrderId id) <- Order, OrderId live in :model
// Consumers must see them to compile, so they are part of core's ABI.
// A signature change in :model recompiles core AND core's consumers.
api(project(":model"))
// The JSON parser is used only inside method bodies and private fields.
// It reaches consumers' runtime classpath but not their compile
// classpath, so upgrading it or changing its ABI recompiles only core.
implementation("com.example:json-parser:1.4.0")
// Internal helper module: same rule. Changes inside :core-internal
// stop at core's boundary.
implementation(project(":core-internal"))
}
// app/build.gradle.kts
// plugins { `java-library` }
// dependencies {
// implementation(project(":core"))
// }
//
// What :app's compileJava does after each kind of edit:
// edit a method body in :core -> core ABI unchanged, UP-TO-DATE
// add a private method in :core -> core ABI unchanged, UP-TO-DATE
// change a public signature in :core -> recompiles
// change a public signature in :model -> recompiles (leaks via api)
// bump json-parser or edit :core-internal -> UP-TO-DATE (implementation)
A java-library build script that uses api only for types exposed in public signatures and implementation for everything else, with notes on which upstream edits make a consumer's compileJava rerun and which leave it up to date.
How does the Gradle build cache work, and why do cache misses happen?
Gradle's build cache stores the outputs of cacheable tasks under a key computed from the task's declared inputs: the hashed contents of input files, input property values, the task implementation, and the classpath of its actions. If another build computes the same key, Gradle reuses the stored outputs instead of running the task. The local cache lives in the Gradle user home and helps when you switch branches or run a clean build. A remote cache lets CI and developers share results across machines. Enable caching with --build-cache or org.gradle.caching=true in gradle.properties.
A miss means two builds produced different keys for what should be the same work. Common causes:
- Absolute paths in inputs. Two checkouts in different directories hash differently unless the input is declared path-sensitive relative to the project.
- Timestamps or build numbers written into generated sources or resources.
- Undeclared inputs, such as an environment variable or file a task reads without declaring it. This is worse than a miss, because the task can return stale outputs.
- Non-reproducible outputs, such as archives with varying file order or embedded dates. Every downstream task that consumes them then misses as well.
To find the cause, run the same task on two machines or checkouts with -Dorg.gradle.caching.debug=true. Gradle logs the hash of each input. Diff the two logs and look for the input whose hash differs.
A remote cache also brings costs:
- Infrastructure and access control. Someone has to run the cache service and manage who can reach it.
- Write access. A common policy is that only CI writes and developers read. This limits cache poisoning, where a misconfigured or compromised machine uploads bad outputs that everyone else then reuses.
- Network speed. Over a slow link, downloading large outputs can take longer than recompiling the code locally.
#!/usr/bin/env bash
# Find why :app:compileJava misses the cache between two checkouts
# of the same commit, e.g. a CI workspace and a developer clone.
set -euo pipefail
TASK=":app:compileJava"
CHECKOUT_A="$HOME/work/project-a" # placeholder paths
CHECKOUT_B="/tmp/project-b"
run_with_key_debug() {
local dir="$1" out="$2"
# Clean first so the task actually executes and computes its cache key.
# caching.debug makes Gradle log the hash of every input that goes
# into the key.
(cd "$dir" && ./gradlew clean "$TASK" --build-cache \
-Dorg.gradle.caching.debug=true) > "$out" 2>&1
}
run_with_key_debug "$CHECKOUT_A" /tmp/key-a.log
run_with_key_debug "$CHECKOUT_B" /tmp/key-b.log
# Keep only the per-input hash lines, so timing and progress noise
# do not show up in the diff.
grep 'Appending' /tmp/key-a.log | sort > /tmp/key-a.txt
grep 'Appending' /tmp/key-b.log | sort > /tmp/key-b.txt
# Every differing line names an input whose hash changed. Typical findings:
# - an input property holding an absolute path
# - a generated file containing a timestamp
# - a classpath entry built from a non-reproducible jar
diff /tmp/key-a.txt /tmp/key-b.txt || true
This script runs the same task in two checkouts with cache-key debugging enabled and diffs the logged per-input hashes to find the input that causes the miss.
How do you split a Gradle monolith into modules step by step?
Start by mapping the class-level dependency graph, not the folder tree. Packages and team boundaries often hide tangled references. A module drawn along those lines either won't compile alone or will drag half the monolith in through its api surface.
Extract leaf code first: domain models, shared utilities, and protocol types. These have few outgoing dependencies and change rarely, so they stay cached and seldom trigger downstream recompilation. Where you find cycles, break them by moving a shared interface into a lower module or by inverting the dependency. Gradle will not build a cycle between projects.
Aim for a wide, shallow graph. Depth sets the critical path, and width is what parallel execution can use.
Deep chain (serial) Wide, shallow (parallel)
core core
| / / \ \
data billing auth search notify
| \ \ / /
service app
|
app
Configuration-phase hygiene
Configuration cost grows with module count, so small habits start to matter:
- Register tasks lazily. Use
tasks.registerinstead of eager creation. A task is then configured only when the build needs it. - Avoid
allprojects/subprojectsblocks. They couple projects to each other's configuration and block project isolation. - Use convention plugins. Keep shared logic in an included build such as
build-logic. Each module then applies only the plugins it needs, and edits to build logic stay localized. - Make build logic configuration-cache compatible. Tasks must declare their inputs and outputs, must not touch
Projectat execution time, and must avoid shared mutable state.
// settings.gradle.kts: build logic lives in an included build
pluginManagement { includeBuild("build-logic") }
rootProject.name = "shop"
include(":core", ":billing", ":auth", ":search", ":notify", ":app")
// build-logic/build.gradle.kts
plugins { `kotlin-dsl` }
repositories { gradlePluginPortal() }
// build-logic/src/main/kotlin/shop.jvm-library.gradle.kts
// Convention plugin: shared setup without allprojects/subprojects
plugins { `java-library` }
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
tasks.withType<Test>().configureEach { useJUnitPlatform() } // lazy
tasks.register("describeModule") { // configured only if requested
val moduleName = project.name // read Project at configuration time
doLast { println("module: $moduleName") } // no Project access at execution
}
// billing/build.gradle.kts: one of the wide middle layer, depends only on core
plugins { id("shop.jvm-library") }
dependencies { implementation(project(":core")) }
// app/build.gradle.kts: the single node that joins the wide layer
plugins { id("shop.jvm-library") }
dependencies {
implementation(project(":billing"))
implementation(project(":auth"))
implementation(project(":search"))
implementation(project(":notify"))
}
# gradle.properties: build sibling modules in parallel, reuse outputs and configuration
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
A wide, shallow module layout in the Gradle Kotlin DSL: shared conventions come from a precompiled script plugin in an included build-logic build, tasks are registered lazily without Project access at execution time, and parallel execution, the build cache and the configuration cache are enabled.
How much time can module splitting save? An illustrative estimate
Illustrative estimate (assumed numbers, not a benchmark). Assume a monolith whose full compile takes 300 s, with a configuration overhead of 5 s. In practice, a large single module often recompiles widely after any ABI change, so treat each edit as close to a full compile. Split the same code into N = 10 equal modules of 30 s each. The extra project overhead raises configuration to 10 s.
Per-build time is roughly:
T ≈ C + (m / N) × T_compile + T_test
where C is configuration time, m is the number of modules that actually recompile (the changed module plus ABI-affected dependents not served from the build cache), and T_test is test time that cannot be skipped or pulled from cache.
| Scenario (assumed) | C | m | T_test | Estimated build |
|---|---|---|---|---|
| Monolith, any edit | 5 s | 10 | 0 | 305 s |
| Split, leaf edit | 10 s | 1 | 0 | 40 s |
| Split, core ABI edit (6 dependents) | 10 s | 7 | 0 | 220 s |
| Split, leaf edit, configuration grown to 40 s | 40 s | 1 | 0 | 70 s |
| Monolith vs. split, unsplit 600 s test suite | 5 / 10 s | 10 / 1 | 600 s | 905 s vs. 640 s |
A leaf edit is cheap for two reasons. Unaffected modules are up to date locally, and on a fresh CI agent they come from the cache instead of compiling. Core edits erase most of the gain. Configuration growth taxes every build. Tests that are not split along module lines dominate the total.
For a developer running 20 builds a day on leaf modules, the saving is 20 × (305 − 40) s, or about 88 minutes. For CI, multiply the per-build delta by builds per day and by your per-minute agent cost.
When should you avoid module splitting, and what are the alternatives?
Splitting has costs that the estimate leaves out. In a mature codebase, untangling package cycles can take weeks. A half-finished split, where one large core module still depends on most of the code, can be slower than the monolith because you pay per-module overhead without avoiding any work. Past a certain point, many tiny modules increase configuration time and IDE sync time, and they turn routine refactors into edits to dependency declarations. Boundaries chosen for build speed may not match team ownership, and merging modules back together is expensive. Shared convention plugins also concentrate risk, because one change alters every module's configuration at once.
Before splitting further, compare the cheaper options:
| Option | Why it helps | Cost or risk |
|---|---|---|
| Fix cacheability only | Removes misses caused by absolute paths, timestamps or non-deterministic outputs | Requires auditing task inputs |
| Incremental annotation processors | A non-incremental processor forces full recompilation of its module | Depends on processor support; may require replacing the processor |
| Configuration cache | Skips the configuration phase on repeated builds | Plugins and build logic must be compatible |
| Test selection or sharding | Selection runs fewer tests; sharding spreads tests across agents | Selection can skip the test that would catch a regression; sharding adds infrastructure |
| Faster hardware | More cores help parallel tasks; faster disks help I/O-heavy work | Does nothing for serial bottlenecks; recurring spend |
To keep the gains, track build-time and cache-hit trends in CI rather than single runs. Fail the build on new dependency cycles and on accidental api exposure. Re-profile after large refactors, because the critical path moves when the code does.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to gradle build time large project, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.