Gradle Build Caching in Android CI Environments
Shared build caches need careful access controls and correct task declarations to work.

Android CI builds start from nothing every time. The workspace gets wiped, prior outputs are gone, and every task Gradle touches begins in a state where nothing is UP-TO-DATE. Yet somewhere, on some earlier CI run, that exact same task with those exact same inputs almost certainly already ran. The output exists. It's just not sitting in the workspace anymore. That gap, between work already done and work available to reuse, is what Gradle's build cache is built to close, and closing it correctly in CI takes more than flipping a config flag.
Local builds get to lean on incremental compilation: change one file, rebuild one file, and everything else stays put. CI doesn't get that luxury. A CI agent either hits the cache or it redoes the work from scratch, full stop. That asymmetry is why a developer who pulls main first thing in the morning and kicks off a build sees an outsized win when CI has been writing to a shared remote cache all along: the build finishes fast because someone else's CI run already paid the cost, not because anything on that laptop was cached locally.
How Gradle computes a cache key and the correctness consequences
Gradle treats every task as a pure function. Same inputs, same outputs, every time. It's safe to skip the work and hand back a result computed somewhere else. That premise only holds if Gradle can prove the inputs really are the same, and it does that with a cache key: a SHA-256 hash built from three things. The files going into the task (source, resources, config), the exact classpath (specific dependency versions and their content), and the code of the task or plugin doing the work.
Take a task like :core:network:compileDebugKotlin. Gradle doesn't just check that the Kotlin files in :core:network look unchanged, it hashes the actual source, the resolved classpath for that module, and the implementation of the Kotlin compiler task itself. If any one of those three things changes, the key changes, which is exactly the behavior you want.
Where this breaks is in the declaring. A task has to tell Gradle everything that affects its output and everything it produces, and if either list is wrong, correctness fails in one of two directions. If an input is missed, two builds that are actually different end up sharing a cache key, so one of them gets handed the wrong output and doesn't know it. Miss an output, and Gradle can't restore what the task actually needs on a cache hit, so the build fails. Neither failure mode announces itself. Both look, on the surface, like the cache "working."
Local cache, remote cache, and where each belongs in your pipeline
Gradle actually gives you two caches, and they solve different problems. The local cache lives on one machine and helps one developer: rebuild a branch you already built yesterday, and the local cache hands back yesterday's outputs. Useful, but it doesn't help the next person on the team, and it doesn't help CI, which usually starts each run on a clean or ephemeral agent anyway.
The remote cache is where the real payoff sits, especially on a large multi-module Android codebase, because it's shared across every machine that's allowed to reach it: CI agents, developer laptops, everyone's builds feeding the same pool and everyone's builds drawing from it.
That sharing is why the access-policy issue is more than a performance issue. Once a cache is shared, whoever can write to it can poison it, intentionally or not, and every future build that reads a poisoned entry inherits a wrong result silently. So this is a trust problem as much as a speed problem, and the AndroidX project's setup is a clean, concrete answer to it. Local development gets read-only access to the remote cache; it can write to a developer's own local cache, but nothing that developer does on their machine goes upstream. Two reasons for that: a developer's build could produce malicious or simply broken outputs that would otherwise contaminate the shared cache, and letting every laptop write to a remote endpoint adds real network and storage overhead without buying the team much in return.
Post-merge CI is different. Code that's already been reviewed and merged carries a lot less risk, so post-merge CI gets both read and write access to the remote cache, which is also what lets it seed the cache for everyone downstream. Release CI sits at the other extreme, with no remote cache access whatsoever. The minutes saved by a cache hit aren't worth the risk of a release build quietly pulling a bad artifact from a shared cache. That's not a close call.
Remote cache backend options and the Develocity Build Cache Node EOL deadline
Teams picking a remote cache backend are choosing among a handful of real options, not building one from scratch. The build tool's own vendor-supported platform provides the officially backed path: build caching plus Build Scans and analytics, and it is not limited to that one build tool, since it also supports other build systems such as Maven and sbt. For teams that want to run their own infrastructure, Gradle's standard HttpBuildCache protocol works against a self-hosted HTTP backend, and that's commonly Artifactory, Nexus, or plain Nginx configured with WebDAV. There's also the option of cloud object storage, AWS S3 or Google Cloud Storage, wired up through community-maintained plugins. And for teams that don't want to run infrastructure at all, managed cache-as-a-service offerings provide a globally distributed cache without anyone on the team owning a server.
Teams self-hosting via the Develocity Build Cache Node have a hard deadline to plan around: that Node is deprecated, and Gradle will stop distributing, supporting, or otherwise making it available after December 31, 2026. The replacement is Develocity Edge, which provides the same remote build caching service but is built for better resiliency, better scalability, and easier day-to-day operation. Edge isn't just a caching swap-in either; it is the infrastructure layer that Develocity's Universal Cache platform will run on going forward, so migrating now instead of in late 2026 means not doing this move twice.
Develocity also ships an Artifact Cache and Setup Cache aimed at a specific CI pain point: ephemeral agents. When agents get created and destroyed for every job, which is standard practice on most cloud CI fleets, every fresh agent starts with no local dependency cache and has to re-download everything from Maven Central or the npm registry from zero. Artifact Cache serves those dependencies from a nearby Edge node instead, cutting out the repeated round trip to the public registry. The 2026.1 release tightened this up further: the CLI tool got smaller, stale cache content gets cleared out more intelligently, and cached images stick around longer before a full rebuild is forced, which together makes restore times more predictable run to run.
The six Android-specific patterns that silently break cache correctness
None of the following are exotic corner cases dreamed up for a conference talk. They appear in real, actively maintained codebases, including well-known open-source Android projects, and a single audit can find several of them stacked on top of each other.
Room's schema export writes out file paths, and when those paths are absolute, a path generated on a developer's machine looks nothing like the same path generated on a CI agent, even though the underlying source is identical, an issue that surfaces through room schema paths with absolute references. Different path, different hash, different cache key, cache miss for no real reason. The fix is to move to the Room Gradle Plugin, which handles schema paths as relative rather than absolute automatically. This single fix, on its own, cut local clean build time by 50% in one team's measurement.
Annotation processor non-determinism, specifically Dagger and Hilt under kapt or KSP. Dagger's annotation processor used to generate code where method ordering wasn't fixed, so two CI runs over the exact same source could produce generated classes that differ byte for byte. Different bytes hash differently, so the cache key differs, and Gradle records a miss even though the two outputs are functionally identical in every way that matters. Upgrading Dagger to 2.53 or later fixes this at the source, since that version produces deterministic output. One team's fix here moved 1,619 previously cache-missing tasks into cache hits, saving over 15 minutes of serial execution time across its CI workflows. Anyone still on kapt should also check their Kotlin version: kapt wasn't cached by default before Kotlin 1.3.30, so a stale toolchain can look like a caching bug when it's really just an old default.
CMake caching mismatches. Native build tasks configured through CMake can end up with cache configuration inconsistencies that cause systematic misses across every native task in the graph, not just an occasional one. Fixing the configuration mismatch avoided 3,740 cacheable tasks running unnecessarily and saved 1 hour 57 minutes of serial execution time in one measured case.
How the DuckDuckGo Android team found and fixed these issues systematically
DuckDuckGo's Android browser is one of the most actively maintained open-source Android projects on GitHub, and its build reflects that: 160 modules, with a build surface spanning assembly, lint, JVM unit tests, Android instrumented tests, code formatting, annotation processing, and native builds. Before any of the fixes above went in, the team was looking at tasks re-running that shouldn't have needed to, caches getting silently invalidated by configuration quirks nobody had flagged, and no clear visibility into where the build's time was actually going.
The diagnostic tool that cut through the guesswork was Develocity's Build Validation Scripts, which are built specifically to compare builds and surface exactly where cache correctness is breaking down. Running them surfaced the three issue classes above: the Room schema path breaking cache correctness, Dagger producing non-deterministic outputs, and the CMake caching configuration mismatch, all present in the same codebase at the same time. Fixing all three together, rather than chasing them one at a time as isolated bugs, cut CI build times by up to 57%.
That number matters less as a benchmark to chase than as a demonstration of scale: none of the three fixes involved rewriting build logic or restructuring modules. Each was a specific, narrow correction to how a task declared its inputs or how a processor generated its output. The build cache doesn't need clever configuration so much as it needs the underlying tasks to tell the truth about what actually affects them.
Enabling and configuring Gradle build caching correctly in a CI environment
Turning caching on at all takes one line, org.gradle.caching=true in gradle.properties. That's table stakes. It's necessary and it's nowhere near sufficient, since local caching alone does nothing for a CI agent that starts clean and never talks to anyone else's cache.
The remote cache itself gets wired up in settings.gradle.kts, inside a buildCache block where a remote(HttpBuildCache::class) (or the equivalent for whichever backend was chosen) points at the shared endpoint and carries its credentials. What actually matters, though, isn't the endpoint; it's the read/write policy layered on top of it. That policy shouldn't be static. It should be set dynamically, based on environment variables that identify what kind of build is running: a flag like isPush gets set to true only when the build detects it's running as a CI push, so write access to the remote cache is earned by context, not granted by default. Local developer builds keep isPush false and stay read-only. Release builds skip the remote cache entirely, consistent with the trust hierarchy laid out earlier. The build tool's build cache configuration is built to be set this way, conditionally, at evaluation time, a standard pattern for enforcing per-context access policies.
None of that matters, though, if the tasks themselves aren't cacheable. Gradle doesn't cache everything by default, custom tasks need to opt in explicitly with @CacheableTask, and that annotation is only half the job. The task also has to declare its inputs and outputs completely and correctly, using @InputFiles, @OutputDirectory, and path sensitivity annotations, which tell Gradle whether absolute paths, relative paths, or just file content should count toward the key. Skipping or fudging these declarations is the root cause behind both failure modes described earlier, the wrong-output cache hit and the failed restoration. A cache is only as trustworthy as the task metadata feeding it, and that metadata is something a team has to get right by hand, task by task, not something the cache infrastructure can paper over on its own.

