The Slack message read: "the QA build is 214 megabytes." I assumed the build server had double-bundled something. It had not. The release APK was 211MB and the iOS IPA was 198MB. We had not added a single asset that quarter. We had simply stopped paying attention.
Six weeks later the Android download was 38MB and the iOS download was 41MB. None of it was magic. All of it was attention. This post is the actual investigation, in the order I did it, with the numbers and the commands. If your Flutter app is too big, the answer is in here somewhere.
Context: where the bytes actually live in a Flutter app
Before optimizing anything, you need a mental model of where size accumulates. A release Flutter APK contains:
- The Flutter engine for each ABI you ship (typically
armeabi-v7a,arm64-v8a, sometimesx86_64). - Your Dart code compiled to AOT machine code (also per ABI).
- Asset bundles declared in
pubspec.yamlplus anything pulled in transitively by plugins. - Native plugin libraries (each plugin can ship its own
.sofiles). - Resources (images, drawables, strings) from Android plugin code.
- The Kotlin/Java standard library and any AndroidX dependencies you pull in.
On iOS, the equivalents are: the Flutter framework, your AOT snapshots, asset catalogs, and any frameworks plugins drop in. App Store Connect strips per-architecture slices for the user's device, so the install size on a user's iPhone is smaller than the upload IPA, but App Store search shows the upload size in some places.
Step one: measure, don't guess
--analyze-size writes a JSON file and opens DevTools showing exactly which packages, classes, and assets contribute. The first time you run it, you will see something embarrassing. In our case: 47MB of unused Lottie animations, 22MB of duplicated icon sets, and a video file someone added "for the splash screen" that nobody removed.
Step two: audit the asset pipeline
Our pubspec.yaml had this:
That one line bundled everything in the assets folder, including the original artist files, the 4K hero videos, and the unused mascot animations. The fix was to enumerate explicitly:
The mistake to avoid: bundling raw PNG when you can ship WebP. WebP at 80 quality is typically 30 to 60 percent smaller than equivalent PNG for our use case. SVG is even smaller for icons but Flutter does not render SVG natively; you need flutter_svg and the runtime cost is real for many simultaneous icons.
Audit asset variants. If you ship 2.0x/ and 3.0x/ versions of every image, they add up. Flutter's AssetImage will pick the right one based on MediaQuery.of(context).devicePixelRatio, but if your images are vector or generated at runtime, the variants are dead weight.
Step three: tree shaking and ABI splitting
Dart's release compiler tree-shakes unused code. Flutter strips unused icons from MaterialIcons automatically when you build release. You can verify this in the build output:
Font asset "MaterialIcons-Regular.otf" was tree-shaken, reducing it from
1645184 to 7508 bytes (99.5% reduction).
If you do not see that line, you are using icon names dynamically (e.g., looking them up by string), which defeats tree shaking. Either pre-compute the icons at build time or accept the larger font.
Splitting by ABI is the single biggest win for Android and the easiest. The Play Store distributes per-device APKs from your AAB automatically:
If you ship an APK directly (sideloading or alternative stores), use --split-per-abi and upload each. A user on arm64-v8a should never have to download the armeabi-v7a engine.
Step four: iOS bitcode and dSYMs
Apple removed bitcode requirements with Xcode 14. If your Podfile or build settings still enable bitcode, you are paying size for nothing:
dSYMs ship in your debug build. They should not be in the IPA you upload for release. Verify in Xcode: Build Settings, Debug Information Format, set to "DWARF with dSYM File" for Release but make sure the dSYM is not embedded in the app bundle (it should be in app.dSYM/, not inside Runner.app).
Step five: native plugin audit
Plugins drag native code in. We had firebase_* packages we were not using, a maps package from before the migration, and three different image picker plugins because nobody removed the old ones. Run:
Then for each native plugin you can identify, look at the generated Pods folder on iOS and the build/intermediates/merged_native_libs folder on Android to see the contributed .so and .framework weight.
Replacing webview_flutter with the built-in platform view abstraction would have saved nothing for us because we used it. But removing one unused Firebase product saved 4MB on Android and 5MB on iOS. Audit ruthlessly.
Step six: the build flags I now always set
--obfuscate reduces symbol size and protects your code. --split-debug-info writes the symbol map to a separate folder so it does not ship in the APK; you keep that folder somewhere safe to deobfuscate stack traces from production. --tree-shake-icons ensures the icon font reduction runs.
For iOS the equivalents:
Before and after
| Surface | Before | After | Notes |
|---|---|---|---|
| Android upload AAB | 211MB | 78MB | 38MB device-specific install |
| iOS upload IPA | 198MB | 95MB | 41MB device-specific install |
| Asset bundle | 102MB | 14MB | WebP + audit |
| Native libraries | 47MB | 22MB | Plugin removal |
| Engine + Dart code | 38MB | 32MB | Obfuscate + tree shake icons |
The remaining 22MB of native libraries on Android is not all in our control. The Flutter engine itself is around 8MB per ABI. The rest is plugins we genuinely use.
What I would do differently
- I would have set a budget on day one. "Release APK must be under X MB" with a CI check that fails the build at 110% of budget. Without a budget you get 200MB before anyone files a bug.
- I would have written the
--analyze-sizereview into our weekly engineering standup. Once a week, ten minutes. Drift gets caught early. - I would have moved heavy onboarding assets to a server-fetched bundle on first launch. Users who never finish onboarding never download the assets. We did this in the cleanup; we should have done it from the start.
- I would have used
flutter_svgfor monochrome icons earlier. The runtime cost was negligible for our quantity and it removed 6MB of PNGs. - I would not have allowed
assets: [assets/]to ship to production. That line should be a lint failure in any mature project.
Closing opinion
Set a size budget, enforce it in CI, and run flutter build --analyze-size before every release. If you do those three things you will never end up where we did. The most common cause of bloat is asset directories shipped wholesale; the second most common is unused plugins. For more on how this fits into a healthy production setup, see Structuring a large Flutter codebase. What actually scales. For an unrelated postmortem in the same series, see Debugging a Flutter crash that only happened in production on iOS.
Written by the author of Flutterstacks
A developer who shipped production apps in Swift, Kotlin, and Dart — with a genuine native reference point that most Flutter writers simply don't have.
More articles →