5 min read

R8 in Anger: What Actually Shrinks, What Actually Breaks

R8 removes 30–40% of a typical APK and speeds up startup. Almost every 'R8 broke my app' bug traces to reflection. What the three phases do, how to write precise keep rules instead of blanket ones, and how to read usage.txt to find what you broke.

AndroidR8ProGuardPerformanceRelease Engineering
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — R8 shrinks, optimises and obfuscates, in that order. It works from static reachability, so the one thing it reliably breaks is code reached by reflection — Gson field names, Class.forName, Retrofit interfaces. The standard panic response, a blanket -keep class com.myapp.**, disables shrinking for your entire app while looking like a fix. Keep precisely, read usage.txt to see what was removed, and run smoke tests against a release build in CI.

What you're leaving on the table

minifyEnabled true with shrinkResources true typically removes 30–40% of an APK. The size matters for install conversion, but the startup win is the underrated part: less code means less to load, verify and JIT.

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

Note proguard-android-optimize.txt rather than the default — the non-optimising variant disables the optimisation phase, which is a common and silent way to lose half the benefit.

The three phases

Shrinking. R8 builds a reachability graph from your entry points — the manifest, Activity subclasses, anything in a keep rule — and deletes everything unreachable. This is where most of the size win comes from, and most of it is unused library code, not yours.

Optimisation. Inlining, devirtualisation, constant folding, dead-branch removal, class merging. This is what makes R8 meaningfully better than old ProGuard, and it's why a debug build's performance is not a prediction of release performance.

Obfuscation. Renaming to a, b, c. It shrinks the string pool a little; its main effect on your life is making stack traces unreadable unless you keep mapping.txt.

Why it breaks things: reflection

R8 reasons about static references. It cannot see:

Class.forName("com.myapp.plugins.$name")       // constructed at runtime
gson.fromJson(json, UserDto::class.java)       // field names read reflectively
retrofit.create(UserApi::class.java)           // interface resolved at runtime

For the Gson case, R8 sees a class whose fields are never read in code, so it removes the fields or renames them — which is correct given the evidence. At runtime, the JSON key display_name no longer matches the field named a, and you get nulls rather than a crash. Silent data loss is the worst version of this bug.

Keep precisely, not broadly

The reflex fix, and why it's expensive:

# Disables shrinking, optimisation AND obfuscation for your whole app.
-keep class com.myapp.** { *; }

The APK returns to its original size, the crash goes away, and nobody notices the trade for a year. Instead, keep exactly the surface that reflection touches:

# DTOs whose field names are the wire format
-keep class com.myapp.data.api.dto.** { *; }

# Retrofit interfaces (modern Retrofit ships its own rules; verify before adding)
-keep,allowobfuscation interface com.myapp.data.api.*Api

# Enum values() is reflective
-keepclassmembers enum * { public static **[] values(); public static ** valueOf(java.lang.String); }

Better still, put the rule next to the code so it survives a package move:

@Keep
data class UserDto(val id: String, val display_name: String)

And better again: use a compile-time serialiser. kotlinx.serialization generates real code, so R8 can see the field usage and no keep rule is needed. That deletes the whole problem class rather than managing it.

Read what it actually did

After a release build, build/outputs/mapping/release/ contains:

  • usage.txt — everything R8 removed. When something breaks, search this for the class name. It is the single fastest way to confirm "R8 deleted it" versus "my code is wrong".
  • mapping.txt — the obfuscation map. Upload it to Crashlytics (the Gradle plugin does this automatically; verify it) or every release stack trace is noise.
  • seeds.txt — everything your keep rules protected. If this file is enormous, a blanket rule is hiding in your config.
  • configuration.txt — the fully merged rules, including those injected by libraries. This is how you find out a dependency added a keep rule you didn't ask for.

Skimming seeds.txt once a quarter has repeatedly found stale rules for libraries removed long ago.

The habit that prevents all of it

Run smoke tests against a release build in CI. Debug builds don't run R8 at all, so a debug-only test suite structurally cannot catch these bugs — it guarantees your users find them first.

A minimal version pays for itself: build the release variant, install it, launch, log in, hit the three screens that use reflection-heavy libraries. Ten minutes of CI to eliminate the most common category of "only in production" bug.

The mindset

R8 isn't fragile and it isn't magic — it's a static analyser doing exactly what the evidence supports. Every break is a place where your code does something the evidence can't show. Treating it that way turns "R8 broke my app" into a specific, findable question: what did I do at runtime that I never wrote down?

Keep reading