Gradle: Performance Profiling & Build Scans

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Gradle 9.8.1 (released on 7 October 2026) and the Develocity Gradle plugin 4.6.0, with Gradle 8.14.6, the last 8.x release, mentioned where it differs. Every example marked as run was executed with the 9.8.1 wrapper on JDK 26 (Oracle java version "26") on 2026-10-16, on macOS (Apple silicon, 10 cores), in scratch copies of the tidepool build, once with *.gradle.kts files and once with *.gradle files. Both copies used a Gradle User Home created for the page (selected with the GRADLE_USER_HOME environment variable), so the real ~/.gradle was not touched, and the daemon was started with the default settings unless a run says otherwise. The machine’s default locale was Spanish (es_ES), which shows in one listing: the verbose file-system-watching statistics print thousands with a dot. Scratch paths are shown as <work>; a line that Gradle prints and that is irrelevant here (the hint about the incubating problems report, and the hint that the configuration cache could be enabled) are not shown in the listings. Gradle 8.14.x was not downloaded, so what is said about it comes from the official documentation and release notes and is labelled "not run here". Two things were not run at all and are labelled the same way: publishing a Build Scan to Gradle’s public Build Scan service (which means accepting its terms of use, something this page does not do for anybody), and the Gradle Profiler, which is a separate tool.

A fast build is a matter of habit more than of tricks. This page puts the habit first: measure, change one thing, measure again. Then it goes through the checklist that the official performance guide recommends, one item at a time, with the tidepool build as a place to try each of them. Most items are a flag or a line in gradle.properties; the details of each feature live on their own pages, which this page links instead of repeating: the Gradle Daemon, the build cache, the configuration cache, incremental build and the dependency cache. The --profile option is also listed in Command-Line Interface, and the org.gradle.* properties in Build Environment & Properties. For the other two tools: Ant has no daemon and no cache and reports times through a logger (see Ant Listeners, Loggers & Input Handlers), and Maven’s equivalents are described in Maven: Build Performance, Cache, Parallel Builds & mvnd.

Measure before you tune

The performance guide starts with a step zero, "Inspect your Build", and it is the step people skip. Without a baseline you cannot tell whether a change helped, and many of the folk remedies for slow builds (a bigger heap, more workers, a cleaner every time) help nothing or make things worse. The loop is:

flowchart LR A["Run the build and record the time"] --> B["Find where the time goes"] B --> C["Change one thing"] C --> D["Run again and compare"] D -- "faster" --> E["Keep it"] D -- "no change or slower" --> F["Revert it"] E --> B F --> B

Gradle gives three levels of detail, from cheapest to most expensive:

Tool What it shows Where the data goes

--profile report

An HTML page with the build split into startup, settings and buildSrc, loading projects, configuring projects, dependency resolution, artifact transforms and task execution, with a time for each project, configuration and task.

A file under build/reports/profile of the root project. Nothing leaves the machine.

Build Scan (--scan)

The same categories in much more detail: a timeline of the tasks, the cache activity, the plugins and scripts applied during configuration, the dependency resolution and the network downloads, the tests, and the environment.

Published to a server (Gradle’s public service, or a Develocity installation of your own). See the section on Build Scans below.

Gradle Profiler

Scripted scenarios (for example "run assemble after an ABI-breaking change") run several times, with warm-up, collecting the data of Java profilers; it can produce a Build Scan too.

A separate command-line tool (gradle-profiler). Not run here.

The example build

The running example is the tidepool build from the other pages of this section, with two additions that make performance visible. First, its model library is the one that most projects depend on, so changes there ripple through the build. Second, a build that compiles four small classes finishes before any measurement means anything, so three projects (tidepool-model, tidepool-ports and tidepool-charts) carry a script plugin that generates 1,500 small classes with 20 methods each into build/generated/tables and compiles them as part of main. The generated code is an artificial load, and the page says so wherever a number comes from it. The five projects are:

  • tidepool-model, with the generated classes and one JUnit test;

  • tidepool-engine, which api-depends on the model and uses Commons CSV;

  • tidepool-cli, the application that uses picocli and the engine;

  • tidepool-ports and tidepool-charts, two independent libraries that only carry generated classes, so that there is work that can run in parallel with the model.

The wrapper properties are the official ones (the page was run with a copy that points at a local archive of the same version):

gradle/wrapper/gradle-wrapper.properties
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

The settings file declares the repository once and includes the five projects; the version catalog and gradle.properties are the same for both DSLs:

Kotlin DSL: settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

rootProject.name = "tidepool"

include("tidepool-model", "tidepool-engine", "tidepool-cli", "tidepool-ports", "tidepool-charts")
Groovy DSL: settings.gradle
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

rootProject.name = 'tidepool'

include 'tidepool-model', 'tidepool-engine', 'tidepool-cli', 'tidepool-ports', 'tidepool-charts'
gradle.properties (the starting point; the tuned version comes at the end of the checklist)
group=io.tidepool
version=1.0.0
gradle/libs.versions.toml (the same for both DSLs; highlight.js has no TOML grammar, so it is shown as plain text)
[versions]
commons-csv = "1.14.1"
picocli = "4.7.7"
junit = "6.1.3"

[libraries]
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
picocli-codegen = { module = "info.picocli:picocli-codegen", version.ref = "picocli" }

The generator is a script plugin, applied by the three projects that need it. It is an ordinary task with a declared output, an input property and a doLast action that touches only values captured at configuration time, which makes it compatible with the configuration cache:

Kotlin DSL: gradle/generated-sources.gradle.kts
// Applied by the projects that carry a large table of generated classes.
val generateTables = tasks.register("generateTables") {
    val classCount = 1500
    val packageDir = layout.buildDirectory.dir("generated/tables/io/tidepool/${project.name.replace("-", "_")}")
    val packageName = "io.tidepool.${project.name.replace("-", "_")}"
    outputs.dir(layout.buildDirectory.dir("generated/tables"))
    inputs.property("classCount", classCount)
    doLast {
        val dir = packageDir.get().asFile
        dir.mkdirs()
        for (i in 0 until classCount) {
            val methods = (0 until 20).joinToString("\n") { m ->
                "    public static double f$m(double x) { return Math.sin(x * $m) + Math.cos(x / ${m + 1}) * $i; }"
            }
            File(dir, "Table$i.java").writeText("package $packageName;\n\npublic final class Table$i {\n$methods\n}\n")
        }
    }
}

extensions.getByType<SourceSetContainer>()["main"].java.srcDir(generateTables)
Groovy DSL: gradle/generated-sources.gradle
// Applied by the projects that carry a large table of generated classes.
def generateTables = tasks.register('generateTables') {
    def classCount = 1500
    def packageName = "io.tidepool.${project.name.replace('-', '_')}"
    def packageDir = layout.buildDirectory.dir("generated/tables/${packageName.replace('.', '/')}")
    outputs.dir(layout.buildDirectory.dir('generated/tables'))
    inputs.property('classCount', classCount)
    doLast {
        def dir = packageDir.get().asFile
        dir.mkdirs()
        for (int i = 0; i < classCount; i++) {
            def methods = (0..<20).collect { m ->
                "    public static double f${m}(double x) { return Math.sin(x * ${m}) + Math.cos(x / ${m + 1}) * ${i}; }"
            }.join('\n')
            new File(dir, "Table${i}.java").text = "package ${packageName};\n\npublic final class Table${i} {\n${methods}\n}\n"
        }
    }
}

sourceSets.main.java.srcDir(generateTables)

The model’s script applies it and compiles for Java 21 with all lint warnings. (The fork line and the Test block are added in the Java section below; they are shown now so that the file is shown once.) tidepool-ports and tidepool-charts apply the same script plugin and have the same compiler block, tidepool-engine and tidepool-cli have the dependencies from the list above and no generated sources.

Kotlin DSL: tidepool-model/build.gradle.kts
plugins {
    `java-library`
}

apply(from = rootProject.file("gradle/generated-sources.gradle.kts"))

tasks.withType<JavaCompile>().configureEach {
    options.release = 21
    options.compilerArgs.add("-Xlint:all")
    options.isFork = true
}

testing {
    suites {
        named<JvmTestSuite>("test") {
            useJUnitJupiter(libs.versions.junit)
        }
    }
}

tasks.withType<Test>().configureEach {
    maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
    if (!project.hasProperty("createReports")) {
        reports.html.required = false
        reports.junitXml.required = false
    }
}
Groovy DSL: tidepool-model/build.gradle
plugins {
    id 'java-library'
}

apply from: rootProject.file('gradle/generated-sources.gradle')

tasks.withType(JavaCompile).configureEach {
    options.release = 21
    options.compilerArgs.add('-Xlint:all')
    options.fork = true
}

testing {
    suites {
        test {
            useJUnitJupiter(libs.versions.junit)
        }
    }
}

tasks.withType(Test).configureEach {
    maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1
    if (!project.hasProperty('createReports')) {
        reports.html.required = false
        reports.junitXml.required = false
    }
}

All timings below were taken with the version of these scripts without the fork line and the Test block (they are measured separately in the Java section), by running ./gradlew clean build --console=plain and reading the line BUILD SUCCESSFUL in .... Because the page is about measuring, one warning belongs here: the machine was busy with other builds while these runs were made (its load average was above 30 on 10 cores), so the absolute figures are noisy and each is shown three times. Read the order of magnitude and the task counts, not the seconds.

The --profile report

--profile writes an HTML report to build/reports/profile of the root project, named with a time stamp so that reports do not overwrite one another. It costs nothing to produce and needs no server, so it is the first thing to try:

./gradlew clean build --profile
BUILD SUCCESSFUL in 17s
24 actionable tasks: 23 executed, 1 up-to-date
See the profiling report at: file://<work>/build/reports/profile/profile-2026-10-16-21-24-48.html
A fine-grained performance profile is available: use the --scan option.

The report has a summary and four tabs: Configuration, Dependency Resolution, Artifact Transforms and Task Execution. These are the numbers of the summary and the slowest tasks of that run (the report is HTML; the table below is its text rearranged):

Summary Duration Task execution, by project Duration

Total Build Time

17.809s

:tidepool-model (total)

6.500s

Startup

0.855s

:tidepool-model:compileJava

4.197s

Settings and buildSrc

0.011s

:tidepool-model:test

1.173s

Loading Projects

0.008s

:tidepool-ports (total)

6.326s

Configuring Projects

0.082s

:tidepool-ports:compileJava

5.233s

Artifact Transforms

0s

:tidepool-charts (total)

3.536s

Task Execution

16.800s

:tidepool-charts:compileJava

2.707s

The reading is straightforward and typical. Startup, settings, project loading and configuration add up to about a second; the other sixteen seconds are task execution, and most of that is three compileJava tasks and one test run. Dependency resolution in the whole build took 0.061 seconds, so nothing about repositories needs attention. And the three big blocks (6.5, 6.3 and 3.5 seconds) are in different projects that do not depend on each other, which makes this build a candidate for parallel execution, the next section’s subject. The report shows that without running anything else, which is the point of looking before tuning.

The categories are the same in the report and in a Build Scan, and the official page explains what to do about each one:

Category What it covers What a slow one usually means

Startup

JVM initialisation and class loading, downloading the distribution when the wrapper is used, starting a daemon when none is suitable, and running init scripts.

Slow only on the first build after a change of Gradle version or daemon settings. A persistently slow startup is nearly always an init script doing too much.

Settings and buildSrc

Evaluating the settings file and building buildSrc if there is one.

buildSrc is up to date after its first build, but the up-to-date checks cost something; the guide suggests an included build when this phase takes long. See Convention Plugins & Sharing Build Logic.

Loading projects

Creating the project objects.

Roughly a function of the number of projects; there is little to tune.

Configuration

Running every build script and applying every plugin, on every build (unless the configuration cache is reused).

Plugins or scripts doing I/O or heavy computation in the configuration phase; see the section on configuration-time work below.

Dependency resolution

Resolving configurations, with the time per configuration.

Dynamic versions, many repositories, or resolving at configuration time.

Artifact transforms

Running artifact transforms (see Variant-Aware Resolution & Module Metadata).

A transform that is not cacheable or runs for every artifact.

Task execution

Running the tasks, with each task’s time and outcome.

Compilation and tests, usually. Check how many tasks ran instead of being up to date or coming from a cache.

Low-level profiling

When the scripts are fine and the build is still slow, the time is in a plugin, in a custom task or in a constrained resource (memory, disk, a slow network). The report cannot see inside a task. The official page recommends the Gradle Profiler for this: it runs a scenario you define (for example "run assemble after an ABI-breaking change") several times, discards the warm-up, and can collect a Build Scan or the data of a method profiler such as JProfiler or YourKit. Those find inefficient algorithms in custom plugins, which is where a build author’s own code can hurt. The Gradle Profiler is installed separately and was not run for this page; its repository is gradle/gradle-profiler.

The performance checklist

The official guide numbers its recommendations 1 to 13. The sections below follow its order, skipping the Android one (not covered in this section) and the one for old Gradle releases (the daemon and incremental compilation it recommends are on by default in 9.8.1).

1. Update Gradle, Java and the plugins

Every Gradle release carries performance work, and an old version misses it. Upgrading within a major version is low-risk because Gradle keeps backward compatibility between minor versions, and staying current makes the next major upgrade smoother because the deprecation warnings arrive early. The wrapper makes it a one-line change:

./gradlew wrapper --gradle-version 9.8.1

The JVM matters too: Gradle runs on it, and newer JDKs bring faster start-up and better garbage collectors. Gradle 9.0.0 raised the JVM needed to run Gradle to 17 (the code you build can still target older versions with release or a toolchain), and 9.8.0 supports Java 27 (see Versions, Compatibility & Release History). The third item is plugins: outdated ones, in particular the Android, Java and Kotlin plugins, can slow a build that a newer version would not. Look at every plugins { } line and check the Plugin Portal. The procedure for a real upgrade (deprecation warnings, --warning-mode=all, plugin updates) is on Upgrading Gradle, and the wrapper is on Gradle Wrapper.

2. Parallel execution

By default Gradle runs one task at a time (a task graph in which several tasks are ready still executes them one after another). --parallel, or org.gradle.parallel=true in gradle.properties, lets tasks of different projects run at the same time, as far as their dependencies allow. The effect depends on the shape of the build: if one project dominates the time, or if the projects form a chain, there is little to gain. tidepool is a good case: tidepool-ports and tidepool-charts are independent of the model/engine/cli chain.

Table 1. The same command with and without --parallel (Kotlin DSL copy; three runs each, the daemon warm)
Command Run 1 Run 2 Run 3

./gradlew clean build

14s

15s

16s

./gradlew clean build --parallel

6s

5s

9s

The Groovy DSL copy, run the same way, took 14, 8 and 18 seconds without the option and 8, 10 and 8 with it. The spread is the machine’s load, and the direction is the same as in the Kotlin copy. The speed-up is the expected one: with --parallel the build takes about as long as its longest chain (the model, then the engine and the CLI), not the sum of the three big compile tasks.

Parallel execution assumes that tasks of different projects do not share mutable state. A build that breaks with --parallel has a bug (two tasks writing the same file, a task reading a directory another one is still filling) that was hidden by the sequential order, and the fix is in the build, not in dropping the option. The number of worker threads defaults to the number of CPU cores (10 on this machine); --max-workers or org.gradle.workers.max sets it (see Build Environment & Properties).

Parallel execution also gives the Tooling API clients (the IDEs) parallel model building, which makes an IDE sync faster. Since Gradle 9.4.0 there is a separate property, org.gradle.tooling.parallel, to control that independently of task parallelism; the official page says to set it explicitly (relying on the value derived from org.gradle.parallel is deprecated). That detail is from the documentation and was not run here.

3. The daemon

The daemon has been on by default since Gradle 3.0 (2016-08-15), and the guide only mentions it for builds that switched it off (org.gradle.daemon=false in some gradle.properties). Every timing on this page was taken with a warm daemon; the first run after a daemon starts pays for the JVM start-up and the class loading (26 seconds for the first Kotlin DSL build against 14 to 16 afterwards). The mechanics, the commands that inspect and stop the daemon, and the memory rules are on The Gradle Daemon.

4. The build cache

The build cache stores task outputs keyed by the task’s inputs, so that a task that has already run with the same inputs (on this machine, or on a CI agent that shares the cache) is not run again, and its output is restored. It is off by default; --build-cache enables it for one build and org.gradle.caching=true for all. The incremental build already skips a task whose inputs did not change since its last run in this directory; the build cache also covers the cases where the output is gone (after clean) or the inputs changed and changed back. Details, the remote cache and the debugging of cache misses are on Build Cache.

On tidepool the cache was emptied first, and clean build --build-cache was run three times (the first run stores into the cache, the others use it). The tasks marked FROM-CACHE below come from the second run:

> Task :tidepool-model:generateTables
> Task :tidepool-model:compileJava FROM-CACHE
> Task :tidepool-engine:compileJava FROM-CACHE
> Task :tidepool-cli:compileJava FROM-CACHE
> Task :tidepool-model:compileTestJava FROM-CACHE
> Task :tidepool-model:test FROM-CACHE
> Task :tidepool-ports:generateTables
> Task :tidepool-ports:compileJava FROM-CACHE
> Task :tidepool-charts:generateTables
> Task :tidepool-charts:compileJava FROM-CACHE
...
BUILD SUCCESSFUL in 3s
24 actionable tasks: 16 executed, 7 from cache, 1 up-to-date

(Lines with NO-SOURCE, UP-TO-DATE and the lifecycle tasks are left out where the listing shows ....) Every compileJava and the test task came out of the cache. What still executed is worth a look, because it is the typical limit of a cache: generateTables is an ad-hoc task that was never declared cacheable, so it re-ran (it produces identical files, which is why the compile tasks that consume them still hit the cache), and the archive tasks (jar, distZip, distTar) ran too. A task is cacheable only when its type says so (the compile, test and many archive tasks of the core plugins do) or when a custom task type is annotated for it; the generator could be, with the annotation described on Writing Custom Tasks.

Table 2. The effect on the clean build (Kotlin DSL copy, three runs; the first run fills the cache)
Command Run 1 Run 2 Run 3

./gradlew clean build --build-cache

12s

7s

7s

The Groovy copy took 9, 5 and 4 seconds, and reported the same numbers of tasks. The build cache was introduced as production-ready in Gradle 4.0, and Gradle in Action (written for 1.7) predates it.

5. The configuration cache

The configuration cache stores the task graph that the configuration phase built, so that a build whose configuration inputs did not change (build scripts, settings, init scripts, the properties and environment variables they read, files read through providers) skips the phase entirely. The official guide also says that with it enabled, Gradle runs all tasks in parallel, even within one project, and caches the dependency-resolution results. It is off by default in 9.8.1 (it is the "preferred mode" since 9.0, and the default is announced for Gradle 10); enable it for one build with --configuration-cache, or for all with org.gradle.configuration-cache=true. Its requirements (no Project at execution time, providers instead of eager reads) and Isolated Projects are on Configuration Cache & Isolated Projects.

Every example on this page is compatible with it (the generator takes the directory and the package name at configuration time and uses only those in doLast), and the runs below used it where the text says so. The store and the reuse are reported in the output:

$ ./gradlew clean build --configuration-cache --console=plain
...
Configuration cache entry stored.
$ ./gradlew clean build --configuration-cache --console=plain
Reusing configuration cache.
...
Configuration cache entry reused.
Table 3. The effect on the clean build (Kotlin DSL copy, three runs; the first run stores the entry)
Command Run 1 Run 2 Run 3

./gradlew clean build --configuration-cache

10s

12s

9s

./gradlew clean build --parallel --build-cache --configuration-cache

3s

3s

2s

The Groovy copy took 4, 8 and 4 seconds for the first command and 2, 2 and 2 for the second. Two things in these numbers deserve a sentence. First, the build itself is tiny, so the configuration phase (0.08 seconds in the profile above) was never the problem and the configuration cache gained little by skipping it; the difference to the baseline (9 to 12 seconds against 14 to 16) is mostly the parallel execution that comes with the cache. On a build with hundreds of projects and heavy plugins the weight is the other way round, and the configuration cache is then the biggest single saving. Second, the combination of the three is where the large drop is: with all three the clean build is three seconds, because the compile and test outputs come from the cache, the tasks that do run do so in parallel, and the configuration is read from the cache.

6. Incremental build for custom tasks

Most core tasks are incremental: they declare their inputs and outputs, and Gradle skips them when nothing changed. A custom task participates only if it declares them. The generateTables task above does (an inputs.property and an outputs.dir), and without them it would run on every build and make everything downstream rerun. Run build twice without cleaning, and the second run is nearly free:

$ ./gradlew build --console=plain
...
> Task :tidepool-ports:build UP-TO-DATE

BUILD SUCCESSFUL in 375ms
18 actionable tasks: 18 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.8.1/userguide/configuration_cache_enabling.html

The Build Scan "Timeline" view, or the profile report’s task list, shows the tasks that reran when you expected a skip; the usual cause is an input that changes on every build (a time stamp, an absolute path). The mechanism, @Input annotations and the reasons a task is considered out of date are on Incremental Build.

7. Builds for specific workflows

The fastest task is the one that does not run. The guide’s advice is to give each kind of user a task (or a group of tasks) that does only what they need: a lifecycle task that depends on the right subset of tasks, tasks put in a task group so that ./gradlew tasks lists them, and verification deferred until it is wanted. In tidepool a developer who works on the CLI types ./gradlew :tidepool-cli:installDist, and the generated tables of tidepool-ports are never touched. Task naming and grouping are on Tasks.

8. Heap size

The daemon starts with a maximum heap of 512 MB by default, which is enough for most builds; very large builds keep a big model in memory and may need more. Raise it with org.gradle.jvmargs in gradle.properties of the project or of the Gradle User Home:

org.gradle.jvmargs=-Xmx2048M

Do not raise it by habit: a bigger heap does not make a build faster unless it was short of memory, and a daemon that has to be started again with different arguments costs a start-up. To see what the daemon really got, a task can print it. It reads the value in its action, so it is configuration-cache compatible:

Kotlin DSL: build.gradle.kts (root)
plugins {
    base
}

tasks.register("showHeap") {
    doLast {
        println("Daemon max heap: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)} MiB")
    }
}
Groovy DSL: build.gradle (root)
plugins {
    id 'base'
}

tasks.register('showHeap') {
    doLast {
        println "Daemon max heap: ${Runtime.runtime.maxMemory().intdiv(1024 * 1024)} MiB"
    }
}
$ ./gradlew showHeap -q --configuration-cache
Daemon max heap: 512 MiB
$ ./gradlew showHeap -q --configuration-cache -Dorg.gradle.jvmargs=-Xmx2g
Daemon max heap: 2048 MiB

The Groovy DSL copy printed the same two lines. Changing org.gradle.jvmargs starts a second daemon (the command above left two idle daemons, which --status listed), because the JVM arguments are among the settings that must match for a daemon to be reused. The full list of daemon JVM settings is on The Gradle Daemon. The org.gradle.jvmargs value applies to the daemon only; the client JVM is configured through GRADLE_OPTS.

9. Work in the configuration phase

The configuration phase runs on every build, for every project, whatever tasks were asked for, so a second of work there is a second added to gradle help. The usual culprits, from the guide:

  • Expensive or blocking work in a plugin’s apply or a task constructor: network calls, encryption, parsing big files. Move it into a task action, so that it runs only when the task does.

  • Plugins and scripts applied everywhere: allprojects { } or subprojects { } blocks applying a plugin to projects that do not use it. Apply it where it is needed, or build a convention plugin and apply that where it is needed (see Convention Plugins & Sharing Build Logic).

  • Dynamic Groovy in plugins: Groovy’s dynamic dispatch is slower than Java or Kotlin; @CompileStatic or a plugin written in Java or Kotlin avoids it.

  • Resolving dependencies while configuring: the case that the profile report catches, shown next.

To see the last one, the engine’s script was given a line that prints its compile classpath, outside of any task, so that it runs on every build (a mistake that is easy to make while debugging a classpath):

Kotlin DSL: tidepool-engine/build.gradle.kts (added line)
// Resolves the classpath while the build is being configured, on every build.
configurations.compileClasspath.get().files.forEach { println("classpath entry: ${it.name}") }
Groovy DSL: tidepool-engine/build.gradle (added line)
// Resolves the classpath while the build is being configured, on every build.
configurations.compileClasspath.files.each { println "classpath entry: ${it.name}" }

Running ./gradlew help --profile prints the entries during configuration, in a build whose only task is help:

> Configure project :tidepool-engine
classpath entry: main
classpath entry: commons-csv-1.14.1.jar
classpath entry: commons-io-2.20.0.jar
classpath entry: commons-codec-1.19.0.jar

and the Dependency Resolution tab of that report names the configuration that was resolved, which the same build without the line does not list:

Dependency Resolution tab With the line Without it

All dependencies

0.010s

0.001s

:tidepool-engine:compileClasspath

0.009s

not listed

The times are tiny because the dependencies were already in the dependency cache and the build is small; the cost is I/O and grows with the number of configurations and the state of the cache. The tab’s list is the thing to watch: a help or tasks build should resolve nothing. The fix is to defer the resolution to execution time and to capture the file collection (not the project) so that the task stays compatible with the configuration cache:

Kotlin DSL: tidepool-engine/build.gradle.kts
tasks.register("printDeps") {
    val classpath: FileCollection = configurations.compileClasspath.get()
    doLast {
        classpath.files.forEach { println("classpath entry: ${it.name}") }
    }
}
Groovy DSL: tidepool-engine/build.gradle
tasks.register('printDeps') {
    FileCollection classpath = configurations.compileClasspath
    doLast {
        classpath.files.each { println "classpath entry: ${it.name}" }
    }
}
$ ./gradlew printDeps --configuration-cache --console=plain
Calculating task graph as no cached configuration is available for tasks: printDeps
> Task :tidepool-engine:printDeps
classpath entry: main
classpath entry: commons-csv-1.14.1.jar
classpath entry: commons-io-2.20.0.jar
classpath entry: commons-codec-1.19.0.jar
BUILD SUCCESSFUL in 485ms
1 actionable task: 1 executed
Configuration cache entry stored.
$ ./gradlew printDeps --configuration-cache --console=plain
Reusing configuration cache.
> Task :tidepool-engine:printDeps
...
Configuration cache entry reused.

Both DSLs gave the same output. The Build Scan has the same information on its "Performance" page ("Settings and suggestions" lists the dependencies resolved during configuration) and also shows the plugins and scripts in the order of the time they took to apply, which is where the "apply it only where needed" advice is found.

10. Dependency resolution

Resolution contacts remote servers, so the way dependencies are declared decides how often it does. The guide’s list, with the pages that cover each point:

  • Do not keep unused dependencies. Each one costs resolution, download and maintenance time.

  • Declare the repository that hosts most of your dependencies first, and keep the number of repositories small, because Gradle searches them in the order declared. A virtual repository in a repository manager that aggregates many is better than many entries; see Repositories & Content Filtering, where content filters keep a repository from being asked about modules it does not host.

  • Avoid dynamic versions (1.+, latest.release) and changing (snapshot) versions. Gradle caches the answer of a dynamic version for 24 hours by default and then asks again.

  • Do not put slow or remote logic in resolution rules (an eachDependency that fetches a version over HTTP, for example); see Resolution Rules & Substitution. Use a version catalog instead (Version Catalogs).

  • Use a nearby mirror or a repository proxy if downloads from the origin are slow. The Build Scan lists the network downloads sorted by time.

The dynamic-version case has a side effect on the configuration cache that is easy to miss. A dynamic version is a configuration input: when its cached answer expires, the configuration cache entry cannot be reused, and the configuration phase runs again. To see this without waiting 24 hours, the engine was given the dynamic version 1.+ of Commons CSV and a cache time of zero seconds:

Kotlin DSL: tidepool-engine/build.gradle.kts (changed lines)
dependencies {
    api(project(":tidepool-model"))
    implementation("org.apache.commons:commons-csv:1.+")
}

configurations.all {
    resolutionStrategy {
        cacheDynamicVersionsFor(0, "seconds")
    }
}
Groovy DSL: tidepool-engine/build.gradle (changed lines)
dependencies {
    api project(':tidepool-model')
    implementation 'org.apache.commons:commons-csv:1.+'
}

configurations.all {
    resolutionStrategy {
        cacheDynamicVersionsFor 0, 'seconds'
    }
}

Running ./gradlew :tidepool-engine:compileJava --configuration-cache twice in a row, the second run could not reuse the entry:

$ ./gradlew :tidepool-engine:compileJava --configuration-cache --console=plain
Calculating task graph as no cached configuration is available for tasks: :tidepool-engine:compileJava
Configuration cache entry stored.
$ ./gradlew :tidepool-engine:compileJava --configuration-cache --console=plain
Calculating task graph as configuration cache cannot be reused because cached version information for org.apache.commons:commons-csv:1.+ has expired.
Configuration cache entry stored.

The Groovy copy printed the same messages. With the default 24 hours the same message appears once a day, which is also the way to find the culprit in a real build: the message names the dynamic dependency. dependencyInsight shows which version a dynamic declaration selected:

$ ./gradlew :tidepool-engine:dependencyInsight --dependency commons-csv --configuration compileClasspath --console=plain
> Task :tidepool-engine:dependencyInsight
org.apache.commons:commons-csv:1.14.1
...
org.apache.commons:commons-csv:1.+ -> 1.14.1
\--- compileClasspath

The two values (cacheDynamicVersionsFor and cacheChangingModulesFor) should not be lowered in a build or an init script unless there is a reason: lower values make Gradle ask the repositories more often. The other fix is the one that follows from the line that found it: a fixed version, such as the 1.14.1 in the version catalog. Locking the dynamic versions that remain is on Dependency Locking, Verification & Caching.

11. Tests and the compiler

For a project with the Java plugin the guide has three test suggestions and two compiler ones. The model’s script above shows all of them, and the build was run with each.

  • Run test classes in parallel forks. maxParallelForks on the Test task starts several test JVMs; the guide suggests the number of cores, or slightly fewer (the scripts use half of the cores, at least one). It assumes the tests are isolated: tests that share a file, a port or a database fail intermittently when run in parallel.

  • Fork a fresh JVM every N tests. forkEvery = 100 limits the memory a large suite accumulates, at the cost of the start-up of each JVM; a value that is too low costs more than it saves.

  • Do not generate reports you do not read. The HTML and JUnit XML reports of the Test task cost time in a large suite. The scripts turn both off unless the project property createReports is set. Run with -PcreateReports, or put createReports=true into gradle.properties of your Gradle User Home, to get them back.

  • Fork the compiler. options.isFork = true (Kotlin DSL) or options.fork = true (Groovy DSL) compiles in a separate process that Gradle reuses for the rest of the build, which keeps compiler garbage out of the daemon heap. The guide says it matters when one task compiles more than about a thousand source files and has little effect on small builds, which is why the tidepool numbers above were taken without it.

  • Use implementation, not api, for internal dependencies, so that a change in the engine does not recompile the CLI. See Gradle Java Projects and Testing JVM Projects.

Running ./gradlew clean :tidepool-model:test (configuration cache on) with the Test block of the model produced no HTML report, and the XML report was absent too: only the binary results directory was left. Running it again with the property brought them back:

$ ./gradlew clean :tidepool-model:test --console=plain
...
BUILD SUCCESSFUL in 4s
$ ls tidepool-model/build/reports
ls: tidepool-model/build/reports: No such file or directory
$ ls tidepool-model/build/test-results/test
binary
$ ./gradlew clean :tidepool-model:test -PcreateReports --console=plain
...
$ ls tidepool-model/build/reports tidepool-model/build/test-results
tidepool-model/build/reports:
tests

tidepool-model/build/test-results:
test

Both DSLs produced the same result with the forked compiler setting in place.

Putting it together

After the measurements, the settings that earned their place in tidepool go into gradle.properties, which is the same for both DSLs:

gradle.properties (tuned)
group=io.tidepool
version=1.0.0
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx2g

(The heap line is only there to show where it goes: nothing in this build needs more than the default, and the profile showed no memory problem.) With these properties, ./gradlew clean build took 9 seconds the first time (some compiled outputs were already in the cache from earlier runs, and the configuration cache was stored) and 2 seconds for the next two runs in the Kotlin DSL copy, and 6, 1 and 2 seconds in the Groovy copy, against 14 to 16 seconds at the start. Anyone who clones the project gets the same settings; a developer or CI agent that needs different ones overrides them on the command line (--no-parallel, --no-build-cache) or in the Gradle User Home. Where the cache is shared across machines, who may write to it is a decision of its own (see Build Cache).

File-system watching

Gradle keeps a virtual file system (VFS) in the daemon: what it knows about the files of the previous build, with their hashes. File-system watching lets the daemon learn from the operating system which files changed, so that the next build does not have to scan the input directories to find out. It has been on by default for the supported systems since Gradle 7.0. --watch-fs forces it, --no-watch-fs or org.gradle.vfs.watch=false turns it off, and org.gradle.vfs.verbose=true (a daemon system property, passed here with -D) prints what happened at the beginning and the end of each build. Three runs on the tidepool build show it. The output is trimmed to the lines about the VFS and the tasks that ran; the numbers use a dot as the thousands separator because of the machine’s Spanish locale:

$ ./gradlew assemble -Dorg.gradle.vfs.verbose=true --console=plain
VFS> > DirectorySnapshotter: Snapshot 28 directory hierarchies (visited 78 directories, 9.042 files and 0 failed files)
Received 7 file system events since last build while watching 1 locations
Virtual file system retained information about 9115 files, 78 directories and 12 missing files since last build
BUILD SUCCESSFUL in 947ms
16 actionable tasks: 16 up-to-date
Virtual file system retains information about 9115 files, 78 directories and 12 missing files until next build
...
$ ./gradlew assemble -Dorg.gradle.vfs.verbose=true --console=plain     (after appending a comment to TideEngine.java)
VFS> > DirectorySnapshotter: Snapshot 0 directory hierarchies (visited 0 directories, 0 files and 0 failed files)
Received 10 file system events since last build while watching 1 locations
Virtual file system retained information about 9114 files, 74 directories and 12 missing files since last build
> Task :tidepool-engine:compileJava
BUILD SUCCESSFUL in 1s
16 actionable tasks: 1 executed, 15 up-to-date
...
VFS> > FileHasher: Hashed 3 files (5.764 bytes)
VFS> > DirectorySnapshotter: Snapshot 4 directory hierarchies (visited 10 directories, 4 files and 0 failed files)
$ ./gradlew assemble --no-watch-fs -Dorg.gradle.vfs.verbose=true --console=plain
BUILD SUCCESSFUL in 1s
16 actionable tasks: 16 up-to-date
VFS> > DirectorySnapshotter: Snapshot 28 directory hierarchies (visited 78 directories, 9.042 files and 0 failed files)

(The snapshot line near the top of a listing is the statistics of the previous build, not of the one printed below it. The lines in the second listing show the part that matters: the daemon was told about 10 file events, retained what it knew of the other 9,114 files, and re-hashed only 3 files, instead of visiting the directories.) With --no-watch-fs the third run walked all 78 directories and 9,042 files again. On a small project this costs well under a second; on a large one it is the difference between an incremental build that starts instantly and one that scans first.

The limits are the ones of the operating system. Supported are Windows 10 (1709 and later), Linux (the tested distributions are listed in the guide) and macOS 12 or later on Intel and ARM, with the file systems APFS, btrfs, ext3, ext4, XFS, HFS+ and NTFS (and VirtualBox shared folders). Network file systems such as Samba and NFS, and Microsoft Dev Drives (ReFS), are not supported, and Gradle then falls back to scanning those locations. Files reached through symbolic links do not benefit. Gradle excludes .git, .gradle and build/ from watching and offers no public way to add excludes, so the advice is to limit the inputs of a task with fileTree().exclude(). On Linux a build can run out of inotify watches; the guide has a troubleshooting section for that. Continuous builds, below, depend on the same mechanism.

Continuous builds

--continuous (or -t) runs the requested tasks, waits, and runs them again when their inputs change. It uses the file-system watching above, so it does not work with --no-daemon. It is meant for the terminal next to your editor: the guide notes that IDEs have their own incremental compilation and that the mode is not typically integrated with them. Run in the background on tidepool, with a script that edited a file after each wait (the paths of the changed files are printed in full; here the <work> prefix stands for the scratch directory):

./gradlew :tidepool-cli:classes --continuous
> Task :tidepool-model:generateTables UP-TO-DATE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-cli:compileJava UP-TO-DATE
> Task :tidepool-cli:processResources NO-SOURCE
> Task :tidepool-cli:classes UP-TO-DATE

BUILD SUCCESSFUL in 967ms
4 actionable tasks: 4 up-to-date

Waiting for changes to input files...
modified: <work>/tidepool-engine/src/main/java/io/tidepool/engine/TideEngine.java
Change detected, executing build...

> Task :tidepool-model:generateTables UP-TO-DATE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava
> Task :tidepool-cli:compileJava UP-TO-DATE
> Task :tidepool-cli:processResources NO-SOURCE
> Task :tidepool-cli:classes UP-TO-DATE

BUILD SUCCESSFUL in 117ms
4 actionable tasks: 1 executed, 3 up-to-date

Waiting for changes to input files...
modified: <work>/tidepool-model/src/main/java/io/tidepool/model/Height.java
Change detected, executing build...

> Task :tidepool-model:generateTables UP-TO-DATE
> Task :tidepool-model:compileJava
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-cli:compileJava UP-TO-DATE
> Task :tidepool-cli:processResources NO-SOURCE
> Task :tidepool-cli:classes UP-TO-DATE

BUILD SUCCESSFUL in 263ms
4 actionable tasks: 1 executed, 3 up-to-date

Waiting for changes to input files...

The first edit (a comment in the engine) recompiled only the engine. The second (a comment in the model) recompiled the model and stopped there: the engine and the CLI stayed up to date because the compiled interface of the model did not change, which is compile avoidance at work (see Gradle Java Projects). A third change, appending a line to the root build.gradle.kts, produced no output at all: the process was stopped ten seconds later without a rebuild. That is the documented limitation, not a bug: "changes to build logic are not considered", because the build model is not recalculated; restart the continuous build after editing a build script. The other limits listed by the guide are that creating a missing input directory may go unnoticed, that changes to the inputs of untracked tasks or of tasks without outputs may be missed, that only files inside the project directory are watched, that a task that modifies its own inputs causes an endless loop of builds, and that changes behind symbolic links are not detected. A build in this mode waits for a quiet period before it starts (org.gradle.continuous.quietperiod, in milliseconds), and in a terminal it is ended with Ctrl-D; a script must kill the process, as this run did. Maven and Ant have nothing like it in their cores.

Build Scans

A Build Scan is a persistent, shareable record of one build: the environment, the tasks and their outcomes, the timeline, the cache activity, the dependency resolution, the test results, the plugins, and the console log. It is created locally by the build and then published to a server. The scan "does not run code on your machine" and "never modifies your build", the official page says; whether to publish is your choice, and every scan gets a unique URL that is private by default (anyone who has the link can see it; the link cannot be guessed). A scan replaces the pasting of error messages and environment details into a question to a colleague or a forum: you send the link. For performance it is the richest of the three tools above, because it shows the timeline of the tasks, which the --profile report does not.

Publishing a scan: --scan and the terms of use

The command is the same since Gradle 4.3: add --scan.

./gradlew build --scan

At the end of the build Gradle prints the address of the scan. Without a server of your own, the build is published to Gradle’s public Build Scan service (known as scans.gradle.com in older material; the current documentation names it Develocity at gradle.com, and it redirects to docs.develocity.ai). That is where the data leaves your machine, and it requires accepting the terms of use, either when asked on the command line or in the build, with a line that the plugin documentation warns not to commit into a project that other people build:

Kotlin DSL: settings.gradle.kts (not run here, and not to be copied without reading the terms)
develocity {
    buildScan {
        termsOfUseUrl.set("https://gradle.com/help/legal-terms-of-use")
        termsOfUseAgree.set("yes")
    }
}
Groovy DSL: settings.gradle (not run here)
develocity {
    buildScan {
        termsOfUseUrl = "https://gradle.com/help/legal-terms-of-use"
        termsOfUseAgree = "yes"
    }
}

Nothing on this page publishes a scan. Publishing a scan and accepting the terms of use were not run, and no example here contains the agreement. What the page did run is the plugin itself: --scan makes Gradle apply the Develocity plugin automatically (the version bundled with that Gradle release), but a project can also apply it explicitly, which is how a team normally configures it. The plugin id is com.gradle.develocity (version 4.6.0 is the latest at the time of writing, and works with all Gradle 5.x and later), and it must be applied in the settings file. The scratch copies of the build got this settings file in front of the lines shown earlier; publishing.onlyIf { false } makes publication on-demand only (the plugin’s default is to publish every build), and --no-scan turns it off for one build:

Kotlin DSL: settings.gradle.kts (added at the top)
plugins {
    id("com.gradle.develocity") version "4.6.0"
}

develocity {
    buildScan {
        // Nothing here accepts the terms of use: publishing stays off unless someone asks for it.
        publishing.onlyIf { false }
        obfuscation {
            username { name -> name.reversed() }
        }
    }
}
Groovy DSL: settings.gradle (added at the top)
plugins {
    id 'com.gradle.develocity' version '4.6.0'
}

develocity {
    buildScan {
        // Nothing here accepts the terms of use: publishing stays off unless someone asks for it.
        publishing.onlyIf { false }
        obfuscation {
            username { name -> name.reverse() }
        }
    }
}

Run with --no-scan, the plugin loaded (it is downloaded from the Gradle Plugin Portal like any plugin), the build ran and was reused from the configuration cache the second time, so the plugin is compatible with it:

$ ./gradlew build --no-scan --configuration-cache --console=plain
Calculating task graph as no cached configuration is available for tasks: build
...
BUILD SUCCESSFUL in 24s
18 actionable tasks: 18 executed
Configuration cache entry stored.
$ ./gradlew build --no-scan --configuration-cache --console=plain
Reusing configuration cache.
...
BUILD SUCCESSFUL in 1s
18 actionable tasks: 18 up-to-date
Configuration cache entry reused.

The Groovy copy gave the same results (10 seconds, then 585 ms). The plugin also adds a task, buildScanPublishPrevious, which publishes the data captured by the last build, useful for "I should have run that with --scan`". It was not run, but its description is real output of `help:

$ ./gradlew help --task buildScanPublishPrevious --no-scan --console=plain
> Task :help
Detailed task information for buildScanPublishPrevious
Path
     :buildScanPublishPrevious (registered by plugin 'com.gradle.develocity')
Type
     BuildScanPublishPreviousTask (com.gradle.develocity.agent.gradle.internal.publish.BuildScanPublishPreviousTask)
Options
     --rerun     Causes the task to be re-run even if up-to-date.
Description
     Publishes the data captured by the last build.
Group
     Develocity

The plugin documentation also covers publishing only for failed builds, only when the user is authenticated, or only on CI, by giving publishing.onlyIf a predicate; these were not run here.

What a scan captures

The plugin documentation lists the captured information in an appendix. In summary: the environment (user home, user name, host names and IP addresses, which can be obfuscated; the JVM and its settings; locale and time zone; operating system; hardware, memory and CPU, network and disk throughput), the build (the invocation options, the console output, failure messages and stack traces, deprecations, the project names and structure), the executed tasks (name, outcome, dependencies, cache operations and keys, input and output property names without their values), the executed tests (names, outcomes, durations, output, failures), the artifact transforms, the applied plugins, the resolved dependencies with their origin repositories, the network downloads, the build cache configuration and the Java toolchains used. The plugin documentation states what it does not capture: the source code being built and the output artifacts. It adds a caveat that matters for privacy: compiler errors and test failures are captured, and these messages can reveal parts of the source code. That is why the obfuscation block above exists (it rewrites the user name, the host names, the IP addresses or the process names before they are sent), and why a team that works on closed-source code should read the appendix before it enables publication, and decide where scans go.

A server of your own

If your organisation runs a Develocity installation, scans go there instead of to the public service, and are visible to all users who can reach the server. There are three ways to point a build at it, in increasing order of how much the project knows about it:

  • In the settings file, develocity { server.set("https://develocity.example.com") } (Kotlin DSL) or develocity { server = "https://develocity.example.com" } (Groovy DSL), with the plugin applied. The example.com address is a placeholder, and no server was contacted.

  • On the command line, with Gradle 9.5.0 or later, --develocity-url https://develocity.example.com: Gradle applies the Develocity plugin automatically if the build does not apply it, in a default version, which --develocity-plugin-version changes (the plugin must be 4.4.0 or later when a URL is given).

  • From the environment: COM_GRADLE_DEVELOCITY_URL and COM_GRADLE_DEVELOCITY_PLUGIN_VERSION, or the properties com.gradle.develocity.url and com.gradle.develocity.plugin.version. The command line wins over the property, and the property over the environment variable. This is convenient on a CI server where you do not control the command line, and in an init script (see Init Scripts & the Tooling API).

Authentication to the server (access keys) is described in the plugin manual. The commercial product also offers a shared remote build cache, test distribution and other features, which are beyond this section; the plugin manual at docs.develocity.ai/gradle/4.6 is the reference. None of this was run here.

Reading a scan for performance

This part of the page is from the official guide; no scan was published to look at. The "Performance" page of a scan shows the same stages as the profile report and some more: startup, configuration, dependency resolution and task execution, with tabs that break each down. The guide points to these views in particular:

  • Timeline: the tasks as bars on parallel lanes, to find a bottleneck at the start or the end of the build, and, sorted by duration, the tasks worth optimizing. Tasks that reran when they should have been up to date show up here with the reason (an input that changed, such as a time stamp).

  • Configuration: the scripts and plugins applied, sorted by the time they took, with where each was applied from. A script applied once for 3 seconds, or for 1 second in each of 3 projects, is a candidate for the "apply only where needed" fix.

  • Dependency resolution: the time spent resolving during project configuration (which should be 0), and the network activity with the slowest downloads. A dynamic version appears in the list of the dependencies.

  • Build cache: the number of tasks that touched a cache, which cache was used and the transfer rates, and the "Task execution" tab, which groups tasks by cacheability and says why a task was not cached.

  • Tests: the slowest tests and test classes, sorted by duration.

Gradle 8.14 and Gradle 9

Everything on this page works the same way in both lines, with these differences. The column for 8.14 comes from the official documentation and release notes (not run here); the column for 9.8.1 is what was run above or what the 9.x documentation says.

Topic Gradle 8.14 Gradle 9.8.1

Configuration cache

Stable since 8.1; off by default.

Off by default but the preferred mode since 9.0.0; a read-only mode for CI since 9.1.0; its default is announced for Gradle 10. Isolated Projects incubating since 9.7.0. Runs above used it.

org.gradle.tooling.parallel (parallelism of IDE model building separate from task parallelism)

Does not exist; it follows org.gradle.parallel.

Exists since 9.4.0; the documentation says to set it explicitly.

Build Scan with --scan

Works; the Develocity plugin com.gradle.develocity is recognised as the replacement of the Gradle Enterprise plugin from 8.4 on, and older Gradle versions may apply an older, deprecated plugin that conflicts with an explicit one.

Works; --scan auto-applies a default plugin version, --develocity-plugin-version chooses another.

--develocity-url, COM_GRADLE_DEVELOCITY_URL

Not available (publishing to a server needs the plugin applied in the settings file).

Since 9.5.0, with a plugin version of 4.4.0 or later.

File-system watching

On by default (since 7.0) on supported systems.

The same.

JVM that runs Gradle

Older JVMs still run the daemon.

Java 17 or later (9.0.0); the daemon JVM can be chosen separately, see The Gradle Daemon.

The --profile report

Available; the report categories are the same as above.

Run above.

Equivalents in Ant and Maven

Gradle Maven Ant

--profile HTML report

No built-in report; the reactor summary prints the time per module. See Maven: Build Performance, Cache, Parallel Builds & mvnd, which has a measuring section.

A logger that prints the time per target and task, org.apache.tools.ant.listener.ProfileLogger (see Ant Listeners, Loggers & Input Handlers).

Build Scan (Develocity plugin)

The Develocity extension for Maven is the equivalent; it is a separate product with its own documentation.

None in the core.

--parallel

-T (threads, per core or fixed), and the Maven 4 concurrent builder.

The <parallel> task inside a target; no parallel targets.

The daemon

The Maven Daemon (mvnd).

None: Ant starts a new JVM on every run (see Running Ant).

The build cache

The Maven Build Cache extension.

None; individual tasks compare time stamps.

The configuration cache

None; Maven reads the POMs on every run.

None.

File-system watching and --continuous

None in the core.

None in the core.

org.gradle.jvmargs

MAVEN_OPTS and .mvn/jvm.config (see Configuring Maven).

ANT_OPTS.

The general comparison across the three tools is on Comparing Ant, Maven & Gradle.

Where the books are outdated

Gradle in Action (Muschko, Manning 2014) was written for Gradle 1.7. Where it touches the topics of this page, the practice has changed:

  • The daemon is opt-in (--daemon). It has been on by default since Gradle 3.0; there is nothing to enable, and the guide’s advice is to check that nobody disabled it.

  • There is no build cache. The build cache arrived in Gradle 4.0 and is the main way to avoid repeating work across clean builds and machines.

  • There is no configuration cache, and project may be used freely at execution time. The configuration cache is stable since 8.1 and the preferred mode since 9.0.0, and a build that reads the Project from a task action is incompatible with it.

  • A JDK 1.5 or later runs Gradle, and the build is configured by sourceCompatibility. Gradle 9 needs Java 17 to run, and Java toolchains choose the JDK of the compiler and the tests (see Gradle Toolchains).

  • Build analysis by reading logs. The build report today is the --profile HTML report or a Build Scan, neither of which existed in this form in 1.7.

See also the list of removed idioms on Upgrading Gradle.

Classic mistakes

  • Tuning without a baseline. A change that is not measured twice, on a quiet machine, is an opinion. Keep the profile report of the "before".

  • Raising the heap first. The default is enough for most builds; a bigger heap hides a leak and costs a daemon restart.

  • Dynamic versions for convenience. They make the build slower and non-reproducible, and, with the configuration cache, they expire the entry every day.

  • Resolving configurations while configuring. A .files or a .get() on a resolvable configuration outside a task action or a provider runs on every build. The profile report’s Dependency Resolution tab on a help build tells.

  • Publishing a scan to the public service without reading what it captures, or committing the agreement to the terms of use into a project that others build.

  • Turning on --parallel over a build with tasks that share state, and blaming Gradle for the intermittent failures.

  • Believing a CI result for a cache you never checked. Check the cache hit rate; a shared cache that only CI writes to and developers read from is the safe setup.

  • Forgetting that continuous mode ignores build-script edits. Restart it after changing a build file.

Where to go next