App Size Optimization for Android and iOS
App size optimization directly impacts user acquisition and retention. Industry research consistently shows that for every 6MB increase in download size, install conversion rates drop by roughly 1%. On Android, apps over 150MB cannot be downloaded over mobile data in many markets without explicit confirmation. On iOS, binaries over 200MB historically required WiFi for cellular downloads, though Apple has raised this ceiling over time. Smaller binaries mean more installs, fewer uninstalls, faster updates, and lower data costs for users on metered connections.
This guide covers practical strategies for both platforms, from code shrinking and asset optimization to dynamic delivery and automated size budgets. Applied together, these techniques routinely reduce app sizes by 40-70% without sacrificing functionality. Crucially, the work pays off most when it is continuous: size creeps back the moment a team stops watching it, so the goal is a sustainable process rather than a one-time cleanup.
Android: R8 Code Shrinking
R8 is Android’s default code shrinker, optimizer, and obfuscator. It removes unused classes and methods, inlines small functions, merges classes, and reduces DEX file size. Moreover, R8’s full mode (enabled by default in modern Android Gradle Plugin versions) performs more aggressive whole-program optimization than the legacy ProGuard it replaced.
// build.gradle.kts
android {
buildTypes {
release {
isMinifyEnabled = true // Enable R8
isShrinkResources = true // Remove unused resources
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
// Enable Android App Bundle (AAB)
bundle {
language {
enableSplit = true // Separate APKs per language
}
density {
enableSplit = true // Separate APKs per screen density
}
abi {
enableSplit = true // Separate APKs per CPU architecture
}
}
}
# proguard-rules.pro — Keep rules for common libraries
-keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# Retrofit
-keepattributes Signature
-keep class retrofit2.** { *; }
-keepclassmembers,allowobfuscation interface * {
@retrofit2.http.* ;
}
# Kotlin serialization
-keepattributes InnerClasses
-keep,includedescriptorclasses class
com.example.**$serializer { *; }
-keepclassmembers class com.example.** {
*** Companion;
}
# Room database
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**
Android: Dynamic Feature Modules
Dynamic feature modules let you deliver features on-demand instead of bundling everything in the initial download. Therefore, the base APK stays small while users fetch heavyweight features such as a document scanner, AR mode, or onboarding tutorial only when they actually open them.
// In dynamic feature module's build.gradle.kts
plugins {
id("com.android.dynamic-feature")
}
android {
namespace = "com.example.feature.scanner"
}
dependencies {
implementation(project(":app"))
implementation("com.google.android.play:feature-delivery-ktx:2.1.0")
}
// Request feature installation at runtime
class FeatureManager(private val context: Context) {
private val splitInstallManager =
SplitInstallManagerFactory.create(context)
suspend fun installScanner(): Result<Unit> {
val request = SplitInstallRequest.newBuilder()
.addModule("scanner")
.build()
return suspendCancellableCoroutine { cont ->
splitInstallManager.startInstall(request)
.addOnSuccessListener {
cont.resume(Result.success(Unit))
}
.addOnFailureListener { exception ->
cont.resume(Result.failure(exception))
}
}
}
fun isInstalled(moduleName: String): Boolean {
return splitInstallManager.installedModules
.contains(moduleName)
}
}
iOS: App Thinning
iOS app thinning is the umbrella term for three mechanisms: slicing, on-demand resources, and (historically) bitcode. Slicing means the App Store delivers a variant tailored to each device, so an iPhone with a 3x display never downloads the 1x and 2x image variants it cannot use. Asset catalogs are the key enabler here, since Xcode automatically generates and tags the per-device variants.
// Asset Catalog optimization
// Use asset catalogs instead of loose files
// Xcode automatically generates 1x, 2x, 3x variants
// On-Demand Resources (ODR) for large assets
// Tag resources in Xcode → Build Settings → On Demand Resources
class ResourceManager {
func loadLevel(named tag: String) async throws -> Bool {
let request = NSBundleResourceRequest(tags: [tag])
// Check if already downloaded
if request.conditionallyBeginAccessingResources(
completionHandler: { available in
if available {
// Resources already cached
}
})
{
return true
}
// Download on-demand
try await request.beginAccessingResources()
return true
}
func freeResources(tag: String) {
let request = NSBundleResourceRequest(tags: [tag])
request.endAccessingResources()
}
}
# Analyze iOS app size
xcrun bitcode-strip MyApp -r -o MyApp_stripped
# Check app thinning report
xcodebuild -exportArchive \
-archivePath MyApp.xcarchive \
-exportPath export \
-exportOptionsPlist ExportOptions.plist \
-exportThinning UniversalApps
Asset Optimization Strategies
Images and other media often account for 50-70% of an app’s bundle. Consequently, optimizing assets provides the biggest absolute savings, frequently exceeding everything code shrinking can achieve. The two highest-leverage moves are switching raster formats (PNG to WebP on Android, HEIC for photos on iOS) and replacing icons and simple illustrations with vectors that scale to any density from a single small file.
# Android: Convert PNGs to WebP (50-70% smaller)
# In Android Studio: Right-click → Convert to WebP
# iOS: Use HEIC for photos, asset catalogs for icons
# Xcode automatically compresses catalog assets
# Both platforms: SVG for vector graphics
# Android: VectorDrawable (XML)
# iOS: PDF vectors in asset catalogs
# Image compression pipeline (CI/CD)
# Install: npm install -g sharp-cli imagemin-cli
# Compress PNGs
find app/src/main/res -name "*.png" -exec \
pngquant --quality=65-80 --skip-if-larger --force \
--output {} {} \;
# Convert to WebP
find app/src/main/res -name "*.png" -exec \
cwebp -q 80 {} -o {}.webp \;
Size Impact by Optimization Type
┌────────────────────────────┬──────────┬──────────┐
│ Optimization │ Android │ iOS │
├────────────────────────────┼──────────┼──────────┤
│ R8/Bitcode code shrinking │ -15-25% │ -10-15% │
│ Resource shrinking │ -5-10% │ N/A │
│ Image → WebP/HEIC │ -20-40% │ -15-30% │
│ App Bundle/Thinning │ -30-50% │ -20-30% │
│ Dynamic delivery/ODR │ -10-40% │ -10-30% │
│ Remove unused libraries │ -5-15% │ -5-15% │
│ ProGuard dictionary │ -2-5% │ N/A │
│ Native lib stripping │ -5-10% │ -3-8% │
├────────────────────────────┼──────────┼──────────┤
│ Combined (typical) │ -40-70% │ -35-60% │
└────────────────────────────┴──────────┴──────────┘
Auditing Dependencies and Native Libraries
A surprising share of bloat hides in third-party libraries. A single analytics or image-loading SDK can add several megabytes of code, native .so binaries, and transitive dependencies. Therefore, treat dependency review as a first-class part of size work rather than an afterthought.
On Android, run the official APK Analyzer (Build → Analyze APK in Android Studio) or the bundletool CLI to inspect exactly which libraries contribute the most DEX and native bytes. Native libraries deserve special attention: shipping all four ABIs (armeabi-v7a, arm64-v8a, x86, x86_64) when nearly every real device is arm64 wastes space, and App Bundles solve this automatically by serving only the matching ABI split. For libraries you only partially use, prefer a smaller modular artifact over the monolithic one — for example, depending on a single Firebase module instead of the whole platform bundle.
On iOS, the App Thinning Size Report.txt generated during export breaks down compressed and uncompressed sizes per device. Static linking versus dynamic frameworks also matters: too many embedded dynamic frameworks inflate the binary and slow launch, so consolidating them is often a quiet win. As a rule, every dependency should justify its byte cost, and unused ones should be removed entirely rather than merely shrunk.
Compression and On-Disk Versus Download Size
It helps to distinguish three numbers that teams frequently conflate: the download size users see in the store, the compressed bundle you upload, and the installed size on disk. The store always reports the post-compression download for the specific device, which is why the Play Console’s size figures and the App Store Connect thinning report are the numbers that actually correlate with conversion.
Because store delivery already applies compression, pre-compressing assets that are themselves incompressible (already-encoded JPEGs, MP4s, WebP) yields little extra benefit and can even hurt if it disables further system optimization. Conversely, text-heavy resources — JSON, fonts, XML layouts — compress extremely well, so trimming and deduplicating them pays off twice. Fonts are a common offender: shipping a full multi-language font when the app only renders Latin scripts can add a megabyte that font subsetting tools remove in seconds.
Size Budget Monitoring
Prevent regression with automated size budgets enforced in CI. A budget turns size from an occasional cleanup project into a guardrail that fails the build before bloat ever ships, and it makes every pull request author accountable for the bytes they add.
# .github/workflows/size-check.yml
name: App Size Check
on: [pull_request]
jobs:
android-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Build release bundle
run: ./gradlew bundleRelease
- name: Check bundle size
run: |
SIZE=$(stat -f%z app/build/outputs/bundle/release/app-release.aab 2>/dev/null || stat -c%s app/build/outputs/bundle/release/app-release.aab)
MAX_SIZE=20971520 # 20MB budget
echo "Bundle size: $((SIZE / 1024 / 1024))MB"
if [ "$SIZE" -gt "$MAX_SIZE" ]; then
echo "ERROR: Bundle exceeds 20MB budget!"
exit 1
fi
- name: Size diff report
uses: nicklegan/github-repo-size-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
// Android: Runtime size analysis
class SizeAnalyzer(private val context: Context) {
fun getAppSizeInfo(): Map<String, Long> {
val pm = context.packageManager
val appInfo = pm.getApplicationInfo(
context.packageName, 0)
val sourceDir = File(appInfo.sourceDir)
val dataDir = File(appInfo.dataDir)
return mapOf(
"apk_size" to sourceDir.length(),
"data_size" to getDirectorySize(dataDir),
"cache_size" to getDirectorySize(context.cacheDir),
"total" to (sourceDir.length() +
getDirectorySize(dataDir)),
)
}
private fun getDirectorySize(dir: File): Long {
return dir.walkTopDown()
.filter { it.isFile }
.sumOf { it.length() }
}
}
For best results, surface the size diff as a comment on every pull request rather than only failing past a hard ceiling. A small +180KB increase that is visible and explained is easy to accept or push back on; the same increase hidden across twenty merges is how apps silently double in size over a year. Many teams pair the hard budget with a softer warning threshold that nudges reviewers without blocking urgent fixes.
When NOT to Use Aggressive Optimization
Over-aggressive code shrinking can break reflection-based libraries. If your app relies on runtime annotation processing, serialization, or dependency injection, verify your keep rules thoroughly and test the release build — not just debug — before shipping, since R8 only runs on release variants. Many of the most painful production crashes trace back to a missing keep rule that obfuscated a class the framework looked up by name.
Dynamic delivery adds real complexity to testing and QA, because every module combination becomes a separate state to verify, and offline or install-failure paths need graceful handling. For apps already comfortably under 30MB, the engineering effort of splitting features rarely justifies the marginal saving. Likewise, do not chase the last few hundred kilobytes at the cost of code readability or by stripping debugging symbols you need for crash symbolication. Often the cleanest win is simply deleting a rarely-used feature outright rather than engineering an elaborate on-demand delivery path for it.
Key Takeaways
- Smaller binaries reduce install abandonment — every 6MB of growth costs roughly 1% of conversions
- Android App Bundles with density/language/ABI splits cut download size by 30-50% with almost no code change
- Image optimization (WebP/HEIC conversion) and vectors deliver the largest absolute reduction at 20-40%
- Dynamic feature modules and On-Demand Resources defer non-essential content from the initial download
- Dependency and native-library audits often uncover megabytes hiding in a single SDK
- Automated size budgets and per-PR diffs stop gradual regression before it ships
Related Reading
- Jetpack Compose Performance Optimization
- Android Baseline Profiles for Startup Performance
- Flutter Impeller Rendering Engine Guide
- Mobile App Performance Optimization
External Resources
In conclusion, App size optimization is an essential, ongoing discipline rather than a one-time task. By combining code shrinking, asset and dependency audits, platform-native delivery features, and automated budgets, you can build leaner apps that install faster and retain more users. Start with the highest-leverage wins — App Bundles and image formats — then iterate, measure the store-reported download size, and keep a CI guardrail in place so your hard-won savings never silently erode.