- Published on
Flutter Performance Part 4: Detoxing RAM & Shedding App Size
- Authors

- Name
- Phat Tran
An app can hold a perfect 60 FPS and still get killed. If your banking app vanishes back to the home screen after ten minutes of browsing, you are not looking at a rendering problem. You are looking at an Out-Of-Memory (OOM) crash: the app ate too much RAM, and iOS or Android terminated it without so much as an error dialog.
The frustrating part is that OOM crashes rarely reproduce at your desk. They happen on the cheap devices your users actually own. So this part is about the two things that decide whether you survive there: what you keep in RAM, and how much you ship in the binary.
1. The number one culprit: image decoding
By far the most common cause of OOM crashes in Flutter apps is mishandling network images.
Say your app shows friends' avatars in a 50x50 circle, and the backend serves the original 4K uploads. A plain Image.network(url) downloads the 4K file and decodes the entire bitmap into RAM just to draw it into that tiny circle. A decoded 4K image is tens of megabytes. Put 100 of them in a list and the OS kills you.
The file size on disk is not the problem. The decoded bitmap is. So resize at decode time:
// Bad: decodes the full 4K image into RAM.
CircleAvatar(
radius: 25,
backgroundImage: NetworkImage(user.avatarUrl),
);
// Good: forces the engine to decode a small, memory-friendly bitmap.
CircleAvatar(
radius: 25,
backgroundImage: ResizeImage(
NetworkImage(user.avatarUrl),
width: 100, // Roughly 2x the radius for high-DPI screens
height: 100,
),
);
ResizeImage (or the cacheWidth/cacheHeight parameters on Image) caps the decoded size regardless of what the server sends. This one change can reclaim hundreds of megabytes in an image-heavy app. The real fix is a backend that serves thumbnails, but the client-side cap protects you either way.
2. Plugging memory leaks
A leak is an object the garbage collector cannot free because something still holds a reference to it. In Flutter that "something" is usually a widget that forgot to clean up:
- Every
TextEditingController,ScrollController, andAnimationControllerneeds a matchingdispose()in yourState'sdisposemethod. - Every
Timer.periodicand every stream subscription (RxDart subjects included) must be cancelled when the user leaves the screen. A leaked periodic timer is worse than a leaked object, because it keeps doing work forever. - And then there is the async gap: you start a 5-second API call, the user backs out after 2 seconds, the widget dies, and your callback fires anyway against a dead widget.
// Good: always check the widget still exists after an async gap.
Future<void> fetchBalance() async {
final balance = await api.getBalance();
// If the user left the screen, do not update the state!
if (!context.mounted) return;
setState(() {
_balance = balance;
});
}
You do not have to hunt leaks blind. The Memory view in DevTools can diff heap snapshots between two points in time, and the leak_tracker package can flag undisposed objects in your tests before they ever reach production.
3. Shedding app size
Nobody wants to download 150MB to check a bank balance. Oversized apps lose installs, and every store listing shows the number.
Start by finding out where the weight actually is:
# Analyze your Android APK
flutter build apk --analyze-size
# Analyze your iOS app
flutter build ipa --analyze-size
The command produces a size map you can open in DevTools and inspect package by package. The usual suspects: PNG onboarding illustrations that should be WebP or vector, a full font family bundled when you use two weights, and one heavy third-party package nobody remembers adding.
Beyond asset cleanup, a few build flags do a lot of work:
- Ship an app bundle (
flutter build appbundle) so Google Play delivers each device only the native code for its own ABI. If you must distribute APKs,--split-per-abidoes the same job manually. --split-debug-infomoves Dart symbol tables out of the binary (keep the files for de-obfuscating crash reports), and--obfuscateshrinks and scrambles what remains.- Icon fonts are tree-shaken automatically, so prefer
Icons.*constants over bundling icon images.
Run the analysis before every major release. Size regressions creep in one dependency at a time, and the map makes them obvious while they are still cheap to remove.
Up next: breaking the speed limit with Rust
Dart is optimized, memory is under control, the binary is lean. But what happens when Dart itself is the bottleneck? The final Part 5 covers FFI: calling native Rust code with almost no overhead, and knowing when that power is worth its cost.