Memory Leak Detection
4 min read
Performance
| Category | Example | Detection signal |
|---|---|---|
| Forgotten listeners | stream.listen(...) without subscription.cancel() | Subscription / closure count grows per screen open |
| Undisposed controllers | AnimationController, ScrollController, TextEditingController, ChangeNotifier | DevTools shows growing controller count |
| Closure-captured State | Timer.periodic((_) => setState(...)) not cancelled | State objects retained after navigation |
| Static / singleton holders | Service.callback = () => ... capturing widget | Singletons grow; State retained forever |
Code in action — the canonical fixes
// ❌ Leak: subscription never cancelled
class _LeakyState extends State<X> {
@override
void initState() {
super.initState();
firestore.collection('messages').snapshots().listen((_) => setState(() {}));
}
}
// ✅ Hold + cancel
class _OkState extends State<X> {
late final StreamSubscription _sub;
@override
void initState() {
super.initState();
_sub = firestore.collection('messages').snapshots().listen((_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_sub.cancel();
super.dispose();
}
}
// ❌ Leak: setState after async gap with disposed widget
Future<void> _load() async {
final data = await api.fetch();
setState(() => _data = data); // 💥 if user left screen
}
// ✅ Guard with mounted
Future<void> _load() async {
final data = await api.fetch();
if (!mounted) return;
setState(() => _data = data);
}
// ❌ Singleton holding callback that captures State
class Notifier {
static VoidCallback? onTap;
}
class _ScreenState extends State<Screen> {
@override
void initState() {
super.initState();
Notifier.onTap = () => setState(() {}); // captures this State
}
}
// ✅ Clear the reference in dispose
@override
void dispose() {
if (Notifier.onTap == _onTap) Notifier.onTap = null;
super.dispose();
}
Detection workflow
| Step | Tool / signal |
|---|---|
| 1. Reproduce: navigate to a screen → away → repeat 5-10× | Memory chart climbs |
| 2. Take heap snapshot A, navigate, force GC, take snapshot B | DevTools Memory tab |
| 3. Diff A vs B — instances that survived multiple navigations are leaks | Filter by _MyScreenState, StreamSubscription, etc. |
| 4. Walk "incoming references" back to the root reference | DevTools "show retainers" |
5. Fix at the root — usually a missing cancel/dispose/null | One change |
| 6. Re-snapshot to confirm count drops to 0 | Diff again |
Automated detection — leak_tracker
// pubspec.yaml
dev_dependencies:
leak_tracker_flutter_testing: any
// In your test setup
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
void main() {
LeakTesting.enable(); // any widget test that leaks → fail
testWidgets('screen disposes cleanly', (tester) async {
await tester.pumpWidget(const MyScreen());
await tester.pumpAndSettle();
await tester.pumpWidget(const SizedBox()); // unmount
// If MyScreen leaked anything, the test fails here
});
}
This catches leaks in CI before they ship — far cheaper than hunting them in production.
Common mistakes to avoid
❌ "I'll add dispose later" — never do
Every initState() that registers/subscribes must have a matching dispose().
Make it a habit while writing the code, not retroactively.
❌ Storing BuildContext in a service / global
class Service { BuildContext? ctx; }
The Element tree can't be GC'd until you null it.
❌ Lazy late fields that escape dispose
late final _ctrl = AnimationController(vsync: this, ...);
If you forget dispose, ticker debug-asserts; in release it just leaks.
❌ Forgetting to cancel timers in dispose
Timer.periodic(...) — that closure captures everything in scope.
❌ Loading full-resolution images
Not a leak per se, but kills memory. Use cacheWidth/Height or ResizeImage.
❌ Skipping mounted checks after every await
The first time a user navigates fast enough to crash your app, you'll wish you had.
❌ Assuming GC will clean up "eventually"
GC can't collect what's still reachable. Leaks aren't a GC failure, they're a
reference-graph failure.
Interview follow-ups
-
What's the difference between a leak and high memory usage? A leak: objects stay alive that should be collected — memory climbs without bound. High usage: lots of live objects you genuinely need (a big image cache, a long history). The fix is different: leak = find the dangling reference; usage = add eviction / pagination.
-
How does
leak_trackerfind leaks during tests? It uses weak references and finalisers to track instances that should have been GC'd by the end of a test but weren't. Anything still alive afterpump(disposed)+ GC is flagged. Built intoflutter_testsince 3.16, so even with minimal setup most leaks surface. -
Why do you need
if (mounted)afterawait? Because the State may have been disposed during the async gap (user navigated away). CallingsetStateon a disposed State throws and crashes the app. The check is a one-line preventer that should follow everyawaitin widget code. -
What's the relationship between disposing an
AnimationControllerand Ticker assertions? EachAnimationControllerregisters a Ticker that subscribes to vsync frame callbacks. The Ticker holds a reference to its TickerProvider (your State). If you don't dispose the controller, the Ticker stays subscribed and the State can't be GC'd. Flutter asserts on this in debug ("ticker was active when its TickerProvider was disposed") — the engine helping you find your own bug.
How helpful was this content?
Please sign in to rate this article.