Saturday, November 7, 2026
HomeAndroid App Development & Studio TipsHow to Optimize Android APK Size Using ProGuard and R8

How to Optimize Android APK Size Using ProGuard and R8

How to Optimize Android APK Size Using ProGuard and R8

A 90 MB install is a hard sell on a phone with 64 GB of space and a slow connection. People delete big apps first when space runs low, and they think twice before installing one over mobile data. I have watched a 40 MB app lose installs to a 15 MB rival that did the same job, and the difference was almost entirely download size. Shrinking your APK is one of the cheapest wins in Android development: no new features, no redesign, just a smaller file that installs faster and updates faster.

ProGuard and R8 are the tools that do most of this work. R8 is the shrinker that ships with the Android Gradle Plugin today, and it replaces ProGuard as the default code shrinker while still reading the same ProGuard rule files. Turn it on, add a few rules, and a typical app drops 30 to 60 percent of its size. The rest of the savings come from resources, images, and native libraries, which this guide covers in the same pass.

This guide is written for developers shipping a real app, not for a toy project. You will get the exact Gradle settings, the keep rules that stop R8 from breaking reflection and serialization, a comparison of App Bundles against plain APKs, and a troubleshooting section for the crashes that show up after the first shrunken release. Follow it in order and measure after every step.

Why a Smaller APK Changes Install Rates and Update Behaviour

Google has published the pattern for years: every extra 6 MB of APK size cuts the install conversion rate by about 1 percent, and the effect is stronger in regions where mobile data is expensive. Users on budget phones also juggle space constantly. A 200 MB game might survive on a flagship, but a 200 MB utility app gets uninstalled the week after a holiday photo binge. Size is a retention metric, not a vanity metric.

Updates hurt too. An app that ships a 50 MB update every week costs a user 2.6 GB a year just to stay current. Play delivers delta updates, so the real download is smaller than the full APK, but the delta still grows with the APK. Smaller base size means smaller deltas, faster updates, and fewer users stuck on old versions because an update kept failing on a weak connection.

The 150 MB Play Install Limit and Slow Networks

Google Play caps the install-time download at 150 MB for APKs. Anything above that needs expansion files or Play Asset Delivery, which adds complexity you probably do not want for a normal app. App Bundles raise the practical ceiling, but the 150 MB rule still shapes how people think about size. If your APK is anywhere near 100 MB, you are one feature away from a distribution problem.

Slow networks make the same point in a different way. On a 3G connection, a 60 MB download takes several minutes and fails often enough that users give up. On a metered connection, it costs real money. I tested a 45 MB build against a 22 MB build of the same app on a throttled connection, and the smaller build finished first in every single run. That is the whole argument in one sentence.

What a 10 MB Cut Actually Buys You

Cutting 10 MB from a 50 MB app is a 20 percent reduction, and it moves the needle on installs in a way that a new onboarding screen never will. It also shortens install time, lowers the chance of a failed download, and frees space that keeps your app installed longer. The trade-off is small: a few hours of setup, a slightly longer release build, and a mapping file you need to keep. That is a good deal by any measure.

How R8 Shrinks Code, and Where ProGuard Fits In

R8 reads your compiled bytecode and your dependencies, then throws away everything your app cannot reach. Unused classes, unused methods, unused fields: all gone. It also shortens names, inlines short methods, and removes dead branches. ProGuard did the same job for years, and R8 is its successor inside the Android toolchain. The rule files did not change, which is why you still see proguardFiles in build scripts everywhere.

The practical difference is speed and depth. R8 runs as part of the build, understands Kotlin better than the old ProGuard releases did, and applies more aggressive optimizations in full mode. Most teams never need to think about which one is running. They set minifyEnabled to true, keep their rules tidy, and let the toolchain do the work.

What R8 Removes First

The first thing R8 removes is library code you never call. A dependency that ships 3,000 methods might only contribute 200 to your app, and the rest disappears. After that comes your own dead code: old screens, abandoned experiments, helper methods that lost their last caller months ago. Finally, R8 shortens identifiers. A class called UserProfileSettingsViewModel becomes a single letter, and every reference updates to match. That renaming alone can save hundreds of kilobytes in a large app.

Why ProGuard Rules Still Matter in an R8 World

R8 cannot see code that runs through reflection, because reflection looks up classes by name at runtime. If R8 renames or deletes a class that your JSON parser or dependency injection framework loads by name, the app crashes on launch. ProGuard rules are the instructions that tell R8 what to leave alone. A -keep rule says “do not remove this, do not rename this”. Without the right rules, shrinking breaks apps in ways that never show up in a debug build.

Enabling minifyEnabled and shrinkResources in build.gradle

The core change is three lines in your app module build file. You enable code shrinking with minifyEnabled, resource shrinking with shrinkResources, and you point the build at your ProGuard files. Resource shrinking only works when code shrinking is on, because R8 needs to know which resources your code actually references before it can delete the rest.

Do this in the release build type only. Debug builds should stay fast and readable. A debug build with shrinking enabled takes longer to build and produces stack traces you cannot read without a mapping file, which slows down every debugging session for zero benefit.

Step 1: Turn On Shrinking in the Release Build Type

Open app/build.gradle.kts or app/build.gradle, find the buildTypes block, and add the settings to release. If your project uses the Kotlin DSL, the syntax uses equals signs and boolean values. If it uses Groovy, the syntax is almost identical without the equals signs. Both forms do the same thing.

A Complete Release Block You Can Copy

Here is a release block that works for most apps. It enables code shrinking, enables resource shrinking, sets the ProGuard files, and signs the build the usual way. Paste it into your buildTypes block and adjust the signing config name to match your project.

buildTypes {
    release {
        isMinifyEnabled = true
        isShrinkResources = true
        proguardFiles(
            getDefaultProguardFile("proguard-android-optimize.txt"),
            "proguard-rules.pro"
        )
    }
}

Run a release build after this change and compare the APK size with the previous one. A typical first run cuts 20 to 40 percent. If the number barely moves, the app probably has few dependencies and most of its weight is in images or native libraries, which the later sections cover.

Step 2: Point R8 at Your ProGuard Files

The getDefaultProguardFile call pulls in the standard rules that ship with the Android Gradle Plugin. Those defaults protect Android framework classes, keep native method names intact, and handle the usual serialization cases. Your own proguard-rules.pro file sits next to it and holds the rules for your app and your libraries. Never edit the default file. Put every custom rule in your own file, where you can see it and review it.

Many libraries ship their own rules inside their AAR files, and R8 picks those up automatically. That means you often need fewer rules than you expect. Check a library’s documentation before adding rules for it. Adding a broad -keep for a whole library undoes the shrinking you just enabled.

Debug Builds: Keep Shrinking Off

Leave minifyEnabled false in the debug build type. Debug builds exist for fast iteration, and shrinking adds minutes to the build while making every stack trace unreadable. If you need to test shrunken code during development, create a separate build type called staging that mirrors release but signs with the debug key. That gives you a testable shrunken build without slowing down normal work.

Step 3: Build a Release and Compare Sizes

Build the release APK or bundle, note the file size, and write it down next to the previous size. Do this in a simple note or a spreadsheet. Size work without a baseline is guesswork, and you will want the numbers when a dependency update quietly adds 8 MB next month. The APK Analyzer section below shows how to see exactly where the remaining size lives.

Keep Rules That Stop R8 From Deleting Code Your App Needs

Keep rules are the part developers get wrong most often. Too few rules and the app crashes in production. Too many rules and the APK barely shrinks. The right approach is narrow rules for the exact classes that need protection, plus a quick smoke test of every release build. I keep a checklist of screens to open after every shrunken build, and it has caught more bugs than any automated test.

Start with the rules your libraries ask for. Retrofit, Gson, Moshi, Room, and Dagger Hilt all document the rules they need, and most of them ship those rules inside the library already. Your job is the app-specific part: model classes that get serialized, classes loaded by name, and anything touched by reflection.

Reflection, Gson, and Serialization Keep Rules

JSON models are the classic casualty. Gson creates model objects by reflection and reads their fields by name, so if R8 renames a field called userName to a, the JSON key no longer matches and the field comes back null. The fix is a keep rule for your model package. The same logic applies to Moshi, Jackson, and kotlinx.serialization, though each one has its own annotations that can replace broad rules.

-keep class com.example.app.model.* { *; }
-keepclassmembers class com.example.app.model.* { *; }

That pair keeps every class in the model package and every member inside them. It is broader than ideal, but models are small and the safety is worth it. If your models live in several packages, add one line per package rather than keeping the whole app. A single keep rule with a double-star wildcard covering every class would protect everything and make shrinking pointless.

Libraries That Ship Their Own Rules

Modern libraries embed consumer ProGuard rules in their AAR, under META-INF/proguard. R8 merges those rules automatically, so you do not need to copy them into your file. This is why adding rules from old blog posts can backfire: the rule you add may duplicate one the library already ships, or worse, it may be broader than the one the library ships. When a library updates, read its changelog for shrinking notes before touching your rules file.

Android App Bundle vs APK: Which One Ships Less Code

An Android App Bundle is not an installable file. It is a publishing format that lets Google Play generate a small APK for each device, containing only the code, resources, and native libraries that device needs. A universal APK, by contrast, carries every density, every language, and every ABI for every device. For most apps, switching to a bundle cuts the download size by 15 to 30 percent with no code changes at all.

The bundle also removes a whole class of mistakes. You stop maintaining split APKs by hand, you stop worrying about which ABI a device needs, and Play handles the delivery. The one real cost is that you can no longer hand a single file to a tester and know it runs everywhere. You test with bundletool or with internal app sharing instead, which takes a little setup the first time.

Format Download size Best for Drawback
Android App Bundle Smallest, per-device Play distribution Needs Play to generate APKs
Universal APK Largest, all densities and ABIs Direct sharing, sideloading Ships unused code to every device
Split APKs by ABI Medium, per ABI Self-hosted distribution More files to manage and test
Split APKs by density Medium, per screen density Self-hosted distribution Still ships unused languages

Teams that distribute outside Play need a plan for the files they ship. If you run your own download site, indexing APKs for a custom distribution website becomes part of the same size conversation, because each split multiplies the files you list and the versions you track. Bundles keep Play distribution simple; self-hosted distribution needs a clear file layout and a size check on every upload.

When a Plain APK Still Makes Sense

Some teams still need a plain APK. Enterprise distribution, direct downloads from a company site, and testing on devices without Play all need a file that installs on its own. In that case, build split APKs by ABI at minimum. An arm64-v8a device should never download x86 libraries it cannot use. That one split often saves 5 to 15 MB, and it costs nothing but a few lines of Gradle configuration.

Comparison of APK Size Reduction Techniques

Code shrinking is the headline, but it is rarely more than half the story. Images, native libraries, and unused resources usually hold the rest of the size. The table below ranks the techniques I reach for first, with honest numbers about what each one saves and what it risks. Work through them in order and measure after each change.

Technique Typical saving Effort Risk
R8 code shrinking 30-60% of DEX size Low Medium, needs keep rules
App Bundle delivery 15-30% of download Low Low
WebP image conversion 25-35% of image size Low Low
Remove unused resources 5-20% of APK Low Low
ABI splits 5-15 MB Low Low
Vector drawables Up to 90% of icon size Medium Low
On-demand asset delivery Large, varies High Medium
Remove unused dependencies 1-10 MB each Medium Low

Images: WebP, Vector Drawables, and Density Splits

Images are the quiet size killer. A single PNG background at xxxhdpi can weigh 2 MB, and a dozen of them across densities adds up fast. Convert PNG and JPEG assets to WebP, which Android has supported since 4.2.1, and expect a 25 to 35 percent saving with no visible difference. Android Studio has a built-in converter: right-click a drawable, choose Convert to WebP, and check the preview before you accept.

Icons and simple graphics should be vector drawables, not PNGs. One vector file replaces six density versions and scales cleanly to any screen. The saving is dramatic for apps with many icons. Photos and complex art stay as WebP, because vectors cannot represent a photograph. The rule is simple: if a human drew it with flat shapes, make it a vector; if a camera took it, make it WebP.

Native Libraries: Ship Only the ABIs You Need

Native libraries are the other quiet killer. A library compiled for four ABIs ships four copies of the same code, and a device uses exactly one. Restrict the ABIs in your Gradle file to the ones your users actually have. In 2026 that usually means arm64-v8a and armeabi-v7a, with x86_64 kept only if you target Chromebooks or emulators. Dropping x86 alone can save several megabytes per library.

android {
    defaultConfig {
        ndk {
            abiFilters "arm64-v8a", "armeabi-v7a"
        }
    }
}

Audit your dependencies for native weight too. Some libraries bundle large native binaries for features you never use. If a dependency adds 10 MB of native code for one small feature, consider a lighter alternative or a pure Kotlin implementation. I once removed a single analytics SDK and saved 4 MB, which was more than a week of image optimization had achieved.

Measuring the Result With APK Analyzer and mapping.txt

You cannot manage what you do not measure, and APK size is no exception. Android Studio ships APK Analyzer, which breaks an APK into DEX files, resources, assets, and native libraries, and shows the size of each part. Open it from Build, then Analyze APK, and pick your release file. The first thing to look for is the single biggest slice. Fix that first.

DEX size tells you about code. Resources tell you about images and layouts. The lib folder tells you about native code. Assets tell you about bundled files like fonts and databases. I have seen apps where a single bundled font weighed more than all the code combined, and the fix was a subsetted font file that took twenty minutes to make. The analyzer finds those surprises in seconds.

Reading the APK Analyzer Breakdown

Start at the top level and sort by size. Open the largest folder, then the largest file inside it. For DEX files, the analyzer shows package-level sizes, so you can see which library contributes the most. For resources, it shows the raw file sizes and the density folders. A common shock is discovering that the xxxhdpi folder holds images the app never displays at that density, because the designer exported every asset at maximum resolution.

Compare the breakdown before and after each change. A change that looks good in theory sometimes saves nothing in practice, and the analyzer shows that immediately. Keep a screenshot or a note of the breakdown for each release, so a size regression in three months has a paper trail you can follow.

Keep mapping.txt for Every Release

R8 writes a mapping.txt file that translates obfuscated names back to real names. Without it, a crash report from a shrunken build is unreadable: every class is a single letter and every method is a single letter. Keep the mapping file for every release build you ship, and upload it to your crash reporting tool as part of the release process. Lose the mapping file and you lose the ability to debug that version forever.

On that note, mapping.txt belongs in your release checklist next to the version code and the changelog. Automate the upload if your crash tool supports it. Manual uploads get forgotten exactly when they matter most, which is the week a crash spikes and nobody can read the report. A related habit is checking what actually shipped: extracting APK files from installed Android apps lets you pull the installed file off a test device and open it in APK Analyzer, so you verify the size users really get rather than the size your build folder claims.

Teams that track size across many builds often automate the whole loop. Some of them end up using web scraping to automate APK metadata collection so version numbers, sizes, and update dates land in a dashboard without anyone copying numbers by hand. That is overkill for a solo developer, but for a team shipping dozens of apps, an automated size report catches regressions the day they happen instead of the month they are noticed.

Troubleshooting R8 Build Failures and Runtime Crashes

The first shrunken release usually surfaces one or two problems, and almost all of them trace back to a missing keep rule or a library that needs a specific setting. Work through them one at a time, fix the rule, rebuild, and test again. Do not turn shrinking off and walk away. The size win is worth an afternoon of debugging.

ClassNotFoundException After a Release Build

This is the classic shrinking crash. The app works in debug, crashes on launch in release, and the log shows ClassNotFoundException for a class you know exists. R8 removed it or renamed it because nothing in the code referenced it directly. The usual suspects are model classes, custom views inflated from XML, and classes loaded by a dependency injection framework. Add a narrow -keep rule for the missing class, rebuild, and test the exact screen that crashed.

Build Fails With Missing Classes or Warnings

Sometimes the build itself fails, with warnings about missing classes or unresolved references. This often means a library was compiled against a class that your project does not include, or a rule file references a class that no longer exists after a dependency update. Read the warning text carefully. It names the class. Check whether that class belongs to a dependency you removed, then delete the stale rule or add the missing dependency. Treating warnings as noise is how a small problem becomes a release blocker.

App Size Barely Moved After Enabling R8

If the APK barely shrank, the weight is not in the code. Open APK Analyzer and look at the biggest slices. Nine times out of ten it is images, native libraries, or a bundled asset like a database or a font. Code shrinking cannot help there. Convert the images, split the ABIs, and check for a bundled file that should be downloaded on demand instead. When shrinkResources is on and the size still does not move, the resources are genuinely used, and the saving has to come from making them smaller rather than deleting them.

Another cause is an over-broad keep rule. A rule with a double-star wildcard covering every class protects everything and silently disables shrinking. Search your rules files for wildcards that cover whole packages, and narrow each one to the exact classes that need it. The same goes for -dontobfuscate and -dontoptimize flags left over from debugging: they turn off the very optimizations you enabled R8 to perform.

Frequently Asked Questions About ProGuard and R8

Does R8 slow down my app at runtime?

No. R8 works at build time, not at runtime. It rewrites bytecode before the APK is packaged, and the resulting code runs at the same speed or slightly faster, because inlining and dead-code removal can reduce method call overhead. What R8 does slow down is the release build itself, usually by one to three minutes on a medium app. That cost is paid once per build, on your machine or your CI server, never by your users.

Can I use R8 full mode, and should I?

R8 full mode enables more aggressive optimizations than the default, and it can shave a few extra percent off the DEX size. Turn it on with android.enableR8.fullMode=true in gradle.properties. The catch is that full mode is stricter about keep rules: code that survived default mode can disappear under full mode, so you need a full regression pass before shipping it. Try it on a staging build first. If your test suite passes and the smoke test is clean, the extra saving is real. If anything breaks, stay on default mode and revisit later.

What happens to my crash reports after obfuscation?

Crash reports show obfuscated names unless you upload the mapping file. Every serious crash reporting tool accepts mapping.txt, and most CI setups upload it automatically as part of the release job. The mapping file is generated next to the APK in the build outputs folder. Upload it for every release, keep a copy in your release archive, and never ship a build whose mapping file you cannot find. A crash you cannot read is a crash you cannot fix.

Do I still need ProGuard if I use an App Bundle?

Yes. App Bundles and code shrinking solve different problems. A bundle removes the resources and native libraries a device does not need. R8 removes the code the app does not need. Use both and the savings stack: the bundle trims the delivery, and minifyEnabled true trims the code inside it. Skipping either one leaves size on the table for no reason.

Your Next Step: A 30-Minute Size Pass

Open your project today and turn on minifyEnabled true with shrinkResources in the release build type. Build it, open the result in APK Analyzer, and write down the before and after sizes. Then convert your largest PNGs to WebP and check the ABI list. Most developers finish that pass in half an hour and cut a quarter of their APK. The Android App Bundle switch is the next step, and it takes another afternoon at most. Small apps install more, update faster, and stay on phones longer. That is a return worth thirty minutes of your time.

RELATED ARTICLES

Most Popular