← Back to list

Scaling Android CI: From 44 → 10 minutes — A Deep Dive into Build Time Optimization and Best…

Android monorepos get expensive and slow as they scale. We re-architected CI/CD caching, trimmed the Gradle graph, tuned the JVM…

Pratik Sahu in Swiggy Bytes — Tech Blog · 2026-07-10 05:14 · 6 claps · 4.8 min read
#cicd #gradle #android #kotlin #cache
Open on Medium ↗
Wiki topics: 📱 · Mobile Development ☁️ · DevOps & Cloud 🏛️ · Architecture

Scaling Android CI: From 44 → 10 minutes — A Deep Dive into Build Time Optimization and Best Practices

Android monorepos get expensive and slow as they scale. We re-architected CI/CD caching, trimmed the Gradle graph, tuned the JVM, modernized the toolchain, and finally moved to Apple Silicon — plus specific configs and numbers you can replicate.

Context and Baseline

Our CI/CD pipeline started conventional: git clone → fetch npm and Android dependencies → execute Gradle build → notify; the build phase alone averaged ~35 minutes and repeatedly re-downloaded dependencies with no cache restore while configuration re-ran due to environment-driven invalidations. Each CI minute cost 2 credits.

After targeted optimizations, we cut total time to P90 = 11 minutes and P50 = 10 minutes, and reduced average credits per build from 55 to 43 (~20%).

At ~460 builds/month, we projected ~37% fewer credits and ~37% less wall-clock time, avoiding an imminent $22k/year tier-upgrade by staying on the current plan.

What Was Holding Us Back

  • Archive cache irrelevance: as many modules changed, CICD cache decayed fast and reuse dropped.
  • Configuration invalidations from system properties and env inputs caused re-configuration even when tasks didn’t change.
  • Dependency resolution overhead: Gradle probed multiple repositories for each coordinate, adding latency at our scale.
  • Variant bloat: CI compiled non-required variants, inflating the task DAG by thousands of tasks.

Machine constraints created OOMs and noisy variability that masked true performance.

Strategy Overview

We focused on four compounding pillars:

  • Cache architecture with branch-aware keys, release/develop fallbacks, and a Gradle Remote Cache for task-level reuse.
  • Build graph control to shrink the DAG: reduce inter-module coupling and enforce only required variants.
  • Dependency resolution hardening via repository content filtering to avoid redundant lookups.
  • Runtime tuning: right-size JVM heap/Metaspace for CI and disable work that helps locally but wastes time on ephemeral agents (e.g., configuration cache).

Optimization 1: Deterministic Caching with Branch-Scoped Keys

We made caching branch-aware and OS/arch-aware, and cached more than artifacts: Android SDK components, project dependencies, transformed artifacts, NPM cache, and NDK.

Restore priority was: current branch → previous release → develop.

# Gradle restore (priority order)
{{ .OS }}-{{ .Arch }}-gradle-cache-v3-{{ getenv "SWIGGY_CACHE_VARIANT" }}-{{ getenv "GIT_BRANCH" }}
{{ .OS }}-{{ .Arch }}-gradle-cache-v3-{{ getenv "SWIGGY_CACHE_VARIANT" }}-release
{{ .OS }}-{{ .Arch }}-gradle-cache-v3-{{ getenv "SWIGGY_CACHE_VARIANT" }}-develop
# Gradle save
{{ .OS }}-{{ .Arch }}-gradle-cache-v3-{{ getenv "SWIGGY_CACHE_VARIANT" }}-{{ getenv "GIT_BRANCH" }}
# npm restore/save
{{ .OS }}-{{ .Arch }}-npm-cache-{{ checksum "**/package-lock.json" }}

We normalized cache variants (release, releaseDebuggable) and gated cache saves to the default branch to reduce fragmentation and cost overhead:

# Decide cache variant and whether to save cache
cache_variant="prod"
if [ "$SWIGGY_APK_APP_VARIANT" = "release" ] || [ "$SWIGGY_APK_APP_VARIANT" = "benchmark" ]; then
  cache_variant="release"
elif [ "$SWIGGY_APK_APP_VARIANT" = "releaseDebuggable" ]; then
  cache_variant="releaseDebuggable"
fi
envman add --key SWIGGY_CACHE_VARIANT --value "$cache_variant"

save_cache=false
default_branch=develop
if [ "$GIT_BRANCH" == "$default_branch" ]; then
  save_cache=true
fi
envman add --key PROJECT_DEFAULT_BRANCH --value "$default_branch"
envman add --key SWIGGY_SAVE_CACHE --value "$save_cache"

Early lesson: continuously saving branch-level cache added overhead that often outweighed benefits on our credit-per-minute plan; using develop/release caches (refreshed automatically on back-merges via GitHub Actions) gave better ROI.

Pricing plan tradeoff

Under the credit-per-minute plan (2 credits/min), the 1–1.5 minutes (sometimes ~3 minutes) to save cache increased direct cost, so we avoided branch-level cache saves. Once we moved to a per-build plan, time overhead wasn’t billed linearly, and we started saving branch cache post-build.

Post-Apple Silicon refresh

After moving to Apple Silicon, we set the base branch cache as the primary restore, then save a branch-level cache after the first run so subsequent, same-branch builds consistently hit single-digit minutes when changes are localized.

Optimization 2: Gradle Remote Cache (Module-Level Reuse)

CICD archive cache staled quickly as more modules changed, so we piloted a Gradle Remote Cache on a small EC2 (Linux, 8GB RAM, 256GB SSD) serving GET/PUT under /cache.

Results from the POC:

  • Incremental builds: cache hit is now P50 = 44% and P90 = 56% (latest),

This raised incremental hit rates and stabilized day-to-day developer builds.

Optimization 3: Build Only What Matters (Variants + DAG)

We enforced building only required release variants in CI, cutting total Gradle tasks by ~26% (3399 → 2745).

We also reduced inter-module coupling across 100+ modules to widen parallelism and shorten the critical path — an ongoing effort with tangible impact.

Optimization 4: Repository Content Filtering

On clean builds, Gradle’s multi-repo probing added up. We introduced **repository content filtering** so Gradle fetched coordinates from the correct repo without trial-and-error, reducing resolver latency for our large graph.

Optimization 5: Right-Size the JVM for CI

We ran controlled trials and converged on Heap = 16GB and Metaspace = 12GB for 32GB runners, balancing GC frequency and pause time while avoiding OOMs.

Representatively, under the develop/release cache plan, end-to-end times routinely landed around ~20–22 minutes when cache hit was healthy.

We also disabled configuration cache in CI after build scans showed ~30 seconds spent saving a cache that’s not reused in ephemeral CI — useful locally, wasteful in pipelines.

Optimization 6: Machines That Don’t Fight You

We moved from Ubuntu 22.04 L to XL to eliminate OOMs; Bitrise’s SOC update (Google → AMD) improved times, and M1 Max trials reached ~15 minutes with availability caveats at the time.

We’ve since moved critical workflows to Apple Silicon (M4 Pro, 54GB), which tightened variance and removed memory headroom issues seen on earlier x86 profiles.

Toolchain Modernization

We bumped Kotlin, AGP 8.10.1, and the Gradle wrapper 8.11.1. Combined with the refreshed caching approach on Apple Silicon, this pushed subsequent, small/localized builds into single-digit minutes.

Results

Baseline vs improvements:

  • Before optimizations on x86: P90 ≈ 44m, P50 ≈ 35m.
  • After pipeline optimizations on x86: P90 ≈ 26m, P50 ≈ 23m; average credits per build 55 → 43 (~20% lower); ~37% monthly reductions in both credits and minutes; avoided a $22k/year tier-upgrade.
  • After Apple Silicon (M4 Pro, 54GB) and cache/toolchain refresh: P90 = 11m, P50 = 10m.

Additional visible effects:

  • “Time to start build” dropped from ~4–5 minutes to 30–90 seconds depending on cache source.
  • In a comparable live window after rollout, we saved ~5800 credits (~45%).

What We’d Do Again (Best Practices You Can Lift)

  • Use branch-aware, tiered cache keys and avoid saving branch caches on every run under per-minute billing; refresh authoritative develop/release caches automatically on back-merges.
  • Adopt a Gradle Remote Cache for large modular repos to stabilize incremental builds and raise hit rates.
  • Trim the DAG ruthlessly: enforce required variants only; stub or disable non-essential modules in CI to cut tasks.
  • Constrain dependency lookups with repository content filtering to reduce resolver overhead.
  • Tune the JVM to the runner and validate with CI trials; target predictable GC over edge-of-OOM settings.

Right-size machines and prefer Apple Silicon for Kotlin/AGP-heavy builds where available to compress build times.

Acknowledgements

I am Pratik Sahu from the Android Mobile team at Swiggy and I’d like to thank Sambuddha Dhar, Raj Gohil and Tushar Tayal. This milestone wouldn’t have been possible without their constant help and support


메타데이터
post_id
ff93aec82f05
slug
scaling-android-ci-from-44-10-minutes-a-deep-dive-into-build-time-optimization-and-best-ff93aec82f05
url
https://medium.com/swiggy-bytes/scaling-android-ci-from-44-10-minutes-a-deep-dive-into-build-time-optimization-and-best-ff93aec82f05
canonical_url
https://medium.com/swiggy-bytes/scaling-android-ci-from-44-10-minutes-a-deep-dive-into-build-time-optimization-and-best-ff93aec82f05
author_url
https://medium.com/@pratiksahu184
status
ok
fetched_at
2026-07-11 07:22:59