Memory Profiler: heap snapshots, GC diffing, and leak hunting
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:
| Suspect | Why it leaks |
|---|---|
StreamController not closed | Listeners + buffered events stay alive |
StreamSubscription not cancelled | Holds the source AND the closure (often the State) |
AnimationController / Ticker not disposed | Active ticker keeps State alive forever |
Timer not cancelled | Closure keeps captured State alive |
| Long-lived singletons referencing widget-layer objects | Singleton outlives the widget; widget can't be GC'd |
BuildContext stored in services / global state | Pins the entire Element tree |
| Image cache holding decoded full-size images | Memory bloat (not strictly a leak) |
| Listeners registered without removal pairs | Notifier holds widget reference forever |
Workflow — heap snapshot diffing
- Run in profile mode:
flutter run --profile. - DevTools → Memory tab.
- Navigate to the screen you suspect (e.g., a detail page).
- Take Heap Snapshot A.
- Navigate away and back several times (you want the leak to be visible).
- Force a GC (button in DevTools).
- Take Heap Snapshot B.
- Diff A vs B. Look for instance counts that grew — these are objects retained across navigations.
- 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
| Symptom | First suspect |
|---|---|
| Memory climbs every time you open the same screen | Subscription / controller not disposed |
| Memory climbs over an hour, never resets | Cache with no eviction; growing log buffer |
| Spike on opening a screen with a big image | Loading full-resolution image; missing cacheWidth |
| Out-of-memory crash on long sessions | Combination — start with disposal audit |
| Steady leak per navigation | GlobalKey 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
-
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."
-
How does
StreamSubscriptionkeep a widget alive? The subscription holds a reference to itsonDatacallback. That callback typically capturesthis(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. -
Why is storing
BuildContextin a service a leak?BuildContextis 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. -
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.