A memory leak happens when your app holds on to objects it no longer needs, so the Dart garbage collector can't free them.
On a modern phone with 8 GB of RAM, a leak can hide for a while. On an older phone with 2 or 3 GB, it's a different story: the operating system kills your app in the background, or it crashes while someone is using it. Your crash reports fill up with devices you never tested on.
The most common Flutter leaks have one thing in common: something outlives its screen.
1-3. Controllers you never disposed
AnimationController, TextEditingController and ScrollController all hold resources. If you create one in a State, you dispose it in that State.
class _ProfileFormState extends State<ProfileForm>
with SingleTickerProviderStateMixin {
late final AnimationController _fade;
final _email = TextEditingController();
final _scroll = ScrollController();
@override
void initState() {
super.initState();
_fade = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
}
@override
void dispose() {
_fade.dispose();
_email.dispose();
_scroll.dispose();
super.dispose();
}
// build(...)
}
Rule of thumb: if it was created in initState, it gets cleaned up in dispose.
4. Timers and stream subscriptions
A periodic timer or a stream subscription keeps running after the user leaves the screen, and keeps the State alive with it.
Timer? _poll;
StreamSubscription<Order>? _orders;
@override
void initState() {
super.initState();
_poll = Timer.periodic(const Duration(seconds: 30), (_) => _refresh());
_orders = orderStream.listen(_onOrder);
}
@override
void dispose() {
_poll?.cancel();
_orders?.cancel();
super.dispose();
}
cancel(), not just setting the variable to null.
5. A stored BuildContext
Never store a BuildContext in a long-lived variable or a singleton:
// Don't do this
class Navigation {
static late BuildContext context; // keeps a whole screen alive
}
If you hold on to the context of a screen after the user has navigated away, that entire screen and all of its widgets stay in memory. Pass context into the method that needs it, use it, and let it go. For app-wide navigation, use a navigator key or your router instead.
How to catch what slips through
Open Flutter DevTools and watch the Memory tab while you navigate in and out of a screen a few times. Memory should go up and come back down. A graph that only ever climbs is a leak.
Test on the oldest, cheapest device you can get. Leaks that never show on a flagship show up there within minutes.
The checklist
- [ ] Every controller created in
initStateis disposed indispose - [ ] Every
Timeris cancelled - [ ] Every
StreamSubscriptionis cancelled - [ ] No
BuildContextstored in a field that outlives the widget - [ ] Memory graph checked in DevTools on a low-RAM device
Further reading: Optimizing Flutter app performance for older devices on our blog also covers jank fixes and image caching.
What's the oldest device in your test lab?
Top comments (0)