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.

  4. What's the value of forcing a GC before taking a snapshot? To rule out "garbage that hasn't been collected yet." After a forced GC, what remains is genuinely reachable. Without GC, your snapshot includes objects that would be collected on the next cycle — false positives.


How helpful was this content?

Please sign in to rate this article.