Reducing APK Size Without Breaking Features

Dhruval Dhameliya·October 12, 2025·8 min read

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.

ComponentTypical % of APKPrimary Reduction Strategy
DEX (code)30-40%R8 shrinking, removing unused dependencies
Resources20-30%Resource shrinking, WebP conversion, density splits
Native libraries (.so)15-30%ABI splits, dynamic delivery
Assets5-15%Compression, on-demand download
META-INF, other5-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 -20

R8 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=true

Full 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
        }
    }
}
ABIDevice CoverageTypical Library Size
arm64-v8a~85% of active devicesBaseline
armeabi-v7a~15% of active devicesUsually smaller
x86_64Emulators onlyRemove for production
x86Emulators onlyRemove 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:

DependencyTypical Size ImpactAlternative
Guava2-3 MBKotlin stdlib equivalents
Protocol Buffers (full)1-2 MBProtobuf Lite
ICU4J5+ MBAndroid platform ICU
Joda-Time500 KBjava.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

TechniqueSize ReductionRisk
R8 full mode10-20% of DEXLibrary incompatibility
Resource shrinking5-15% of resourcesDynamic resource access breaks
WebP conversion20-30% of imagesAPI 17+ only (universal now)
ABI filtering30-50% of native libsMust not drop supported ABIs
Dynamic feature deliveryVariableUX complexity for on-demand install
Dependency exclusionVariableMissing 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.xml for 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 bundletool in 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

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