An app that had already cleared review and gone live started drawing a single crash report: it died the moment one particular screen opened. On my machine it never reproduced. Expo Go was fine. The expo run:android debug build was fine. The only build that crashed was the production AAB downloaded from Play.
The cause was a change I had made just before shipping. Wanting to shave a little off the download size, I had enabled R8 code shrinking. R8 decided a class was unreferenced and removed it, and at runtime the app went looking for that class, failed to find it, and crashed. To save you the same detour, here is how to recognize the symptom and apply keep rules.
The symptom: fine in debug, dead only in production
This crash has a distinct fingerprint.
It never reproduces during development. The debug build runs with minifyEnabled off, so the code stays intact and there is nothing to strip. Shrinking only happens in the release build, so the symptom only shows up there.
In the crash log, the exception surfaces as a ClassNotFoundException, a NoSuchMethodError, or sometimes a Kotlin NullPointerException. The class names in the stack trace are collapsed into short tokens like a.a.b. That obfuscated naming is the telltale sign that R8 touched the code.
It also tends to crash on one specific screen or feature rather than the whole app — concentrated wherever you use reflection or JSON deserialization.
Why a class you clearly use disappears
R8 walks your code statically to decide what is "reachable." It can follow ordinary method calls, but it cannot see a class resolved by name through reflection, a bridge invoked from native code, or a model class that a JSON library fills by matching field names. To the static analyzer, those look like nobody calls them.
So a class or field that is genuinely needed at runtime gets treated as unused — deleted, or renamed to something short. When a library like Gson relies on field names, a renamed field no longer matches, and you get empty values or an exception.
In other words, the code is not wrong; R8 simply was not told the part is needed. The fix is entirely about declaring, with keep rules, what must survive.
Trace the stripped class through mapping.txt
Before adding keep rules at random, pin down what actually got removed. Every build, R8 emits a mapping table for its obfuscation.
android/app/build/outputs/mapping/release/mapping.txt
This file maps "obfuscated name to original name." If you build with EAS Build, you can pull the same mapping.txt from the build artifacts.
Look up the a.a.b-style token from the device stack trace in mapping.txt and you recover the original class name. If you use Google Play Console, upload that mapping.txt against the app version, and the crash reports in Console will be de-obfuscated back to readable names automatically. I forgot this step once and spent a good half hour staring at a wall of symbols.
Once you have the real class name, ask why it was invisible to static analysis. Usually it is reflection, or a serialization model.
Apply keep rules with expo-build-properties
In Expo's managed workflow, editing android/ directly gets overwritten by prebuild. R8 settings and keep rules go through the expo-build-properties plugin.
To preserve a group of stripped model classes, keep them by package.
{
"expo": {
"plugins": [
[
"expo-build-properties",
{
"android": {
"enableProguardInReleaseBuilds": true,
"enableShrinkResourcesInReleaseBuilds": true,
"extraProguardRules": "-keepattributes Signature\n-keepattributes *Annotation*\n-keep class com.yourapp.models.** { *; }"
}
}
]
]
}
}
Replace com.yourapp.models.** with your app's real package name. The two -keepattributes lines matter when Gson and similar libraries read generics or annotations.
If you restore enums from strings anywhere, keeping values() and valueOf() is the safe move.
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
If a bridge class is called by name from native code or a library, keep it explicitly too.
-keep class com.yourapp.NativeBridge { *; }
After writing the rules, regenerate android/ with npx expo prebuild --clean and run a release build. In my case, keeping the model package stopped the crash on that screen. The nice part was not having to abandon shrinking altogether.
Guarding against a repeat
Right after enabling R8, always take one full pass through a release build on a real device. Build the same AAB you would ship with --local, install it, and open your main screens in order — that alone catches almost every strip-related crash before users do.
mapping.txt changes every build, so keep the one for each released version, or upload it to Play Console. Without it you are reading production crashes in their obfuscated form, which makes diagnosis noticeably heavier.
It is tempting to keep everything with a broad -keep class com.yourapp.** { *; }, but that dilutes the shrinking. Keeping only the range that was actually stripped gets you closer to both smaller size and stability.
For app size reduction itself, my practical guide to shrinking a Rork app bundle covers the moves beyond R8. Reading both should make it easier to draw the line between what to cut and what to keep.
As a next step, if any app you currently ship has enableProguardInReleaseBuilds on, build a --local release locally and walk through the main screens. If a strip is lurking, you will find it before your users do.