devShakib

What Actually Shrinks a Flutter App: Split-Per-ABI, Deferred Components, and Reading the Size Report

Reduce Flutter app size for real: App Bundles, split per ABI, icon tree shaking, deferred components, and reading the DevTools size report to measure every megabyte.

Every few months someone on my team opens a PR titled "reduce app size" that swaps a PNG for a WebP and calls it a day. It saves 40 KB on a 30 MB app. Meanwhile the same build ships four CPU architectures to every user and drags in the full Material icon font. If you want to actually shrink a Flutter install, you have to know which levers move megabytes and which move rounding errors — and the only way to know is to measure.

I've shipped this optimization across our production apps at Shpper and on my own tools apps, and the ranking of what matters is remarkably consistent. Here's the honest breakdown of how to reduce Flutter app size, in the order that actually pays off.

First, measure your Flutter app size — everything else is guessing

Before you touch a single asset, generate a size report. Flutter builds one for you:

flutter build apk --analyze-size --target-platform android-arm64

This prints a tree of where your bytes go — Dart AOT code, the Flutter engine, native libraries, assets, fonts — and writes a JSON snapshot to disk. Feed that JSON into the DevTools App Size tool (open DevTools → App Size → load the file) and you get a treemap plus a diff view.

The diff view is the part people sleep on. Save a baseline snapshot, make one change, generate a new snapshot, and diff them. Now "I think this helped" becomes "this removed 1.9 MB from libapp.so." I keep baseline snapshots checked into the repo for exactly this reason — size regressions are invisible until someone measures, and by then three PRs have each quietly added a fat dependency.

A few things to internalize when reading the report:

For iOS the equivalent is flutter build ios --analyze-size, though the App Store's thinning and on-demand resources change the delivered-size math in ways Android's model doesn't. The measurement discipline is identical: snapshot, change one thing, diff.

If you want this to hold over time, wire the analysis into CI. Generate a snapshot on every release build, store it as an artifact, and fail (or at least comment on) a PR whose libapp.so jumps by more than a threshold you pick. Size discipline that depends on someone remembering to check manually doesn't survive contact with a shipping deadline.

The single biggest win: don't ship four architectures

A default flutter build apk produces a fat APK containing native libraries for every ABI — armeabi-v7a, arm64-v8a, and x86_64. Every user downloads all of them and runs exactly one. That's the largest chunk of pure waste in most builds, and it's native code, so it doesn't compress away.

The fix is Android App Bundles, and you should already be on them because the Play Store requires the .aab format for new apps:

flutter build appbundle --release

An .aab isn't an installable artifact — it's a publishing format. It lets Play generate and serve a per-device APK at install time through split APKs: the user gets only their ABI, only their screen density's drawables, and only their language resources. You do nothing extra beyond uploading the bundle instead of an APK. This one change typically strips a third or more off the delivered native-library payload, and it costs you nothing.

If you distribute APKs directly — sideloading, a corporate MDM, F-Droid, or GitHub Releases like I do for my tools apps — you don't get Play's automatic splitting, so do it yourself:

flutter build apk --release --split-per-abi

This emits a separate APK per ABI instead of one fat one:

build/app/outputs/flutter-apk/  app-armeabi-v7a-release.apk  app-arm64-v8a-release.apk  app-x86_64-release.apk

Ship the arm64-v8a build as your primary download; it covers essentially every phone and tablet made in the last several years. Keep the armeabi-v7a one around only if you genuinely support old 32-bit hardware — and be aware it also matters for some Chromebooks and low-end devices. The x86_64 split is mostly for emulators; you rarely need to publish it. This is the highest-leverage change in the entire post and it's a single flag.

One caveat worth stating plainly: split-per-ABI means multiple artifacts to manage. If you host on GitHub Releases, upload each APK, label them clearly by architecture, and point most users at arm64-v8a. If you route users through a landing page, sniff the architecture or just default to arm64 and offer the others as a fallback link.

Tree-shaking icons and fonts — mostly automatic, easy to break

When you write Icons.settings, you're referencing a single glyph inside a font file that contains thousands of icons. Flutter's release build tree-shakes these — it detects which code points you actually use and subsets the font down to just those glyphs. You'll see it in the build log:

Font asset "MaterialIcons-Regular.otf" was tree-shaken, reducing itfrom 1645184 to 3572 bytes (99.8% reduction).

That's the difference between shipping a ~1.6 MB font and shipping a few kilobytes — for free, on every release build, with no config.

The trap is that icon tree-shaking only works when the icon set is statically analyzable. The moment you construct an IconData dynamically, the compiler can't prove which glyphs are reachable, so it conservatively keeps the entire font:

// Breaks tree-shaking — Flutter can't know which code point this isIconData dynamicIcon(int codePoint) =>    IconData(codePoint, fontFamily: 'MaterialIcons');// Fine — statically resolvable, gets subsettedconst icon = Icons.settings;

If you're building something data-driven — a settings screen that maps string keys from a CMS to icons, say — map those keys to const IconData references in an explicit switch or lookup table rather than reconstructing them from raw code points:

IconData iconForKey(String key) {  switch (key) {    case 'wifi':      return Icons.wifi;    case 'bluetooth':      return Icons.bluetooth;    case 'battery':      return Icons.battery_full;    default:      return Icons.help_outline;  }}

Every branch returns a compile-time-known glyph, so the subsetter can still prove exactly which code points you reach. If you truly must go fully dynamic and you see --tree-shake-icons silently disabled in the build output, either curate a small explicit icon subset (a custom font with just the glyphs you need) or accept that you're shipping the full font and budget for it. Don't reach for --no-tree-shake-icons just to make a warning disappear — that's how a megabyte sneaks back into every install.

The same subsetting applies to any icon font you bundle (Cupertino icons, FontAwesome wrappers, your own). The rule is the same everywhere: keep glyph references statically resolvable.

Deferred components — powerful, but pick your battles

Deferred loading lets you split Dart code (and its associated assets) out of the base install and download it on demand from Play. In Dart it's the deferred as modifier on an import; on Android it becomes a Play Feature Delivery dynamic feature module.

import 'package:my_app/editor/heavy_editor.dart' deferred as editor;Future<void> openEditor() async {  await editor.loadLibrary(); // fetched on demand from Play  editor.runEditor();}

This is genuinely powerful for the right shape of app — a large feature that a minority of users ever touch: a document editor, an AR mode, an ML-heavy flow with bundled TensorFlow Lite models, a pro-tier toolset. The base install drops that code and those assets, and users who never open the feature never pay the download for it.

But be honest about the cost, because deferred components add real complexity:

For a typical app where code is spread evenly across screens, there's no clean seam to cut and the payoff is small. I reach for deferred components when I have a heavy, isolated, optional feature — not as a general size strategy. And here's the diagnosis that saves you the most time: if your libapp.so is bloated because a single dependency is imported all over the codebase, deferring won't help — there's nothing to isolate. Removing or replacing the dependency is the fix. Use the size report to tell those two situations apart before you invest in the plumbing.

Assets: obvious, but do them last

Yes, compress images, prefer vector (.svg via flutter_svg) or WebP over PNG, drop unused files, and audit exactly what pubspec.yaml bundles. This matters — but it's usually kilobytes, not megabytes, and it's where people burn their time because it feels productive and gives a quick green checkmark.

Do it, but do it after the big three above. A few asset specifics that are worth the effort:

Key takeaways

The ranking almost never changes, and it maps directly to the sections above: App Bundle first, tree-shaking second, deferred components where the seam exists, assets last. Wrapping all of it is the same discipline — generate a size report, keep a baseline, and diff every suspicious PR. I've watched too many "size fix" PRs move nothing at all. Measure first, then pull the levers that actually weigh something.