Every Flutter engagement we join eventually gets the same two complaints from a stakeholder holding a mid-range Android phone: "the download is huge" and "it takes forever to open". Both are measurable, both are budgetable, and both are usually fixed by the same handful of changes. This tutorial is the checklist we work through on client apps, in the order that pays back fastest.
We assume Flutter 3.3x/3.4x with Impeller on, Dart 3, and an app already shipping to both stores.
Step 0: agree on the budget before you optimise
Optimisation without a number is theatre. Pick three numbers and write them into the definition of done:
- Download size on the store listing (not the build artifact) — e.g. under 30 MB on Android, under 60 MB on iOS over cellular.
- Cold start to first frame — e.g. p90 under 1.8 s on the lowest-tier device in your support matrix.
- Time to first useful frame — the moment the user can act, not the moment a spinner appears.
The third number is the one users actually feel, and it is the one teams forget to measure.
Step 1: measure the size that users see
flutter build apk produces an artifact whose size is nearly meaningless. The store recompresses, splits by ABI, and strips. Measure the delivered size.
# Android: build the bundle the store actually gets
flutter build appbundle --release --analyze-size \
--target-platform android-arm64
# iOS
flutter build ipa --release --analyze-size
--analyze-size writes a JSON tree to .dart_tool/flutter_build/ and prints a summary. Open it in DevTools for a treemap:
dart devtools --appSizeBase=path/to/apk-code-size-analysis_01.json
For Android, the authoritative number is in Play Console → App bundle explorer → Download size per device configuration. For iOS, App Store Connect → your build → App Store File Sizes. Screenshot both before you start; you will need the before/after for the client.
To compare two releases and see what regressed:
dart devtools \
--appSizeBase=before_size_analysis.json \
--appSizeTest=after_size_analysis.json
That diff view is the single most useful tool in this whole post. It attributes every kilobyte to a package, a library, or an asset file.
Step 2: the size wins, in order of payback
Assets are almost always the biggest offender
In the treemaps we have run on client apps, assets beat Dart code roughly four times out of five. Common findings:
- PNG splash and onboarding art exported at 4x for every density.
- A 3 MB Lottie file for a 1.2 s animation.
- Two icon fonts where one would do.
- An unused
assets/folder still declared inpubspec.yaml.
Fixes:
# pubspec.yaml — declare files, not folders, once you are optimising
flutter:
assets:
- assets/images/empty_state.webp
- assets/images/logo.svg
Convert raster art to WebP (lossy for photos, lossless for flat art) and flat illustrations to SVG rendered with flutter_svg or, better, precompiled .vec via vector_graphics_compiler:
# one-off conversion
cwebp -q 82 onboarding.png -o onboarding.webp
# SVG -> compiled vector, much cheaper to parse at runtime
dart run vector_graphics_compiler --input-dir assets/svg --out-dir assets/vec
Ship one high-density raster and let Flutter scale down, rather than shipping 1x/2x/3x variants, unless you have measured quality loss.
Fonts: subset, do not bundle
A full variable font family can cost 1–2 MB. Flutter tree-shakes icon fonts automatically in release builds (you will see Font asset ... tree-shaken, reducing it by 99% in build output) — but only for IconData constants known at compile time. If you build icons dynamically:
// Kills icon tree-shaking — the compiler cannot prove which glyphs are used.
Icon(IconData(codePoint, fontFamily: 'MaterialIcons'));
Either avoid it, or accept the cost and pass --no-tree-shake-icons knowingly instead of being surprised by a 1.6 MB regression.
For text fonts, subset to the scripts you actually support:
pyftsubset Inter.ttf --unicodes="U+0000-00FF,U+2000-206F" \
--layout-features="kern,liga" --flavor=woff2 --output-file=Inter-subset.ttf
If you use google_fonts, remember the default behaviour downloads at runtime — great for size, bad for first-run offline. Decide deliberately, and bundle the two weights you use rather than the family.
Build flags that cost nothing to turn on
flutter build appbundle --release \
--obfuscate --split-debug-info=build/symbols \
--dart-define=FLAVOR=prod
--split-debug-info moves the Dart symbol table out of the binary; on a medium app that is typically 2–6 MB. Keep the build/symbols directory as a CI artifact — you need it to symbolicate crash reports later, and losing it means unreadable stack traces forever.
On Android, confirm R8 is actually running and that your Play listing uses an app bundle, not a universal APK:
// android/app/build.gradle
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
Audit dependencies with the treemap open
Every plugin drags in native code. The usual suspects we end up removing on client audits:
- A full mapping SDK used for one static map image — replace with a tiles image request.
- An ML/vision package retained after the feature was cut.
- Three HTTP layers (
http,dio, and a generated client) because three teams touched the app. - A charting library used on one screen, replaceable with
CustomPainter.
dart pub deps --style=compact # who pulls in what
dart run dependency_validator # unused / under-promoted deps
Deferred components (Android) for genuinely optional features
If a large feature is used by a minority of users, split it off with deferred loading. On Android, Flutter maps this to Play Feature Delivery:
import 'package:myapp/reporting/reporting.dart' deferred as reporting;
Future<void> openReports(BuildContext context) async {
await reporting.loadLibrary();
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => reporting.ReportsScreen()),
);
}
This is real engineering overhead — manifest changes, a loading state, and testing on Play internal test tracks. Reach for it only after assets, fonts and dependencies are clean, and only when the deferred feature is worth several megabytes.
Step 3: measure cold start honestly
Cold start means the process is not in memory. Measure it on a low-tier physical device, not a simulator, and force the cold condition each run.
# Android: 10 cold starts, report displayed time
adb shell am force-stop com.example.app
adb shell am start-activity -W -n com.example.app/.MainActivity | grep TotalTime
Better, use Flutter's own instrumentation, which reports the phases that matter:
flutter run --profile --trace-startup --verbose
# writes build/start_up_info.json
{
"engineEnterTimestampMicros": 4126002,
"timeToFrameworkInitMicros": 214553,
"timeToFirstFrameRasterizedMicros": 883412,
"timeToFirstFrameMicros": 921004
}
Track these four numbers in CI on a fixed device. A regression alert on timeToFirstFrameMicros catches the "someone added a synchronous SDK init to main()" bug the day it lands instead of at the next release.
In production, report the same moment through your analytics or FirebasePerformance custom trace, keyed by device model, and read the p90 rather than the mean — the mean hides exactly the users who complain.
Step 4: the cold start wins, in order of payback
Stop doing work in main()
The standard anti-pattern:
// Before: everything blocks the first frame.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await analytics.init();
await remoteConfig.fetchAndActivate(); // network on the critical path!
await Hive.initFlutter();
await featureFlags.load();
runApp(const MyApp());
}
Only initialisation that the first screen genuinely needs belongs before runApp. Everything else moves behind the first frame:
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp()); // first frame ASAP
// Fire after the first frame is on screen.
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_initSecondaryServices());
});
}
Future<void> _initSecondaryServices() async {
await Firebase.initializeApp();
unawaited(analytics.init());
unawaited(remoteConfig.fetchAndActivate()); // cached values serve meanwhile
}
Remote config, A/B assignment and feature flags should always serve a cached or default value on the first run and refresh in the background. Awaiting a network call before the first frame makes your cold start a function of the user's signal strength.
Storage that the first screen needs (a session token, a local DB handle) still has to be awaited — but move it off the platform thread and keep it small. Reading one token from secure storage is fine; opening and migrating a 60 MB SQLite database is not, so show the first frame and stream the data in.
Make the splash-to-content handoff seamless
Use flutter_native_splash to generate the platform splash (Android 12+ SplashScreen API, iOS storyboard) and keep it on screen until the framework is ready, so users never see a white flash:
void main() {
final binding = WidgetsFlutterBinding.ensureInitialized();
FlutterNativeSplash.preserve(widgetsBinding: binding);
runApp(const MyApp());
}
// In the first screen, once the minimum data is ready:
FlutterNativeSplash.remove();
Do not use this as a place to hide slow initialisation — a 3 s splash is still a 3 s cold start. Removing it as early as possible, then rendering skeletons, measures and feels better.
Trim the first frame's widget tree
The first route should be cheap. Common mistakes: a MaterialApp whose home builds five tabs eagerly, an IndexedStack constructing every page up front, a theme built from a ColorScheme.fromImageProvider (which decodes an image before you can render).
- Lazy-build tabs with
PageView/Navigatorper tab rather thanIndexedStackat launch. - Use
constconstructors aggressively; they skip rebuild work and reduce code size. - Defer
precacheImagefor anything below the fold to a post-frame callback.
Shader and jank at launch
With Impeller, the old "shader warm-up / SkSL" ritual is gone on iOS and on Android (Vulkan and the GLES fallback). If your codebase still passes --bundle-sksl-path, delete it — you are shipping a file that does nothing. If launch still janks on Android, profile it with DevTools' timeline rather than assuming shaders; the cause is usually a synchronous plugin call on the platform thread.
Android-specific: startup profiles and app bundles
Baseline/startup profiles help the ART runtime pre-compile hot paths. Play generates a cloud profile automatically after enough installs, but for the first release you can ship one via the androidx.profileinstaller dependency that most recent Flutter templates already include. Verify it is present rather than adding it twice.
A realistic before/after
An audit we ran on a retail client's app, over roughly four days of work:
| Metric | Before | After |
|---|---|---|
| Play download size (arm64) | 48.1 MB | 21.4 MB |
| iOS App Store size | 92 MB | 57 MB |
| p90 cold start, mid-tier Android | 3.4 s | 1.6 s |
| Time to first useful frame | 4.9 s | 1.9 s |
Where it came from: 14 MB of unsubsetted raster assets and duplicated 3x PNGs, 5 MB of Dart symbols moved out with --split-debug-info, a 6 MB vision SDK left over from a cancelled feature, and — for the start-up number — four awaited initialisations moved behind the first frame, of which one was a remote config fetch over the network.
None of that was clever. It was a treemap, a stopwatch, and the discipline to keep both in CI.
Keep it from regressing
Add two gates to your pipeline:
- Size gate. Build with
--analyze-sizeon every release branch, compare the total against the previous tag, and fail the build on a regression above a threshold (say 500 KB) without an explicit override label. - Start-up gate. Run
flutter run --profile --trace-startupon a fixed physical device or a pinned emulator image in CI, parsebuild/start_up_info.json, and fail on a regression intimeToFirstFrameMicros.
Both are a few lines of shell around JSON you already generate. Without them, every optimisation you just made will be quietly undone within two release cycles.
Need this done on your app? AviaryApps runs fixed-scope Flutter size and start-up audits, and our senior Flutter consultants work alongside in-house teams — or as a subcontracted delivery team for agencies. Get in touch with your current store size and target, and we will tell you what is realistically recoverable.