Cutting a Capacitor Android build in half
13.1 MB down to 5.9 MB delivered. R8 is off in the project Capacitor generates, and one library's consumer ProGuard rule was pinning 9,304 classes.
Gradle, ProGuard, the size script and the Vite pluginGist · IndieCoreDev/5d3ca9b6ad1add515f358883a1bf106fPart 9 of 10 on building a word-block puzzle engine. Part 8: pricing an economy off its difficulty model.
A word puzzle with 392 levels of JSON in it was a 17 MB App Bundle. That felt like the levels' fault. It was not — content was 16% of the download and the Android runtime was most of the rest.
After the pass below, a phone downloads 5.88 MB where it used to download 13.09 MB. Here is where it went, in order of how much it was worth.
First: stop reading the wrong number
ls -l on the .aab is not the download. Play splits a bundle per device — by screen density,
by ABI, by language — and strips its own metadata before delivery. My "after" bundle was 15.5 MB
on disk when the actual download had already halved, because R8 had added a 3.83 MB
BUNDLE-METADATA/…/proguard.map that Play keeps for deobfuscating crash reports and never sends
to anyone.
So the first thing to build is a way to see the real figure: sum the compressed entries,
discard BUNDLE-METADATA/ and META-INF/, and keep one density bucket. It needs nothing but
unzip:
#!/usr/bin/env node
/**
* What a phone actually downloads from an Android App Bundle.
*
* node delivered-size.mjs app-release.aab [density]
*
* `ls -l` on the .aab is not the download, and the gap is large enough to send
* you optimising the wrong thing. Play splits a bundle per device — by screen
* density, by ABI, by language — and strips its own metadata before delivery.
*
* The one that catches everybody: turning on R8 ADDS a multi-megabyte
* BUNDLE-METADATA/.../proguard.map to the .aab. Play keeps it to deobfuscate
* crash reports and never sends it to anyone. On one app that was 3.83 MB, so
* the file on disk looked barely improved while the real download had halved.
*
* This is an estimate, not bundletool's exact figure. It moves when the real
* one does, which is what makes a creeping regression visible — and it needs
* nothing but unzip.
*/
import { execFileSync } from 'node:child_process';
const aab = process.argv[2];
const density = process.argv[3] ?? 'xxhdpi';
if (!aab) {
console.error('usage: node delivered-size.mjs <bundle.aab> [density]');
process.exit(1);
}
const others = ['ldpi', 'mdpi', 'hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi']
.filter((d) => d !== density);
const lines = execFileSync('unzip', ['-lv', aab], {
encoding: 'utf8',
maxBuffer: 1 << 28,
}).split('\n');
let total = 0;
let kept = 0;
const buckets = {};
for (const line of lines) {
// length method size cmpr date time crc-32 name
const m = line.match(/^\s*(\d+)\s+\S+\s+(\d+)\s+\S+\s+\S+\s+\S+\s+\S+\s+(.+)$/);
if (!m) continue;
const compressed = Number(m[2]);
const path = m[3].trim();
total += compressed;
// Never delivered: R8's mapping file and the signature block.
if (path.startsWith('BUNDLE-METADATA/') || path.startsWith('META-INF/')) continue;
// Delivered only to devices of that density.
if (others.some((d) => path.includes(`-${d}-`) || path.includes(`-${d}/`))) continue;
kept += compressed;
const key = path.split('/').slice(0, 3).join('/');
buckets[key] = (buckets[key] ?? 0) + compressed;
}
const mb = (n) => `${(n / 1048576).toFixed(2)} MB`;
console.log(aab);
console.log(` .aab on disk ${mb(total)}`);
console.log(` delivered (${density}) ${mb(kept)}`);
for (const [k, v] of Object.entries(buckets).sort((a, b) => b[1] - a[1]).slice(0, 8)) {
console.log(` ${mb(v).padStart(8)} ${k}`);
}
// Typical output before and after an optimisation pass:
//
// .aab on disk 16.83 MB .aab on disk 10.41 MB
// delivered (xxhdpi) 13.09 MB delivered (xxhdpi) 5.88 MB
// 3.01 MB base/dex/classes2.dex 3.21 MB base/dex/classes.dex
// 2.90 MB base/dex/classes.dex 1.10 MB base/assets/public
// 2.71 MB base/dex/classes3.dex 1.03 MB base/dex/classes2.dex
// 1.65 MB base/assets/public 0.20 MB base/resources.pb
// 0.93 MB base/dex/classes4.dex 0.07 MB base/res/mipmap-xxhdpi-v4
.aab on disk 16.83 MB
delivered (xxhdpi) 13.09 MB
3.01 MB base/dex/classes2.dex
2.90 MB base/dex/classes.dex
2.71 MB base/dex/classes3.dex
1.65 MB base/assets/public ← the whole web app + 392 levels
0.93 MB base/dex/classes4.dex
0.35 MB base/res/drawable-port-xxhdpi-v4
Four dex files, 9.55 MB compressed, 73% of the download. The content I had been optimising was the fourth-largest line.
R8 is off in the project Capacitor generates
This is the single biggest item, and it is a default.
buildTypes {
release {
minifyEnabled false // ← what `npx cap add android` gives you
}
}
Nothing shrinks. Every class of Play Services, Firebase, gRPC and Guava ships whole. Turning it on:
// The release block Capacitor does NOT give you.
//
// `npx cap add android` scaffolds a project with minifyEnabled false, so every
// class of Play Services, Firebase, gRPC and Guava ships whole. On one app that
// was four dex files, 9.55 MB compressed, 73% of the download.
//
// Turning R8 on took the delivered download from 13.09 MB to 7.76 MB.
android {
// Play Services and Firebase package their .proto sources and a pile of
// build metadata as Java resources. Android reads none of it — these SDKs
// use generated Java classes, not runtime descriptor parsing. 164 KB -> 15 KB.
packaging {
resources {
excludes += [
'**/*.proto',
'**/*.textproto',
'META-INF/*.version',
'META-INF/*.kotlin_module',
'META-INF/proguard/**',
'META-INF/DEPENDENCIES',
'META-INF/LICENSE*',
'META-INF/NOTICE*',
'DebugProbesKt.bin',
'kotlin-tooling-metadata.json',
]
}
}
// Signing properties live outside the repo so the keystore password is
// never committed.
//
// Guarded, because Gradle evaluates this block for EVERY variant: without
// the check a freshly scaffolded game cannot build even a debug APK until
// its alias exists, which is exactly backwards. A release build with the
// properties missing falls through to no signing config and Gradle says so,
// which is the right failure and a legible one.
signingConfigs {
release {
if (project.hasProperty('GAME_UPLOAD_STORE_FILE')) {
storeFile file(GAME_UPLOAD_STORE_FILE)
storePassword GAME_UPLOAD_STORE_PASSWORD
keyAlias GAME_UPLOAD_KEY_ALIAS
keyPassword GAME_UPLOAD_KEY_PASSWORD
}
}
}
buildTypes {
release {
if (project.hasProperty('GAME_UPLOAD_STORE_FILE')) {
signingConfig signingConfigs.release
}
// The four lines this whole file exists for.
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
}
dependencies {
// Firebase version matters for more than features.
//
// firebase-auth 24.0.1 shipped a consumer ProGuard rule inside the AAR:
//
// -keep class com.google.android.gms.internal.** { *; }
//
// Every Play Services internal — not just the auth ones. 9,304 classes
// pinned unshrunk and unrenamed, by a dependency, in a file nobody writes
// or thinks to look in. It was single-handedly why Play's app-optimisation
// report scored this app at 28% WITH R8 already enabled.
//
// 24.2.0 narrowed that rule to the one proto base class it needed.
//
// The lesson generalises: a consumer ProGuard rule is a dependency's
// ability to disable your optimisation, and nothing in your build tells you
// it happened. If the numbers are worse than they should be, unzip the AARs
// and grep for -keep.
implementation platform('com.google.firebase:firebase-bom:34.18.0')
implementation 'com.google.firebase:firebase-analytics'
implementation 'com.google.firebase:firebase-crashlytics'
}
Dex went 9.55 MB → 4.24 MB compressed. Delivered download 13.09 → 7.76 MB, from four lines of Gradle.
Capacitor itself is safe under R8: the Android library ships consumerProguardFiles, so
@CapacitorPlugin classes and anything extending Plugin are kept automatically. What is not
kept automatically is the @JavascriptInterface object you added by hand to MainActivity —
proguard-android-optimize.txt covers it, but I repeat the rule locally because losing it is a
silent, release-only failure where a bridge method just returns undefined.
The one thing that broke the build first time was a library referencing an SDK that is not a dependency:
ERROR: R8: Missing class com.facebook.CallbackManager$Factory
(referenced from: …FacebookAuthProviderHandler…)
The Firebase authentication plugin compiles in a handler for every provider it supports. The app
offers two of them. -dontwarn com.facebook.** and it builds — R8 writes the exact rules you
need into app/build/outputs/mapping/release/missing_rules.txt, so this is a copy-paste, not an
investigation.
The consumer rule that undid most of it
Then Play's app-optimisation report scored the bundle at 28% optimised, with R8 on. That number sent me looking for what R8 was not allowed to touch.
firebase-auth 24.0.1 shipped this consumer rule inside the AAR:
-keep class com.google.android.gms.internal.** { *; }
Every Play Services internal class. Not the auth ones — all of them. 9,304 classes pinned unshrunk and unrenamed, by a dependency, in a file I never wrote and would not have thought to look in.
Firebase BOM 34.x pulls firebase-auth 24.2.0, which narrows that rule to the one proto base
class it actually needed.
The lesson generalises past this one library: a consumer ProGuard rule is a dependency's
ability to disable your optimisation, and nothing in your build tells you it happened. If the
numbers are worse than they should be, unzip the AARs and grep for -keep.
While I was in there, two lines that Play's report also checks:
# R8 rules for a Capacitor release build.
#
# Most of what a Capacitor app links against ships its own consumer rules inside
# the AAR — Capacitor keeps @CapacitorPlugin classes, Firebase keeps its model
# classes, gRPC keeps its service loaders. What is left here is the handful of
# things R8 cannot see from the bytecode alone.
# MainActivity hands the WebView an anonymous @JavascriptInterface object.
# proguard-android-optimize.txt already keeps @JavascriptInterface members, but
# the rule is repeated because losing it is a silent, RELEASE-ONLY breakage:
# the bridge method just returns undefined and nothing throws.
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# Capacitor instantiates plugin classes by name from the generated plugin list,
# so their no-arg constructors must survive even though nothing calls them.
-keep class com.getcapacitor.** { *; }
-keep class * extends com.getcapacitor.Plugin { *; }
-keepnames class * implements com.getcapacitor.Plugin
# Crashlytics needs line numbers and the original file name to symbolicate the
# stack traces it uploads. Without these, every release crash report is a list
# of one-letter method names with no line numbers.
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# Optional transports and annotations these SDKs reference but never load on
# Android. R8 writes the exact list you need into
# app/build/outputs/mapping/release/missing_rules.txt — copy from there rather
# than guessing.
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-dontwarn javax.naming.**
-dontwarn com.google.errorprone.annotations.**
-dontwarn javax.annotation.**
# The Firebase authentication plugin compiles in a handler for EVERY provider it
# supports, including Facebook, whose SDK is not a dependency here. This is the
# error that fails the first R8 build:
#
# ERROR: R8: Missing class com.facebook.CallbackManager$Factory
# (referenced from: ...FacebookAuthProviderHandler...)
-dontwarn com.facebook.**
# Flatten every renamed class into the root package. Play's app-optimisation
# report checks for this ("Repackage classes"); the payoff is a smaller dex —
# each package name is a string in the pool, and 15k classes spread over
# hundreds of packages is a lot of strings nothing reads at runtime.
#
# Safe because R8 leaves anything a -keep rule names where it is, and
# -allowaccessmodification lets it widen package-private access when a class
# does move. Spelled out rather than relied on from full mode's defaults.
-allowaccessmodification
-repackageclasses ''
Flattening renamed classes into the root package is worth real bytes — every package name is a string in the dex pool, and 15,000 classes spread over hundreds of packages is a lot of strings nothing reads at runtime.
The web SDK that can never run
The JavaScript side had a version of the same problem: code that is unreachable but present.
Every service picks its provider at runtime — Capacitor.isNativePlatform() chooses the native
plugin, otherwise the firebase/* web SDK — and imports both arms statically. So a build
destined for the Play Store carried a full copy of the Firebase JS SDK that the device can never
execute.
Worse, and this is the part I did not predict: every @capacitor-firebase plugin registers a
browser fallback as
registerPlugin('FirebaseAuthentication', {
web: () => import('./web').then(m => new m.FirebaseAuthenticationWeb()),
})
registerPlugin only calls that on a browser. But it is a static import site, so the bundler has
to emit it, and each of those web.js files pulls in its slice of the web SDK. That is where the
525 KB Firestore chunk and the 168 KB Auth chunk in my build were coming from — not from my code
at all.
The fix is a small Vite plugin that, under a VITE_NATIVE=1 flag, resolves both the web-only
service arms and those plugin fallbacks to stubs that throw. Main chunk 909 KB → 433 KB, and
the two large chunks disappeared entirely.
// Drop the Firebase JS SDK from a native (Capacitor) build.
//
// Nothing in a Capacitor build can reach the firebase/* web SDK, yet it was
// ~730 KB of the shipped bundle. Two separate paths pulled it in:
//
// 1. Services pick a provider at runtime — Capacitor.isNativePlatform()
// chooses the native plugin, otherwise the web SDK — but import BOTH arms
// statically, so the bundler emits both.
//
// 2. Every @capacitor-firebase plugin registers a browser fallback as
// registerPlugin('X', { web: () => import('./web').then(...) })
// registerPlugin only calls that on a browser, but it is still a static
// import site, so each of those web.js files drags in its slice of the web
// SDK. This is where the 525 KB Firestore chunk and the 168 KB Auth chunk
// were coming from — not from app code at all.
//
// With VITE_NATIVE=1 both resolve to stubs that throw. Nothing calls them, and
// if something ever does the message says why rather than failing somewhere
// inside a tree-shaken Firebase.
//
// Main chunk 909 KB -> 433 KB, and the two large chunks disappear entirely.
/** Module ids whose web-only implementation is unreachable on a device. */
const WEB_ONLY_STUBS = {
'@your-scope/analytics': ['createAnalytics'],
// Add the service arms your own app imports statically. Keep the export
// names accurate — a missing one is a build error, which is the good failure.
};
const PLUGIN_WEB_FALLBACK =
/@capacitor-firebase[/\\]([a-z]+)[/\\]dist[/\\]esm[/\\]web\.js$/;
export const dropWebFirebase = () => ({
name: 'drop-web-firebase',
apply: 'build',
enforce: 'pre',
async resolveId(source, importer, options) {
if (WEB_ONLY_STUBS[source]) return `\0web-only:${source}`;
// Only './web' can be a plugin fallback, so resolve nothing else — this
// hook runs for every import in the graph.
if (!source.startsWith('./web')) return null;
const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
const plugin = resolved && PLUGIN_WEB_FALLBACK.exec(resolved.id);
return plugin ? `\0web-only-plugin:${plugin[1]}` : null;
},
load(id) {
if (id.startsWith('\0web-only:')) {
const from = id.slice('\0web-only:'.length);
return WEB_ONLY_STUBS[from]
.map((name) => `export const ${name} = () => { throw new Error(${JSON.stringify(
`${name} (${from}) is stubbed out of native builds — the Capacitor provider ` +
'should have been chosen instead.',
)}) }`)
.join('\n');
}
if (id.startsWith('\0web-only-plugin:')) {
const plugin = id.slice('\0web-only-plugin:'.length);
// e.g. authentication -> FirebaseAuthenticationWeb, the name the plugin's
// index.js constructs off the dynamic import.
const cls = `Firebase${plugin[0].toUpperCase()}${plugin.slice(1)}Web`;
return `export class ${cls} { constructor() { throw new Error(${JSON.stringify(
`${cls} is stubbed out of native builds — registerPlugin should have used the ` +
'native implementation.',
)}) } }`;
}
return null;
},
/**
* The part worth insisting on.
*
* A stub only helps while nothing re-imports the real thing. Assert on the
* emitted chunks rather than trusting that the import graph stayed clean —
* without this, the next dependency upgrade quietly puts 700 KB back and
* nobody finds out.
*/
generateBundle(_options, bundle) {
const leaked = Object.entries(bundle)
.filter(([, c]) => c.type === 'chunk' &&
/@firebase\/(app|auth|firestore|analytics)\b/.test(c.code))
.map(([name]) => name);
if (leaked.length) {
this.error(
`VITE_NATIVE=1 but the Firebase JS SDK is still in ${leaked.join(', ')}. ` +
'Something imports it outside the stubbed entry points.',
);
}
},
});
The part I would insist on if I wrote it again is the assertion. A stub only helps while nothing
re-imports the real thing, so the plugin scans the emitted chunks and fails the build if
@firebase/ appears anywhere:
VITE_NATIVE=1 but the Firebase JS SDK is still in assets/index-xxx.js.
Something imports it outside the stubbed entry points.
Without that, the next dependency upgrade quietly puts 700 KB back and nobody finds out.
PNG splash screens are a terrible idea
@capacitor/assets generates a splash image per density, per orientation, per theme. 26 PNGs.
The portrait xxhdpi one was 457 KB.
A full-bleed smooth gradient is close to the worst case for PNG's row filters. The same image as WebP at quality 88 is 28 KB, and I could not tell them apart side by side at full size.
res/ 7.0 MB → 1.1 MB
The Android drawable system does not care about the extension — @drawable/splash resolves to
splash.webp exactly as it resolved to splash.png — so this is a change to the generator
script and a deletion, with no XML to touch. WebP drawables have been supported since API 18;
the minimum here is 24.
Launcher mipmaps got the same treatment for another 30 KB per density bucket, with the monochrome themed-icon layer going lossless because it is flat white on alpha.
The last 150 KB
Play Services and Firebase package their .proto sources and a pile of build metadata as Java
resources. Android reads none of it — these SDKs use generated Java classes, not runtime
descriptor parsing. The packaging { resources { excludes } } block in the Gradle file above
drops it: 164 KB → 15 KB.
Verify it on a device, not in a report
R8 breaks things by removing code that is only reached reflectively, and the failure is at runtime in the release variant — the one build nobody runs during development.
So: ionic capacitor build android release, install the signed APK, and play it. In my case
logcat showed anonymous sign-in succeeding and the backend answering, which is the proof that
mattered; a green build proves only that R8 finished.
If you normally run capacitor build android app or ionic capacitor android build through the
CLI, note that neither assembles a release variant on its own. The size questions all live in
assembleRelease / bundleRelease, so a debug capacitor android build apk will not show you
any of this — debug builds have minifyEnabled false by design and always will.
Where it ended up
| before | after | |
|---|---|---|
| dex | 9.55 MB | 4.24 MB |
| web assets | 1.65 MB | 1.10 MB |
| splash + icons | 1.15 MB | 0.13 MB |
| SDK metadata | 0.16 MB | 0.02 MB |
| delivered (xxhdpi) | 13.09 MB | 5.88 MB |
The remaining dex is mostly the ads SDK, which is the cost of the business model rather than a mistake. I looked at the lite variant and decided not to gamble ad revenue on it.
The release script now prints the delivered estimate against a budget on every build, because the whole point of doing this once is not having to do it again from scratch when something large comes back.
Next: part 10, a release pipeline you cannot forget a step in — generated native projects, and the version number that reset itself to 1.