It Only Crashes in Release — Tracking Down When R8 Full Mode Broke My Gson Mapping
It works in debug but crashes only in release. This walks through the R8 full-mode pattern that breaks Gson reflection, how to reproduce it locally, deobfuscate the stack trace, and write the smallest keep rules that fix it.
One morning Crashlytics was full of a NullPointerException I'd never seen. The top of the stack trace was obfuscated symbols like a.b.c.d — class names I never wrote. Running a debug build on my own device reproduced nothing. A few hours after release, only users who opened one particular screen were crashing. As an indie developer who has run ad-supported wallpaper apps for years, this "only crashes in release" category of bug has tripped me up more than once.
The cause is almost always in the same place. R8's full-mode obfuscation rewrites the field names of the data classes that Gson reads and writes via reflection, so the mapping to the JSON keys falls apart. The debug build has obfuscation disabled, so it runs fine; only release breaks. That asymmetry is exactly what makes it so hard to reproduce, and why you can burn hours before you find it.
This article is a record of how I tracked the problem down the last time I hit it, and how I ultimately fixed it. "Just keep everything and it goes away" is technically true, but that's abandoning the design decision — you throw away both the obfuscation and the size reduction at the same time. We'll go all the way to protecting only the broken class, as narrowly as possible.
Why it never happens in debug and only crashes in release
In an Android release build, when minifyEnabled true, R8 performs shrinking (removing unused code) and obfuscation (shortening class, method, and field names). Since AGP 8.0, R8 is the standard code shrinker, and "full mode" is enabled by default. Full mode optimizes more aggressively than the legacy ProGuard-compatibility mode: anything not explicitly protected by a keep rule is removed or renamed more freely.
The trouble is that Gson matches fields by name. When mapping {"display_name": "Sunset"} onto data class Category(val displayName: String), Gson reads the field name via reflection at runtime, and absent an annotation it uses the field name itself as the JSON key. But if R8 has renamed that field to a, what Gson sees is a, not displayName. The JSON key never matches, and the value stays unfilled as null.
In the debug build, minifyEnabled false, so the field names stay intact and nothing breaks. That asymmetry is the real source of the difficulty. As long as the person who wrote the code runs it on their own machine, it almost never reproduces.
Reproduce the release behavior locally
Killing the cause by guesswork is slow. The fast path is to first make the release behavior reproduce reliably on your own machine.
# Build the release variant and put it on a device/emulator./gradlew assembleRelease# Install the signed release APK (adb install works even if it isn't debuggable)adb install -r app/build/outputs/apk/release/app-release.apk
Now the same obfuscated code that ships to production runs on your machine too. "Only crashes in release" makes it feel like you can only investigate in production, but a single assembleRelease puts you in a state where you can reproduce it on your own device as many times as you like. There was a period where I skipped this and just stared at production logs, and that was the single biggest waste of time.
Open the screen that crashes, trigger it, and capture the stack trace with adb logcat. The trace at this point is still obfuscated symbols, so the next step is to translate it back to the original names.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can reproduce a release-only crash on your own machine instead of staring at production logs
✦You'll understand why R8 full mode breaks Gson reflection, and how to restore an obfuscated stack trace with mapping.txt and retrace
✦You can stop hiding behind 'keep everything' and protect only the broken class with minimal keep rules in your own app
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Every time R8 obfuscates, it emits mapping.txt, a table that maps the original names to the shortened ones. Without it, an obfuscated stack trace is unreadable.
app/build/outputs/mapping/release/mapping.txt
This file changes with every build, so you must keep the mapping.txt for the build you shipped (upload it to Crashlytics or the Play Console and it will restore obfuscated reports automatically). To deobfuscate a local logcat, use retrace, which ships with AGP.
# Save the crash portion of logcat to crash.txt, then restore the original namesretrace app/build/outputs/mapping/release/mapping.txt crash.txt
After restoring, a line that read a.b.c.d turns back into something like com.example.wallpaper.data.CategoryRepository.load. Only then does it click: "Oh, this is where I parse the category-config JSON." A view that was invisible while you stared at the obfuscated form suddenly opens up.
If you hand the restored stack trace and the before/after mapping to an agent-style IDE like Antigravity, it will quickly suggest a hypothesis such as "this class is deserialized by Gson, and once its fields are obfuscated the mapping breaks unless there's a @SerializedName." That said, which keep rule you ultimately adopt is a trade-off between app size and safety that you have to own yourself, so I find it safest to treat the agent's suggestion as a starting point rather than the answer.
What to keep — four options
Once you know the cause is "the fields Gson reads got obfuscated," there's more than one fix. Here are the four I reach for most, with their side effects.
Approach
Scope protected
Strength
Caveat
@Keep annotation
Only the annotated class/member
Rule lives in code; survives moves and renames
At class level it keeps the whole thing, so little size benefit
-keep class rule
Package/class targeted
Can cover all models at once
Written broadly it keeps unused fields too; scope creeps
@SerializedName
Only the JSON key mapping
JSON keys stay fixed even when obfuscated. Most robust
Must be applied to every field; assumes a keep rule alongside
-keepclassmembers
Members (fields) only
Shrinks the class while preserving field names
Slightly more complex rule; needs a precise target
Where I ultimately landed was a combination: decouple field names from JSON keys with @SerializedName, then protect the data-class members with -keepclassmembers. I'll go into the why in the next section, but in one line: "keep obfuscation on, and pin only the names Gson depends on" gave the best balance of size and safety.
Pin the keys with @SerializedName, protect with minimal keep
First, rewrite the model so it no longer depends on field names. With @SerializedName, even if the field is obfuscated to a, Gson always looks at the key named in the annotation.
import com.google.gson.annotations.SerializedNamedata class Category( // Even if the field name is obfuscated, the JSON key stays "display_name" @SerializedName("display_name") val displayName: String, @SerializedName("wallpaper_count") val wallpaperCount: Int, @SerializedName("is_premium") val isPremium: Boolean = false,)
But @SerializedName alone can be insufficient. R8 full mode may delete a field it judges unused, or remove the no-arg constructor that Gson invokes via Unsafe. So we keep the model's members and constructor.
# proguard-rules.pro# Preserve data-class members (prevent field removal/renaming)-keepclassmembers class com.example.wallpaper.data.** { <fields>; <init>(...);}# Models using generics (List<Category> etc.) need type information too-keepattributes Signature# Preserve annotations Gson references internally-keepattributes *Annotation*
-keepattributes Signature is unglamorous but important. When you deserialize a generic type like List<Category>, if R8 drops the type signature, Gson can't resolve the element type and it degrades into a generic type such as LinkedTreeMap, throwing ClassCastException. @SerializedName does not fix this, which makes it an easy second-stage trap to miss.
The reason for keeping <init>(...) is that full mode sometimes treats the data class's no-arg constructor as unused and strips it. Gson calls that constructor via reflection, so once it's gone, you crash at the instance-creation stage.
Verify the fix on a release build
After adding the keep rules, build release again with the fix and walk through the reproduction steps on your own machine. "The build passed" is not "it's fixed." With obfuscation-related bugs, my honest experience is that you can't call it fixed until you actually open the screen in the obfuscated binary.
./gradlew clean assembleReleaseadb install -r app/build/outputs/apk/release/app-release.apk# Open the screen that was crashing; visually confirm JSON-derived values (category names, etc.) render correctly
It's also reassuring to check the mapping.txt diff. If the fields you wanted to protect stay under their original names while everything else is properly obfuscated, that's evidence your keep scope isn't too broad.
# Did the protected field survive under its original name? (= keep is working)grep "displayName" app/build/outputs/mapping/release/mapping.txt
For my wallpaper app, I rolled the fixed version out via staged release starting at 5%, confirmed that the crash in question produced zero events on the new build in Crashlytics, then widened to 100%. With release-only bugs, not trusting a debug-build "it works" too much turns out to be the shortest path in the end.
Three operational habits to avoid falling in the same hole twice. First, make it a rule to add @SerializedName to JSON-mapped models from the start. Obfuscation-caused bugs are expensive to reproduce after the fact, so preventing them at write time is the cheapest option. Second, always keep the mapping.txt for the build you shipped, and upload it to Crashlytics and the Play Console — without it, production crash reports arrive obfuscated and unreadable. Third, bake a check into CI or your release procedure: "at minimum, open the key JSON-reading screens once on a release build."
R8 full mode is an excellent system that, left alone, quietly makes your app smaller and faster. But the parts that rely on reflection break silently unless you state your intent. Rather than capping it all with "keep everything," draw the line once in your own app to protect only the single broken spot — and the next release gets a lot easier.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.