Fix “App optimization is below our threshold” in a Capacitor app
Play Console now warns when obfuscation, optimization or shrinking is under 25%, and it becomes a requirement in February 2027. Why Capacitor apps fail it, the exact R8 setup that passes, and how to ship it without breaking plugins.
If you uploaded a Capacitor app to Google Play recently, you may have seen this in the app bundle explorer: “App optimization is below our threshold”, often with an obfuscation score of 1% to 5%. It is a warning today. From February 2027 it becomes one of Play's technical quality requirements, and Google says missing it can affect your app's visibility and publishing capabilities.
The fix is a few lines of Gradle. The risk is shipping it without testing, because it changes how your native code is compiled. Here is the full process.
What Google requires
From Play Console's technical quality requirements:
- At least 25% obfuscation, 25% optimization and 25% shrinking, each measured separately, for app uploads.
- It applies only when DEX code is non-negligible: over 10 MB for apps and over 50 MB for games.
- You can use R8 or any other shrinker.
- Scores for each uploaded bundle are shown in the app bundle explorer. With Android Gradle Plugin 8.10 or later, R8 also writes a report Play reads, so you can check locally.
DEX is your compiled Java and Kotlin: Capacitor, its plugins, Firebase and every other Android library. Your web bundle is not DEX; it lives in assets/ and does not count. A small Capacitor app may be under 10 MB of DEX and out of scope. Add Firebase, a couple of SDKs for analytics and payments, and you are over.
Why Capacitor apps fail it
The Capacitor Android template ships release builds with minifyEnabled false. R8 never runs, so nothing is renamed, optimized or removed, and the scores sit near zero. Turning R8 on is the whole fix.
Step 1: enable R8 for release builds
In android/app/build.gradle:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
}Use proguard-android-optimize.txt. The older proguard-android.txt contains -dontoptimize, which keeps your optimization score low.
Step 2: remove anything that switches R8 off
Search your project for flags someone added to silence a crash years ago:
grep -rn "dontobfuscate\|dontoptimize\|dontshrink" android/ grep -rn "keep class \*\*" android/ grep -n "enableR8" android/gradle.properties
Delete -dontobfuscate, -dontoptimize, -dontshrink, blanket rules like -keep class ** { *; }, and android.enableR8.fullMode=false. Any one of them can hold a score under 25% on its own. Note that a library can also ship these flags in its own consumer rules; if a score stays low after your changes, check the merged configuration R8 prints with -printconfiguration.
Step 3: keep only what needs keeping
Capacitor and its official plugins ship their own consumer R8 rules, so the bridge and @CapacitorPlugin classes survive minification. You usually need rules only for:
- Local plugins you wrote inside the app, if they fail after minification.
- Classes loaded by reflection or JSON mapping (Gson models, for example).
- Third-party SDKs whose documentation asks for rules.
# android/app/proguard-rules.pro
# A local plugin in your app module
-keep class com.acme.app.plugins.** { *; }
# Models serialized by name with Gson
-keep class com.acme.app.models.** { *; }Keep rules narrow. -keep class com.getcapacitor.** { *; } feels safe but works against the scores you are trying to raise.
Step 4: build, then test the release build on a device
npm run build npx cap sync android cd android && ./gradlew bundleRelease
R8 writes app/build/outputs/mapping/release/mapping.txt. If that file exists, R8 ran. Now install a release build (not debug) on a real device and go through every screen that calls a native plugin: camera, push, file system, sign-in, purchases. R8 problems show up as crashes or silent failures in native calls, never in the web layer.
Step 5: upload the mapping file
With obfuscation on, native stack traces are unreadable without mapping.txt. Play Console picks it up from the bundle in most setups; confirm it under the bundle's assets in the app bundle explorer. If you use Crashlytics or Sentry, upload it there as part of CI too.
Step 6: roll out gradually
Upload to the internal testing track, check the three scores in the app bundle explorer, then release to production with a staged rollout starting at a small percentage. Watch Android vitals for new crashes before going wide.
Why the staged rollout matters here: an R8 mistake is a native bug, so it cannot be fixed with a web update. The only fix is another store build. A 5% rollout keeps the damage small while you wait for review.
The other February 2027 requirements
Play announced more technical quality requirements alongside this one:
| Requirement | Date | Relevance for Capacitor apps |
|---|---|---|
| 25% obfuscation, optimization and shrinking (DEX over 10 MB) | February 2027 | Fixed by enabling R8 as above |
| Memory: anonymous RSS + swap and bitmap memory thresholds at the 90th percentile | February 2027 | WebView apps with large images or long-lived pages should check Android vitals |
| Restore Credentials API for sign-in apps when users move to a new device | April 2027 | Block Store integrations completed on or before 30 September 2026 count as compliant |
The memory requirement is the one to watch for WebView apps. Much of that is web-side work (image sizes, unbounded lists, caches that never clear), and those fixes can ship over the air. See the Capacitor performance checklist.
Keep native releases rare and safe
Turning on R8 is a one-time native change. Most of what you ship afterwards does not need to touch it. With OtaKit, web changes go to users without a new store build, so the Android binary changes only when native code does, and each of those releases gets the careful rollout it deserves.
When you do ship the R8 build, bump runtimeVersion if the native plugin set changed, so older OTA bundles are never applied to the new shell. See channels and runtime version.