Memory Leak Detection

Medium PriorityAsked in ~60% of senior interviews

4 min read

Performance

CategoryExampleDetection signal
Forgotten listenersstream.listen(...) without subscription.cancel()Subscription / closure count grows per screen open
Undisposed controllersAnimationController, ScrollController, TextEditingController, ChangeNotifierDevTools shows growing controller count
Closure-captured StateTimer.periodic((_) => setState(...)) not cancelledState objects retained after navigation
Static / singleton holdersService.callback = () => ... capturing widgetSingletons 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

StepTool / signal
1. Reproduce: navigate to a screen → away → repeat 5-10×Memory chart climbs
2. Take heap snapshot A, navigate, force GC, take snapshot BDevTools Memory tab
3. Diff A vs B — instances that survived multiple navigations are leaksFilter by _MyScreenState, StreamSubscription, etc.
4. Walk "incoming references" back to the root referenceDevTools "show retainers"
5. Fix at the root — usually a missing cancel/dispose/nullOne change
6. Re-snapshot to confirm count drops to 0Diff 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

  1. 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.

  2. How does leak_tracker find 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 after pump(disposed) + GC is flagged. Built into flutter_test since 3.16, so even with minimal setup most leaks surface.

  3. Why do you need if (mounted) after await? Because the State may have been disposed during the async gap (user navigated away). Calling setState on a disposed State throws and crashes the app. The check is a one-line preventer that should follow every await in widget code.

  4. What's the relationship between disposing an AnimationController and Ticker assertions? Each AnimationController registers 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.