Debugging R8 Release-Only Crashes Without Disabling Optimization
A systematic way to diagnose Android crashes that appear only after R8 optimization, from retracing stack traces to writing the narrowest keep rule that fixes the real boundary.
Table of Contents11 sections
A debug build works. The release build installs. Then one screen crashes only after minification is enabled.
The tempting fix is immediate:
-keep class com.example.** { *; }
Or worse, turn R8 off and ship.
Both can make the symptom disappear. Neither tells you what broke.
A release-only R8 failure is usually easier to solve when you treat it as an indirect-reference debugging problem, not a ProGuard guessing contest. The goal is to identify the runtime contract that static analysis cannot see, preserve exactly that contract, and keep the rest of the optimizer working.
First Prove That Optimization Is the Boundary
Do not begin by editing keep rules.
Reproduce the failure with the same release variant that users receive. That matters because build type, flavor, dependency graph, resource shrinking, signing configuration, and generated code can all differ from debug.
A useful first comparison is:
Debug build → works
Release, R8 on → fails
Release, R8 off → works
That strongly points toward optimization, shrinking, obfuscation, or a rule consumed from a dependency. It still does not prove which class needs to be kept.
If your release pipeline is already automated, this is also why Android CI should build the real release variants, not only compile debug APKs.
Retrace Before You Read the Crash
An obfuscated stack trace is evidence with its labels removed.
R8 produces mapping.txt for the optimized variant. Preserve the mapping file for every published build, because a later build can overwrite the local copy. Android’s documentation recommends using retrace when the original stack trace is not automatically deobfuscated.
A manual workflow looks like this:
$ANDROID_HOME/cmdline-tools/latest/bin/retrace \
app/build/outputs/mapping/release/mapping.txt \
trace.txt
The important question after retracing is not simply, “Which class crashed?”
Ask:
What runtime mechanism expected this class, method, field, constructor, annotation, or name to remain discoverable?
That question usually gets you much closer to the missing rule.
Look for Indirect References
R8 is very good at following static references. Problems appear when the program reaches code in ways that static analysis cannot fully infer.
Common boundaries include reflection, classes loaded from string names, JNI, serialization frameworks that inspect members, and libraries that discover implementations dynamically.
Android’s R8 guidance specifically calls out reflection-related failures such as ClassNotFoundException, NoSuchMethodException, NoSuchFieldException, NoClassDefFoundError, NoSuchMethodError, and NoSuchFieldError as useful signals.
Suppose an integration does this:
val clazz = Class.forName(className)
val instance = clazz.getDeclaredConstructor().newInstance()
To the runtime, that class is required.
To a static optimizer, a string containing a class name is not necessarily a normal code reference. If nothing else reaches the class, removing or renaming it may be perfectly logical from R8’s perspective.
The bug is therefore not “R8 randomly deleted my code.” The real issue is that a runtime dependency was invisible to static analysis.
Inspect the Rules R8 Actually Received
Your proguard-rules.pro is not the whole configuration.
Rules can come from the application, Android tooling, and consumer rules packaged by dependencies. Android’s troubleshooting documentation points to the merged R8 configuration under the build outputs, and the Android Developers Blog also recommends printing the final configuration when you need to understand where a rule came from.
That changes the investigation from:
I think this is my R8 configuration.
into:
This is the configuration R8 actually evaluated.
This is especially useful when optimization is weaker than expected. A dependency can bring broad rules or global options that keep far more code than you intended.
Use -whyareyoukeeping in the Opposite Case
Not every R8 problem is missing code.
Sometimes the APK remains unexpectedly large because a class you expected R8 to remove is still present. The -whyareyoukeeping diagnostic rule asks R8 to show the reference chain responsible for retaining that code.
-whyareyoukeeping class com.example.feature.LegacyEntryPoint
Use it as a debugging instrument, not permanent configuration. Android explicitly cautions against checking it into the codebase because it can slow builds.
This gives you two complementary investigations:
Runtime crash
↓
What required code did R8 fail to see?
Unexpected retained code
↓
Why does R8 believe this code is reachable?
Both are better than adding wildcards until the build behaves.
Write the Narrowest Rule That Expresses the Runtime Contract
A broad rule is useful as a temporary experiment.
If keeping an entire package makes the crash disappear, you have learned that the missing contract probably lives inside that package. That is diagnostic progress, but it should not automatically become the final fix.
Android recommends keep rules that are as specific as possible and warns against long-lived package-wide rules such as:
-keep class com.example.feature.** { *; }
Instead, describe the actual boundary.
If only implementations of an interface are dynamically loaded, preserve those implementations and only the constructor the loader needs:
-keep class * implements com.example.runtime.StartupTask {
<init>();
}
If reflection requires a particular member, target the member rather than freezing the entire class.
The best keep rule is not the shortest rule. It is the rule whose scope matches the runtime behavior you can explain.
Prefer Annotations When the Contract Belongs to Your Code
When your own architecture intentionally exposes code to reflection or discovery, annotations can make the boundary explicit.
Instead of maintaining a fragile list of class names, mark the code that participates in the runtime contract and write a rule against that annotation. Android’s keep-rule guidance recommends this pattern because the relationship between source code and preservation becomes visible.
That is a maintainability improvement as much as an optimization improvement.
A future developer can answer “Why is this kept?” from the source instead of archaeology in a large rules file.
Do Not Test the Fix Only by Launching the App
A release build that reaches the home screen proves very little.
The verification should exercise the behavior that crossed the invisible boundary:
- deserialize the affected model;
- open the screen that instantiates the class dynamically;
- run the JNI call;
- execute the deep link or navigation route;
- test the flavor where the dependency is actually included.
Then inspect the optimized artifact again.
If you fixed a crash with a narrower rule, verify both sides of the trade-off: the feature still works and R8 is still allowed to optimize unrelated code.
For multi-flavor apps, this becomes even more important because a rule that appears correct in one variant can hide a dependency-specific problem in another. The same principle applies when reviewing technical feedback across Android flavors: verify the claim against the exact variant and dependency boundary involved.
A Repeatable R8 Debugging Loop
The process can be reduced to a small loop:
Reproduce the release-only failure
↓
Retrace with the matching mapping file
↓
Identify the runtime boundary
↓
Inspect merged R8 configuration
↓
Test a temporary isolation rule
↓
Replace it with the narrowest valid rule
↓
Exercise the affected release behavior
↓
Verify optimization still works elsewhere
The key step is the one developers often skip: replace the temporary broad rule.
A wildcard can be a good diagnostic switch. It is rarely a good explanation.
Treat Keep Rules as Architecture Documentation
R8 failures feel mysterious when the rules file becomes a collection of copied incantations.
They become much more predictable when every rule answers three questions:
- What code is reached indirectly?
- Which runtime mechanism reaches it?
- What is the smallest surface that mechanism requires?
Once you can answer those, the rule stops being a workaround. It becomes documentation for a boundary in your architecture.
That is the standard I would use before shipping an R8 fix: not merely “the release build no longer crashes,” but “we know why the optimizer could not see this dependency, and the rule preserves exactly what runtime needs.”
Continue Exploring
You Might Also Like
Shipping Android Beyond One Store Without Multiplying Release Work
A practical architecture for distributing one Android product across multiple app stores without turning every release into a manual fork.

Unit Testing Bottom Bar Navigation Logic in Android
Learn how to effectively unit test bottom bar navigation logic in Android applications by extracting decision logic into pure Kotlin helper classes.

Automating Android CI Workflows with GitHub Actions and Gradle
A practical technical guide for setting up a reproducible Android CI workflow using GitHub Actions and Gradle, focusing on caching strategies, test execution boundaries, and failure diagnosis.