Memory Profiler: heap snapshots, GC diffing, and leak hunting

Low PriorityAsked in ~40% of mid-level interviews

3 min read

DevTools

Dart is garbage-collected; there's no free(). A "leak" means an object stayed reachable when it shouldn't have. Common Flutter culprits:

SuspectWhy it leaks
StreamController not closedListeners + buffered events stay alive
StreamSubscription not cancelledHolds the source AND the closure (often the State)
AnimationController / Ticker not disposedActive ticker keeps State alive forever
Timer not cancelledClosure keeps captured State alive
Long-lived singletons referencing widget-layer objectsSingleton outlives the widget; widget can't be GC'd
BuildContext stored in services / global statePins the entire Element tree
Image cache holding decoded full-size imagesMemory bloat (not strictly a leak)
Listeners registered without removal pairsNotifier holds widget reference forever

Workflow — heap snapshot diffing

  1. Run in profile mode: flutter run --profile.
  2. DevTools → Memory tab.
  3. Navigate to the screen you suspect (e.g., a detail page).
  4. Take Heap Snapshot A.
  5. Navigate away and back several times (you want the leak to be visible).
  6. Force a GC (button in DevTools).
  7. Take Heap Snapshot B.
  8. Diff A vs B. Look for instance counts that grew — these are objects retained across navigations.
  9. Drill into the type → "incoming references" → walk back to the reference that's keeping it alive.

The diff strips out everything that GC'd cleanly; what remains is your leak surface.


Code in action — the four anti-leak habits

class _ScreenState extends State<Screen> with SingleTickerProviderStateMixin {
  late final _stream = api.events.listen(_onEvent);     // subscription
  late final _ctrl   = AnimationController(vsync: this, duration: ...);
  late final _timer  = Timer.periodic(...);
  late final _text   = TextEditingController();
 
  @override
  void dispose() {
    _stream.cancel();                                   // 1️⃣ cancel subs
    _ctrl.dispose();                                    // 2️⃣ dispose controllers
    _timer.cancel();                                    // 3️⃣ cancel timers
    _text.dispose();                                    // 4️⃣ dispose text controllers
    SomeService.instance.onUpdate = null;               // 5️⃣ clear external refs
    super.dispose();
  }
}

Symptom → likely cause

SymptomFirst suspect
Memory climbs every time you open the same screenSubscription / controller not disposed
Memory climbs over an hour, never resetsCache with no eviction; growing log buffer
Spike on opening a screen with a big imageLoading full-resolution image; missing cacheWidth
Out-of-memory crash on long sessionsCombination — start with disposal audit
Steady leak per navigationGlobalKey outliving its widget; BuildContext captured by a service

Common mistakes to avoid

❌ Profiling in debug mode
   Asserts/instrumentation skew memory; numbers aren't trustworthy.
   ✅ flutter run --profile

❌ Taking one snapshot and panicking at the number
   Absolute numbers vary with GC timing. Always diff.

❌ Trusting "Force GC" too quickly
   GC has its own scheduler. Take the snapshot AFTER triggering GC, not at the same moment.

❌ Forgetting to compare AFTER multiple repetitions of the action
   A single repeat may not show the trend. Repeat 5-10x to amplify the signal.

❌ Fixing the wrong reference
   A retained object often has multiple references — fix the one keeping the OBJECT alive,
   not just one of many references.

Interview follow-ups

  1. What's the difference between a leak and high memory usage? A leak is an object kept alive that should have been collected — memory climbs without bound. High usage is just a lot of live objects (e.g., a big image cache); GC won't help because they're still in use. Leak diagnosis = "find what's reachable that shouldn't be"; usage diagnosis = "find what's being kept on purpose."

  2. How does StreamSubscription keep a widget alive? The subscription holds a reference to its onData callback. That callback typically captures this (the State). So the source stream → subscription → callback → State chain keeps the entire State (and everything it references) alive. Cancel the subscription, the chain breaks.

  3. Why is storing BuildContext in a service a leak? BuildContext is an Element. Holding it keeps that Element (and its ancestors) alive — effectively pinning a chunk of the widget tree even after the user has navigated away. Pass context only at call time, never store it.


How helpful was this content?

Please sign in to rate this article.