DuckDuckGo's Android browser is one of the most actively maintained open-source Android projects on GitHub, available at github.com/duckduckgo/Android. The team ships frequently, reviews pull requests at volume, and relies heavily on CI to keep pace. But like many large Android codebases, their Gradle builds had quietly accumulated inefficiencies — tasks re-executing when they didn't need to, caches silently invalidated by configuration quirks, and no easy way to see where time was actually going.
The project spans 160 modules with a broad build surface — assembly, linting, JVM unit tests, Android instrumented tests, code formatting, annotation processing, and native builds all in the critical path.
Gradle Technologies provides DuckDuckGo with a free Develocity instance as part of its support for the open-source community, giving the team access to build insights from Build Scan, build caching from the Remote Build Cache, and test execution avoidance from Predictive Test Selection. Beyond build caching, Develocity gives Android teams visibility into parts of the build that are otherwise opaque — critical path analysis, artifact transform tracing, configuration time breakdowns, configuration cache miss diagnostics, test failure and flaky test tracking, and resource usage monitoring — all surfaced automatically in every Build Scan captured for local and CI builds.
Working with the Gradle Technologies team, DuckDuckGo used the Develocity Build Validation Scripts for Gradle to systematically uncover and fix performance-related issues. Here's what they found — and what other Android teams can learn from it.
The Build Validation Scripts are a structured sequence of tests that progressively verify a project's build caching behavior. Each experiment builds on the last, narrowing down where work is being unnecessarily repeated.
Experiment 1 — Incremental builds confirmed the basics were sound. A no-op rebuild dropped from 5m 59s to 50s.
Experiment 2 — Local build caching also looked healthy. After a clean rebuild, as is the case when changing branches, for example, the build went from 5m 45s to 1m 36s, with only two negligible tasks (0.4s combined) still executing.
Experiment 3 — Build caching across different project locations is where the problems surfaced. This experiment validates that build caching still delivers full cache hits when the project is checked out at different locations on the file system, as is the case when the project is built by a CI agent and a developer, or a developer uses git worktrees, for example, a workflow DuckDuckGo's team relies on heavily. It also lays the groundwork for remote build caching, since different machines typically have different absolute paths to the project. The build went from 6m 53s to 2m 57s, but 440 cacheable tasks were still executing, representing 14m 44s of serial work being needlessly repeated.
Experiment 4 — CI-to-CI remote caching measures whether a CI agent can reuse task outputs produced by a previous CI run via the remote build cache — the workflow that most directly affects PR feedback time, since every PR build starts with a warm cache populated by other CI runs. This experiment showed 13 tasks with cache misses across CI runs, costing 4m 48s of serial execution time — small in absolute terms, but each recurring miss compounds across every PR build (build comparison).
Each experiment pointed to specific, fixable root causes.
The single biggest improvement came from how Room database schema export paths were configured. They were defined as absolute paths, meaning every machine — every developer laptop, every CI agent — produced different task inputs. Since Gradle's build cache keys include task inputs, different absolute paths meant different keys, which meant full cache invalidation on every cross-machine build.
This is one of the most common cache-busting issues in Android projects using Room, and the fix is a one-line change: use the Room Gradle Plugin to manage schema locations, which handles relative paths automatically. Projects still using the legacy kapt/ksp argument-based approach should migrate.
The result: a 50% reduction in local clean build time from a single configuration change. After this fix, experiment 3 improved from 440 executed cacheable tasks down to 27, and savings jumped from 3m 56s to 5m 3s (build comparison).
Experiment 4 revealed that Dagger's annotation processor was generating classes with non-deterministic method ordering. Two CI runs processing the same source code would produce functionally identical but byte-wise different outputs. Since Gradle's build cache uses content hashing, different byte ordering results in different cache keys—and hence cache misses for tasks that consume the bytecode.
Upgrading Dagger to 2.53+ with deterministic output resolved this immediately: 1,619 previously cache-missing tasks now hit the build cache, saving over 15 minutes of serial execution time across CI workflows (build comparison).
DuckDuckGo's project includes native code built through CMake. Addressing cache misses in this layer was another significant win. After this fix, experiment 3 showed 3,740 avoided cacheable tasks and a total saved serial execution time of 1 hour 57 minutes per clean cross-location build cycle (build comparison).
With the Dagger and CMake fixes combined, experiment 4 now reaches zero executed cacheable tasks between consecutive CI runs — the remote build cache is fully warm (build comparison).
These are end-to-end workflow run times as reported by GitHub Actions.
| Workflow | Before | After | Reduction |
|---|---|---|---|
| build-debug-apk.yml | 11m 30s | 6m 58s | 39% |
| ci.yml | 15m 00s | 12m 12s | 19% |
The ci.yml improvement is lower because it includes Android instrumented tests and Fladle/Firebase tests, which are inherently not cacheable. The cacheable portion of that pipeline saw much larger relative gains.
| Scenario | Before | After | Reduction |
|---|---|---|---|
| Different project locations (exp 3) | 6m 53s | 2m 57s | 57% |
| CI-to-local assembly | 10m 38s | 45s | 93% |
Developers using git worktrees regularly see 10–20 minutes of serial avoidance savings per build, translating to roughly 2–4 minutes off wall-clock time.
These are individual Gradle task times compared across two consecutive CI runs, measuring the effectiveness of the remote build cache.
| Gradle Task | Before | After | Reduction |
|---|---|---|---|
| spotlessCheck | 1m 57s | 50s | 57% |
| jvm_tests | 14m | 1m 52s | 87% |
| androidTestsBuild | 6m 35s | 3m 45s | 43% |
| lint | 10m 15s | 3m 24s | 67% |
Beyond the caching work, the team configured test retry for unit tests and JUnit XML import for Android connected tests and Fladle/Firebase tests. Combined with Develocity's Test Analytics dashboard, this gives the team a centralized view for tracking and fixing flaky and failing tests across their entire test suite.
Following the caching improvements, DuckDuckGo enabled Predictive Test Selection (PTS) for JVM unit tests. PTS uses machine learning to identify which tests are likely affected by a code change and runs only those, skipping the rest.
The team configured PTS with Develocity's Standard profile, which balances speed and test coverage. It's enabled for local builds and PR checks — where fast feedback matters most — while post-merge and nightly workflows still run the full test suite to maintain safety.
In just the last 7 days: 20 hours and 33 minutes of serial test time were saved, representing 56% of test execution time saved in builds where PTS was active. For a project with thousands of unit tests spread across dozens of modules, that translates directly into faster PR feedback loops and lower CI costs.
If any of this sounds familiar, here are the highest-impact things you can check in your own project:
Check your Room schema paths. If you're defining schema export locations with absolute paths, you're likely invalidating your build cache across machines. Migrate to the Room Gradle Plugin, which handles relative schema paths automatically — it's probably the most common cache-busting issue in Android projects.
Pin annotation processor versions with deterministic output. Dagger and other annotation processors have historically produced non-deterministic output ordering. If you're seeing unexplained cross-machine cache misses in kapt or ksp tasks, try upgrading. Develocity's build comparison feature makes it straightforward to diff task inputs between two builds and spot the non-determinism.
Run the Build Validation Scripts yourself. The Develocity Build Validation Scripts are open source and free to use. They'll tell you exactly how much caching headroom your project has and point you at the specific tasks that need attention. For another example of this approach at scale, see how Netflix uses Build Validation Scripts to monitor build performance.
Don't dismiss small per-task savings. DuckDuckGo's project has thousands of tasks. Fixing cache behavior across all of them added up to nearly 2 hours of serial execution time saved per full build cycle. The wall-clock savings depend on parallelism, but the CI cost savings are directly proportional to serial time.
Explore what else Develocity offers beyond build caching. This post focused on build caching optimization and test execution avoidance optimization, but Develocity provides a broader toolkit worth evaluating. Test Distribution parallelizes test execution across multiple agents for faster feedback on large suites. The Universal Cache platform goes beyond task output caching — the Artifact Cache acts as a LAN-based caching layer for build dependencies (Maven Central, npm, internal repositories), delivering at least 6x faster dependency resolution on ephemeral CI agents, while the Setup Cache accelerates Gradle build initialization by restoring pre-computed state like compiled build scripts and file hashes, cutting configuration phase time by roughly 50%. The Dependency Provenance Governor enforces supply chain policies on which dependencies are allowed into builds. And every build automatically produces a Build Scan with critical path analysis, resource usage monitoring, and failure diagnostics — making the kinds of investigations described in this post straightforward to conduct.
Make your build data available to AI agents. The investigations behind this post — correlating cache misses across machines, diffing task inputs, hunting non-determinism in generated code — were themselves accelerated by AI agents querying Develocity through its MCP server. The CMake cache miss, in particular, was easier to untangle once an agent could pull task inputs and cache keys from Develocity directly, rather than us pasting log snippets into a chat. The MCP server exposes Build Scans, Test Analytics, and Failures to AI coding assistants, so the same ground truth a human engineer would inspect is available to the agent as it reasons alongside them.
DuckDuckGo's Android browser is open source at github.com/duckduckgo/Android. Their Develocity instance is provided by Gradle Technologies to support the project.
Learn more about Build Cache.
