Reducing APK Size Without Breaking Features
Practical techniques for shrinking Android APK size in production apps, covering R8 configuration, resource optimization, native library management, and the trade-offs of each approach.
Context
APK size directly affects install conversion rates. Google's data shows a 1% drop in installs for every 6MB increase in download size. For apps targeting emerging markets where users have limited storage and metered connections, this number is even more dramatic. Reducing APK size is not vanity optimization. It is a growth lever.
Problem
Large apps accumulate size from many sources: bundled resources for every screen density, unused code from transitive dependencies, native libraries for multiple ABIs, and debug information that leaks into release builds. No single technique solves the problem. Effective size reduction requires a systematic approach across code, resources, and native libraries, with continuous monitoring to prevent regression.
Related: Event Tracking System Design for Android Applications.
See also: Designing Event Schemas That Survive Product Changes.
Constraints
- Must not break R8/ProGuard rules that protect reflection-based code (Gson, Retrofit, Room)
- Must preserve functionality for all supported device configurations
- Must not increase crash rates (over-aggressive shrinking removes needed code)
- Must support Play Feature Delivery for on-demand modules
- Size budget must be enforced per module in CI
Design
Understanding Where Size Comes From
Before optimizing, measure. Use Android Studio's APK Analyzer or bundletool to break down the APK.
| Component | Typical % of APK | Primary Reduction Strategy |
|---|---|---|
| DEX (code) | 30-40% | R8 shrinking, removing unused dependencies |
| Resources | 20-30% | Resource shrinking, WebP conversion, density splits |
| Native libraries (.so) | 15-30% | ABI splits, dynamic delivery |
| Assets | 5-15% | Compression, on-demand download |
| META-INF, other | 5-10% | Minimal opportunity |
# Analyze AAB size breakdown
bundletool build-apks --bundle=app.aab --output=app.apks --mode=universal
unzip -l app.apks universal.apk | sort -k1 -n -r | head -20R8 Optimization
R8 performs code shrinking, obfuscation, and optimization. Proper configuration is the single highest-impact size reduction.
// build.gradle.kts
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}Critical keep rules for common libraries:
# Retrofit: keep API interface methods
-keepattributes Signature
-keepattributes *Annotation*
-keep,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
# Gson: keep model classes used in serialization
-keep class com.example.app.models.** { *; }
# Room: keep entity classes
-keep @androidx.room.Entity class * { *; }
# Kotlin serialization
-keepattributes RuntimeVisibleAnnotations
-keep class kotlinx.serialization.** { *; }
-keepclassmembers class * {
@kotlinx.serialization.Serializable *;
}Aggressive R8 mode (full mode):
// gradle.properties
android.enableR8.fullMode=trueFull mode enables additional optimizations including class merging, more aggressive inlining, and removal of unused constructors. Test thoroughly, as some libraries break under full mode.
Resource Optimization
1. Convert images to WebP
# Convert all PNGs to WebP (lossless, ~30% size reduction)
find . -name "*.png" -not -name "*.9.png" -exec cwebp -lossless {} -o {}.webp \;2. Remove unused resources
android {
buildTypes {
release {
isShrinkResources = true // removes resources not referenced in code
}
}
}For resources referenced dynamically (via getIdentifier()), create a keep file:
<!-- res/raw/keep.xml -->
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@layout/dynamic_*,@drawable/icon_*"
tools:shrinkMode="safe" />3. Per-density resource generation
android {
splits {
density {
isEnable = true
include("mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi")
}
}
}With App Bundles, Google Play automatically generates per-device APKs. This alone can reduce download size by 15-25%.
Native Library Management
Native libraries are often the largest contributor and the easiest to reduce.
android {
defaultConfig {
ndk {
// Only include ABIs you actually need
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
// x86 and x86_64 are only needed for emulators
}
}
}| ABI | Device Coverage | Typical Library Size |
|---|---|---|
| arm64-v8a | ~85% of active devices | Baseline |
| armeabi-v7a | ~15% of active devices | Usually smaller |
| x86_64 | Emulators only | Remove for production |
| x86 | Emulators only | Remove for production |
For large native libraries (ML models, media codecs), use Play Feature Delivery:
// Dynamic feature module for ML
// feature/ml/build.gradle.kts
plugins {
id("com.android.dynamic-feature")
}
android {
dynamicFeatures += ":feature:ml"
}// On-demand install in the app
class MlFeatureManager @Inject constructor(
private val splitInstallManager: SplitInstallManager
) {
fun requestMlFeature(): Flow<InstallState> = callbackFlow {
val request = SplitInstallRequest.newBuilder()
.addModule("ml")
.build()
val listener = SplitInstallStateUpdatedListener { state ->
trySend(state.toInstallState())
}
splitInstallManager.registerListener(listener)
splitInstallManager.startInstall(request)
awaitClose { splitInstallManager.unregisterListener(listener) }
}
}Dependency Audit
Transitive dependencies quietly inflate APK size. Audit regularly.
# List all dependencies with their sizes
./gradlew app:dependencies --configuration releaseRuntimeClasspath | \
grep '+---' | sort | uniq// In build.gradle.kts, exclude unnecessary transitive dependencies
implementation("com.some.library:core:1.0") {
exclude(group = "com.google.guava") // if you don't use it
exclude(group = "org.jetbrains", module = "annotations")
}Common bloat sources:
| Dependency | Typical Size Impact | Alternative |
|---|---|---|
| Guava | 2-3 MB | Kotlin stdlib equivalents |
| Protocol Buffers (full) | 1-2 MB | Protobuf Lite |
| ICU4J | 5+ MB | Android platform ICU |
| Joda-Time | 500 KB | java.time (desugared) |
CI Enforcement
// Custom Gradle task to enforce size budget
tasks.register("checkApkSize") {
dependsOn("assembleRelease")
doLast {
val apk = file("build/outputs/apk/release/app-release.apk")
val sizeMb = apk.length() / (1024.0 * 1024.0)
val budgetMb = 25.0
if (sizeMb > budgetMb) {
throw GradleException(
"APK size ${String.format("%.2f", sizeMb)}MB exceeds budget of ${budgetMb}MB"
)
}
println("APK size: ${String.format("%.2f", sizeMb)}MB (budget: ${budgetMb}MB)")
}
}Trade-offs
| Technique | Size Reduction | Risk |
|---|---|---|
| R8 full mode | 10-20% of DEX | Library incompatibility |
| Resource shrinking | 5-15% of resources | Dynamic resource access breaks |
| WebP conversion | 20-30% of images | API 17+ only (universal now) |
| ABI filtering | 30-50% of native libs | Must not drop supported ABIs |
| Dynamic feature delivery | Variable | UX complexity for on-demand install |
| Dependency exclusion | Variable | Missing functionality if used transitively |
Failure Modes
- R8 removes a class used via reflection: app crashes in production. Always test release builds end-to-end. Maintain keep rules alongside the code that requires them.
- Resource shrinking removes a dynamically loaded resource: screens show missing images or crash. Use
keep.xmlfor dynamic resources. - ABI filter too aggressive: app does not install on older 32-bit-only devices. Verify device coverage data before excluding ABIs.
- Feature delivery fails: user has no connectivity when on-demand module is needed. Always implement graceful fallback for dynamic features.
Scaling Considerations
- Assign per-module size budgets. A team adding a 5MB dependency to a shared module affects every user.
- Track size attribution per module across releases.
- Consider moving to App Bundles if still shipping universal APKs. The migration alone yields 15-20% download size reduction.
- Use
bundletoolin CI to measure the actual download size for representative device configurations.
Observability
- Track APK download size per release in a dashboard
- Monitor install conversion rates correlated with size changes
- Alert when any single PR increases APK size by more than 500KB
- Generate per-module size reports in CI for team accountability
Key Takeaways
- Measure before optimizing. APK Analyzer shows exactly where the bytes are.
- R8 with full mode and resource shrinking is the highest-leverage change.
- Native libraries are often the largest component. ABI filtering and dynamic delivery address this directly.
- Audit transitive dependencies regularly. A single library can add megabytes of unused code.
- Enforce size budgets in CI. Without automated checks, size creeps up with every release.
- App Bundles with per-device delivery are table stakes. If you are shipping universal APKs, migrate first.
Further Reading
- How I Profile Android Apps in Production: Techniques for collecting meaningful performance data from production Android apps without degrading user experience, covering sampling s...
- Optimizing RecyclerView vs Compose Lists: A side-by-side comparison of RecyclerView and LazyColumn performance characteristics, optimization strategies, and trade-offs for product...
- How Garbage Collection Impacts Android Performance: A detailed look at ART's garbage collection mechanisms, how GC pauses affect frame rates, and practical strategies to minimize GC impact ...
Final Thoughts
APK size is a feature. It affects acquisition, retention, and user experience on constrained devices. Treat it as a first-class metric with budgets, monitoring, and CI enforcement. The techniques are well-understood. The discipline of applying them consistently is what separates apps that grow from those that stall.
Recommended
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.
Understanding ANRs: Detection, Root Causes, and Fixes
A systematic look at Application Not Responding errors on Android, covering the detection mechanism, common root causes in production, and concrete strategies to fix and prevent them.