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.
How helpful was this content?
Please sign in to rate this article.