Size comes from three separate places and they need different levers. There is a floor you cannot move — the engine and framework. There is what you added, which usually dominates and which no build flag fixes, because it is mostly assets. And there is how you ship it, which changes what a user downloads without changing the build at all, and is typically the largest single win. Teams reliably spend their effort on the middle row's flags while ignoring the other two.
"The app is too big" arrives late in most Flutter projects, usually from someone in marketing with a conversion chart or someone in a market where mobile data is metered. The response is usually a hunt for build flags, which is the least effective of the three available moves.
Before any of it: the published savings for every technique below vary by a factor of two or more between sources, because they depend entirely on what your app contains. Measure with --analyze-size on your own build. Any article that promises you a percentage — including the figures quoted here, which are other people's — is describing a different app.
Three places, three levers
Start with how you ship it
Shipping an app bundle rather than a universal APK lets the store generate device-specific packages — handling architecture splitting, screen density resources and language resources automatically, so a device downloads only what it needs. No code change, and it is usually the largest single reduction in what users actually receive.
If you distribute outside a store — which for enterprise and banking apps is common — --split-per-abi does the architecture part manually. A universal APK carries native code for every processor architecture and each device uses exactly one. The reported saving is where the sources disagree most sharply: one puts it at 60–70% per APK, another at 30–40%. Both are plausible for different apps, which is precisely why the number to act on is yours.
Then look at what you added — starting with assets
This is the row teams get wrong, because the instinct is to reach for compiler flags and the dominant contributor is usually a folder of images nobody has audited since the first sprint.
Uncompressed PNGs, multiple resolutions of assets the app no longer uses, a full icon font for the nine icons you reference, fonts shipped in every weight when you use two. None of that is addressed by any build flag, and all of it is visible in five minutes with --analyze-size.
Tree shaking handles the code side and runs on Dart in release builds, but it works on static references — anything the compiler cannot prove unused stays in. The classic way to defeat it is a dynamic lookup: build an icon name or a class reference from a string at runtime and the compiler must keep everything it could resolve to. For icons specifically, --tree-shake-icons is the dedicated flag, with one source reporting 5–8 MB saved, depending entirely on how many icon fonts you carry.
--obfuscate --split-debug-info contributes too, though its main job is keeping obfuscated stack traces readable rather than shrinking the binary.
Deferred components: sometimes
These split Dart code and assets into modules installed later rather than shipping in the initial download. The public guidance is inconsistent enough that it is worth checking the official docs for your Flutter version before committing: one source notes they are not ideal for every app or plugin combination, another says they pay off mainly when the deferred screen is used by a minority of users, and one guide claims deferred imports apply mainly to web — which directly contradicts the Android-focused material.
Our reading is that they are a real tool for a genuinely optional and genuinely large feature, and unjustified complexity for anything else. A cheque-scanning module most customers never open is a candidate. Your settings screen is not.
Why a bank should care
Size reads as an engineering vanity metric until you look at where it bites.
An abandoned download is an abandoned onboarding, and the abandonment concentrates exactly where growth is. In markets with metered mobile data, a large download has a cost the customer can see before they commit to it. On low-end Android devices, storage pressure is real and an app competing for space loses to one that is not. And enterprise distribution multiplies every megabyte by the fleet.
For a bank building across the Gulf, wider MENA or Africa — which is where most OBDX deployments we work on actually serve customers — these are not marginal. They are the difference between a customer completing onboarding on a train and giving up on it.
A sensible order of work
Measure with --analyze-size. Switch to an app bundle if you have not. Audit the assets folder, which will take an afternoon and usually produce the second-largest win. Add --tree-shake-icons and the obfuscation flags. Re-measure. Only then consider deferred components, and only for something genuinely optional.
And measure the download a real device would receive, not the artefact on your build machine — once the store repackages, those are different numbers, and the second one is the only one a customer experiences.
If you are weighing Flutter against the alternative for an enterprise build more broadly, we covered that separately in Flutter vs React Native for enterprise and whether Flutter is enterprise-ready.
Questions we get asked
What's the first thing to do?
Ship an app bundle rather than a universal APK, and measure first. The store then generates device-specific packages — architecture, screen density and language resources handled automatically. No code change, and usually the largest single cut in what users download. Measure with --analyze-size rather than trusting a published percentage.
Does per-ABI splitting help?
Yes for direct distribution, and the sources disagree enough to make measuring essential — one reports 60–70% per APK, another 30–40%. A universal APK carries native code for every architecture and a device uses one. If your store accepts app bundles it already does this; per-ABI matters when you distribute outside one, which is common for banking apps.
When does tree shaking fail?
When references aren't static. Tree shaking removes unreferenced Dart code in release builds, but anything the compiler can't prove unused stays. Building an icon name or class reference from a string at runtime forces it to keep everything that could match. --tree-shake-icons is the dedicated flag; one source reports 5–8 MB, depending on your icon fonts.
Are deferred components worth it?
Sometimes, and check the docs for your version — the public guidance conflicts, with one guide even claiming they're mainly for web against Android-focused material saying otherwise. They suit a genuinely optional, genuinely large feature: a cheque-scanning module most customers never open, yes; your settings screen, no.
Why does size matter for a banking app?
An abandoned download is an abandoned onboarding, and it concentrates where growth is. Metered data makes the cost visible before the customer commits; low-end Android storage pressure means you lose to apps that fit; enterprise distribution multiplies every megabyte by the fleet. For the Gulf, MENA and Africa these aren't marginal.
How should we measure?
--analyze-size, on your own app, before and after each change. Published figures vary by a factor of two because they depend on what the app contains. Measure the download a real device receives, not the build-machine artefact. And check the assets folder early — uncompressed images are often the biggest contributor and no flag touches them.
We build Flutter apps for banks and enterprises, often for markets where download size has a direct commercial cost. If someone has quoted you a percentage saving without asking what your app contains, it was not a measurement.