The bug that only exists in production
You've been developing your Flutter app for weeks. Debug mode works perfectly — every screen, every API call, every edge case. You run flutter build apk --release, install it on a device, and the app crashes on launch. No changes to your code. No new dependencies. It just dies.
You switch back to debug mode. Works fine. Release mode. Crash. Debug. Fine. Release. Crash.
If this has happened to you, there's a good chance you've met R8 — and you didn't know it.
What happens when you build for release
When you run a debug build, Flutter compiles your Dart code and packages it with the Android runtime mostly as-is. Everything keeps its real name. Every class, every method, every field — exactly as you wrote it.
Release builds are different. After your Dart code is compiled, Gradle runs a tool called R8 on the Java and Kotlin layer of your app. R8 is Google's replacement for an older tool called ProGuard — same idea, newer implementation. The name "ProGuard" stuck in the community, so you'll see both names used interchangeably. When someone says "add ProGuard rules," they mean R8 rules. Same file, same syntax.
R8 does three things to your app's Java/Kotlin code:
Shrinking. R8 traces through your code starting from known entry points and finds every class and method that's actually reachable. Everything else — unused utility classes, methods you imported but never called, entire libraries you added but only used one function from — gets removed. Gone from the APK entirely.
Obfuscation. Every surviving class and method gets renamed to the shortest possible name. UserAuthenticationManager becomes a. validateCredentials() becomes b(). NetworkResponseHandler becomes c. The code does the same thing — it's just unreadable, smaller, and slightly faster to load.
Optimization. R8 inlines small methods, removes dead branches, simplifies control flow. If a method just calls another method, R8 can collapse them into one. If an if condition is always true, R8 removes the branch.
The result: your release APK can be significantly smaller than your debug APK. Methods load faster. The attack surface for reverse engineering is smaller. This is genuinely good engineering.
So where does it break?
The problem: code that finds other code by name
R8's shrinking and obfuscation work perfectly when all the connections between classes are visible at compile time. Java calls Java, everything is traceable, R8 can see the full picture.
The problem appears when something outside the Java world needs to find a Java class by its original name at runtime.
The most common case: JNI (Java Native Interface). This is the bridge between native C/C++ code and the Java/Kotlin layer. When a native library needs to interact with Java, it does something like this in C:
// C code asking the JVM: "find me this class"
jclass cls = (*env)->FindClass(env, "io/objectbox/BoxStore");That's a string lookup. The C code is saying: "I need the class whose full name is io.objectbox.BoxStore." If R8 renamed that class to a.b.c, the lookup fails. The class exists — it's just not called that anymore. The native code can't find it, and your app crashes with a ClassNotFoundException or UnsatisfiedLinkError.
R8 has no way to know that some C code, compiled separately, will ask for this class by string. From R8's perspective, if no Java code references BoxStore directly, it's unused. It gets removed or renamed.
This isn't theoretical. This is the exact failure mode that hit ObjectBox, Realm, SQLCipher, and dozens of other libraries that use native code behind the scenes. The app works in debug (no R8). The app crashes in release (R8 renamed or removed the classes the native code needs).
The fix: keep rules
ProGuard/R8 rules are instructions that tell R8: "don't touch these." They live in a file called proguard-rules.pro in your Android project.
The syntax is straightforward:
# Keep this entire class — don't rename it, don't remove it
-keep class io.objectbox.BoxStore { *; }
# Keep everything in this package
-keep class io.objectbox.** { *; }
# Keep classes that implement this interface
-keep class * implements io.objectbox.ModelBuilder { *; }The -keep directive is the core instruction. It says: "this class must survive R8 unchanged — same name, same methods, same fields." There are variations (-keepnames keeps the name but allows shrinking unused members, -keepclassmembers keeps members but allows renaming the class), but -keep is the blunt instrument that solves most problems.
You can also keep things based on annotations:
# Keep anything annotated with @Keep
-keep @androidx.annotation.Keep class * { *; }Or prevent warnings from specific packages:
# Don't warn about missing references in this library
-dontwarn org.some.legacy.library.**Each rule is a line in proguard-rules.pro. R8 reads them all before it starts shrinking. Anything matching a keep rule is excluded from removal and obfuscation.
Why you almost never have to write these yourself
Here's the part that makes this elegant: Android libraries can bundle their own ProGuard rules.
When a library is packaged as an AAR (Android Archive — the compiled format for Android libraries), it can include a file called proguard.txt or consumer-proguard-rules.pro inside the archive. When Gradle builds your app and includes that library, it automatically merges the library's rules with yours.
This means the library author — who knows exactly which classes the native code needs — writes the keep rules once, ships them with the library, and every app that uses the library gets the right rules without the developer doing anything.
This is why, in 2026, most Flutter developers never touch proguard-rules.pro. The libraries that need rules ship their own. objectbox_flutter_libs bundles rules for its JNI bridge. flutter_secure_storage bundles rules for its crypto classes. Firebase bundles rules. Google Maps bundles rules. It just works.
Five years ago, this was less reliable. Libraries didn't always bundle rules, documentation was patchy, and developers had to manually add keep rules by trial and error — crash, add a rule, rebuild, crash on something else, add another rule. Stack Overflow from that era is full of "add these 15 lines to your proguard-rules.pro." Most of that advice is now outdated, because the libraries themselves handle it.
When you might still need custom rules
There are a few scenarios where you'll need to write your own rules:
Reflection. If your Java/Kotlin code uses reflection to find classes by name — Class.forName("com.myapp.SomeClass") — R8 can't trace that. You need to keep the reflected classes manually.
Serialization libraries. Some JSON serialization libraries (like Gson) inspect field names at runtime. If R8 renames your fields, the JSON keys no longer match. The fix is either a keep rule for your model classes, or using @SerializedName annotations (which Gson respects even after obfuscation).
Platform channels with method names. If your Flutter plugin uses platform channels and the Kotlin side matches method names by string, those method-bearing classes need to survive obfuscation. Most well-maintained plugins handle this, but if you're writing your own, this is on you.
Legacy or unmaintained libraries. If a library doesn't bundle its own rules and the native code needs specific classes, you're back to the manual approach. The library's README or GitHub issues are usually where you find the required rules.
For all of these, the process is the same: add the rules to android/app/proguard-rules.pro:
# In android/app/proguard-rules.pro
# Example: keep model classes for Gson reflection
-keep class com.myapp.models.** { *; }
# Example: keep a platform channel handler
-keep class com.myapp.MyMethodCallHandler { *; }How to debug R8 problems
When your release build crashes but debug works, here's the diagnostic path:
Step 1: Confirm it's R8. Temporarily disable R8 in android/app/build.gradle:
buildTypes {
release {
minifyEnabled false // disables R8 — for diagnosis only
shrinkResources false
}
}If the release build works with R8 disabled, you've confirmed the problem.
Step 2: Find what's missing. Re-enable R8 and add this to proguard-rules.pro:
# Print everything R8 removes — check build/outputs for the report
-printusage usage.txtThis generates a file listing every class and method R8 removed. Search it for the class name from your crash log.
Step 3: Add a targeted keep rule. Keep only what's needed — not everything. A broad -keep class ** { *; } defeats the entire purpose of R8.
Step 4: Remove the diagnostic flags once you've fixed it. -printusage adds overhead to the build.
The mapping file: reading obfuscated crash logs
Once R8 renames your classes, crash stack traces become unreadable:
java.lang.NullPointerException
at a.b.c(Unknown Source:12)
at d.e.f(Unknown Source:3)R8 produces a mapping file (build/app/outputs/mapping/release/mapping.txt) that maps obfuscated names back to originals. Upload this file to your crash reporting tool — Firebase Crashlytics, Sentry, and Bugsnag all accept it — and your stack traces become readable again.
# mapping.txt (partial)
com.myapp.UserAuthenticationManager -> a.b:
void validateCredentials(java.lang.String) -> cWithout the mapping file, production crashes are puzzles with no pieces. Keep it. Upload it. Automate the upload in your CI pipeline.
What R8 means for Flutter specifically
Most of what R8 touches is the Java/Kotlin layer underneath your Flutter app — the Android embedding, platform plugins, and any native libraries. Your Dart code is compiled separately by the Dart AOT compiler and isn't affected by R8.
But R8 still matters because Flutter apps aren't pure Dart. Every plugin with native Android code — camera, maps, payments, biometrics, local databases — has a Java/Kotlin component that R8 processes. If any of those components need classes preserved for JNI, reflection, or serialization, R8 rules are what prevent the release build from breaking.
The good news: this is a solved problem for the Flutter ecosystem. The plugin system, the AAR bundling, and the rule merging pipeline all work together to make R8 invisible to most developers. You benefit from smaller APKs and obfuscated code without writing a single rule.
The only reason to know about R8 is so that when something does break — and in a long enough career, something will — you know where to look and what to do about it.