How we reduced our Gradle build times by over 80%

When I joined my current company a few months ago, building our Android app took an average of 14 minutes on my machine. You can imagine how wildly unproductive that made me feel, so I went on a journey to speed it up.
It’s down to about 2 minutes now, so I’ll share some of the things I learned along the way, but please don’t apply these changes blindly to your project and instead use the Gradle profiler to ensure that they work for your existing setup. Different things work for different projects.
Easy wins
- Use the latest versions of the tools and plugins you use in your project
We were using AGP 4.2, and by updating to AGP 7, we were able to take advantage of the performance improvmenets promised in Gradle 7, which takes us to my next point.
2. Enable file-system watching
If you’re already using Gradle version 7, skip this section because it’s enabled by default, but if you’re stuck on version 6.7 (AGP versions 7 and 4.2), then you should manually enable file-system watching. It allows Gradle to store information about which tasks’ inputs and outputs were changed between builds, so it can quickly figure out which ones to re-execute.
You can enable it by adding this to your gradle.properties
script
org.gradle.vfs.watch=true
3. Enable configuration on demand
This is helpful for multi-module projects because it attempts to configure only modules that are relevant for the tasks you run instead of configuring your entire project for every task. However, it’s currently still an incubating feature, so it might not work for your project. You can read more about it here
You can enable it by adding this to your gradle.properties
script
org.gradle.configureondemand=true
4. Enable parallel execution
If you’re working on a multi-module project, then forcing Gradle to execute tasks in parallel is also an easy performance gain. This works with the caveat that your tasks in different modules should be independent, and not access shared state, which you shouldn’t be doing anyway.
You can enable it by adding this to your gradle.properties
script
org.gradle.parallel=true
5. Enable build caching
This works by storing and reusing outputs produced by other builds if the inputs haven’t changed. One feature of this is task output caching. It leverages Gradle’s existing UP_TO_DATE
checks, but instead of only reusing outputs from the most recent build on the same machine, it allows Gradle to reuse outputs from any earlier build in any location on the machine. When using a shared build cache for task output caching, this even works across developer machines and build agents, so you can share the same cache with your coworkers and your CI. Nelson Osacky wrote a nice series about the benefits and caveats of using remote build caching here
You can enable it by adding this to your gradle.properties
script
org.gradle.caching=true
General advice
Before we move on to the next section, here’s a quick primer on the Gradle build lifecycle. This is important so we understand the savings we’ll get from some of the points below.
Every Gradle build has 3 phases:
- Initialization: this is where Gradle decides which modules are going to take part in the build, and creates a
project
instance for each of them. - Configuration: this is when the project objects are configured, and all the build scripts of all projects which are part of the build are executed. This phase is where you need to pay the most attention because it’s executed with every build, so if you’re firing off a network request here for some reason, please don’t.
- Execution: like the name implies, this is where Gradle executes the tasks that were created and configured earlier.
Now on to some random bits of advice in no particular order of importance!
6. Think carefully about the plugins you add to your project
Every plugin you add to your project adds time to the configuration phase, even if it doesn’t do anything. So go through your plugins and remove the ones you’re not using. You can find out how much time each plugin is adding to your build by looking at build scans.
7. If you’re using the Firebase Performance Monitoring plugin, disable it for debug builds
We saw massive improvements by making this change.
android {
……
buildTypes {
debug {
FirebasePerformance {
instrumentationEnabled false
}
}
}
}
8. Convert build logic to static tasks
If you have a lot of logic in your build.gradle
script, consider converting it to static Gradle tasks so Gradle can cache their results and alleviate their effects on your project’s configuration phase time. For example, if you have code that determines the versionName
based on the current Github branch, that’s a good candidate to start with. While we’re on this topic, always check and make sure your build.gradle
scripts are as lean as possible. We removed a few legacy tasks/code snippets that didn’t make sense in the scope of our project anymore this way. You can read about the best practices for authoring maintainable builds here
9. Move scripts and tasks to your buildSrc directory
This might not give you a performance improvement, but like we mentioned above, this cleans up your build.gradle
scripts, and also allows you to easily reuse these tasks in different modules. You can read about the buildSrc directory here, writing scripts here, and writing tasks here.
10. Take advantage of configuration avoidance
Now that you’re writing your own Gradle scripts, you should learn about configuration avoidance. It allows Gradle to avoid creating/configuring tasks during the configuration phase when these tasks won’t be executed. For example, when running a compile task, other unrelated tasks, like code quality, testing and publishing tasks, will not be executed, so any time spent creating and configuring those tasks is unnecessary. You can start by registering your tasks instead of eagerly creating them, but there’s even more you can learn about effective task creation by reading the docs linked above. Registered tasks are known to the build, but they’re only configured when necessary for execution. Take a look at these two tasks:
task mySlowTask {
sleep 2000
doLast {
println "This task will add 2 seconds to your configuration phase every time you run any task"
}
}tasks.register("mySlowTask") {
sleep 2000
doLast {
println "This task won't add much time to your build unless you specifically execute it or any tasks that depend on it"
}
}
11. Enable the parallel garbage collector if you’re using JDK 9+
You might want to profile this change first before enabling it for your project, but it can be enabled by appending the string -XX:+UseParallelGC
to your org.gradle.jvmargs=
in the gradle.properties
script, or just by adding the following line there if you haven’t customized these settings before.
org.gradle.jvmargs=-XX:+UseParallelGC
12. Use the gradle-doctor plugin
I know I just said you should really think before adding plugins to your project, but this one’s only purpose is to improve your builds by giving you warnings about issues it finds in your project, and you can remove it once you’re done.
13. Enable non-transitive R classes
Doing so helps prevent resource duplication by ensuring that each module’s R class only contains references to its own resources, without pulling references from its dependencies. This leads to more UP_TO_DATE
builds and the corresponding benefits of compilation avoidance. Many people didn’t see any any performance improvement with this change though, so take it with a grain of salt.
Here’s an excellent blog post about this by Blundell
14. Enable configuration caching
This is an experimental feature, so proceed with caution, but I can confirm that it was absolutely magical for us. Remember the configuration phase we talked about above? Well, this feature caches its results and reuses them for subsequent builds, similar to how build caching caches and reuses task outputs. You can enable it by adding the line below to your gradle.properties
script, but please read about it here first.
org.gradle.unsafe.configuration-cache=true
15. Disable Jetifier
The jetifier tool basically replaces the android.support
libraries with their equivalent androidx
versions, but it also adds a ton of time to your configuration phase. If you don’t need it, it’s an easy performance gain.
This excellent blog post by Adam Bennett will help you determine whether or not you can remove the Jetifier from your project, as well as describing the improvements his team saw by doing so.
16. Disable the build variants you’re not using
Gradle creates a build variant for every possible combination of the product flavors and build types that you configure, but there might be some variants that either you do not need or do not make sense in the context of your project. You can remove these build variant configurations by creating a variant filter in your module-level build.gradle
. Here’s an example for disabling the mockRelease variant in case you never want to build that.
android {
………
buildTypes {
debug { … }
release { … }
}
productFlavors {
mock { … }
full { … }
}
variantFilter { variant ->
def names = variant.flavors*.name
def isReleaseBuildType = variant.buildType.name == "release"
if(names.contains("mock") && isReleaseBuildType) {
setIgnore(true)
}
}
}
17. If you’re using Github Actions for your CI, consider the Gradle Build Action
We were able to cut our total CI runtime for all consecutive pushes to the same branch in half by using the Gradle Build Action because it’s able to properly task cache results from previous runs, unlike the Github Cache action which kinda didn’t work at all for us out of the box.
Many thanks to Adam Bennett and Vladimir Jovanović for their help with proof-reading this blog post!
That’s it! Please share this blog post to spread the knowledge
Add me on Linkedin and Twitter to talk about Gradle and Android! :)