R8 and ProGuard for Kotlin, Room, and Compose: the crash that only happens in release
Why a Room + Compose Android app can work perfectly in debug and crash in production, and the specific R8/ProGuard keep rules that catch it before a user does.
A build that passes every test on your device and then crashes for a user on the exact same version is one of the more unsettling bugs Android offers, and it usually has one cause: minifyEnabled true. Debug builds skip shrinking and obfuscation entirely, so anything R8 would touch never gets exercised until the release APK — the one already on the Play Store — hits code you never actually ran.
This isn’t an argument against minification. Shrinking a Kotlin + Compose app’s method count and stripping unused code meaningfully helps install size and cold start, and Google increasingly expects it. The fix is smaller: know which parts of your codebase R8 can’t reason about statically, and tell it to leave them alone.
Why R8 breaks things that “obviously” work
R8 decides what to keep by tracing reachability from your app’s entry points — activities, the manifest, anything called directly. Code reached only through reflection, a class name stored as a string, or a library reconstructing a type at runtime is invisible to that trace. R8 renames or removes it, the app compiles fine, and the failure only shows up the moment that reflective path actually runs.
Two places this bites a typical local-first Android app in practice:
WorkManager workers instantiated by name
If you’re scheduling background work — the kind behind the reminder scheduling in Hydrame — your ListenableWorker subclasses aren’t called directly. WorkManager persists the worker’s fully-qualified class name in its own database and reconstructs the instance with Class.forName when the work actually runs, sometimes minutes or hours after the app process that scheduled it is gone. Obfuscate that class name and the crash doesn’t happen at schedule time — it happens later, silently, as a ClassNotFoundException deep in WorkManager’s dispatcher, with no stack trace pointing back at your code:
# proguard-rules.pro
-keep public class * extends androidx.work.ListenableWorker {
public <init>(android.content.Context, androidx.work.WorkerParameters);
}
Recent work-runtime versions ship a consumer rule close to this already, but “probably already covered by the library” isn’t something to take on faith in a release build — verify it, don’t assume it.
Reflection-based JSON for backup and export
Any local-first app with a real export/import flow — the kind Stocky uses to back up pantry data — is serializing entity data class fields to JSON and back. If that serialization goes through a reflective library (Gson, or Moshi without the codegen path), the JSON keys are your Kotlin property names by default. R8 renames private fields it considers safe to rename, the export file it produces still looks fine, and the failure shows up only on import, when the reflective reader looks for a field name that no longer exists in the shrunk class. No crash, no error — just data that silently doesn’t come back.
-keepclassmembers class com.example.app.data.** {
<fields>;
}
-keepattributes Signature
Scope this to your actual data package, not your whole codebase — a blanket -keep class ** defeats the point of minifying at all.
Testing the build that actually ships
The only reliable way to catch these is to run the release build, not the debug one:
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
./gradlew assembleRelease, install the APK on a real device, and walk through every core flow by hand before each submission — scheduling a reminder, exporting data, then force-quitting and reopening to confirm the import still reads it back. It’s a five-minute check against a class of bug that’s otherwise invisible until Play Console’s crash reports start filling in, which for an app with no third-party crash reporter attached is the only visibility you’ll get after the fact.
One more habit worth keeping: hold on to the mapping.txt R8 generates per release (Play Console will ask for it, and does automatically if you upload via App Bundle). Without it, a crash that does reach you in production is a stack trace full of single-letter class and method names — readable to R8, not to you.
The takeaway
Minification doesn’t fail loudly. It fails at the one code path you didn’t manually retest, weeks after the build that shipped it passed everything you did check. Treat any class instantiated by name — workers, reflective serializers, anything a library reconstructs from a string — as a keep rule you write on purpose, not one you hope a library’s defaults cover for you.
// Related reading
More from the journal
Room TypeConverters in 2026: storing enums, dates, and lists without corrupting your schema
A practical guide to Room TypeConverters on Android — enums, Instant/LocalDate, and lists — plus the mistakes that turn a converter into a silent data-corruption bug.
Room database indices in 2026: finding the query that's actually slow and fixing it
A practical guide to indexing a Room/SQLite database on Android — reading EXPLAIN QUERY PLAN, adding @Index without guessing, and the mistakes that silently defeat one.
Room's @Relation: querying one-to-many data on Android without N+1 queries
A practical guide to Room's @Relation annotation — modeling one-to-many data like categories and entries without N+1 queries or manual joins.