Dart 82 🎯 Memory Leak Detection and Tracking Uncollected Objects
A memory leak in Dart is not an object that was never freed by the garbage collector. It is an object that remains reachable from a GC root long after the application has stopped needing it. The garbage collector does its job perfectly: it collects everything unreachable. The leak exists because something still references the object—a global list, a static cache, a listener that was never removed, a closure that captured more than intended. Detecting these leaks requires seeing reachability, not just memory totals.
Dart provides two complementary approaches to this problem. DevTools’ Memory view offers snapshot diffing and retaining path inspection: you capture the heap at two points, compare them, and follow the reference chain from a GC root to the surviving object. The leak_tracker package, integrated into Flutter’s testing framework, takes a different approach: it instruments disposable objects and asserts that disposal and garbage collection happen in the expected order, failing tests when they do not. Together, these tools cover both manual investigation and automated regression prevention.
This chapter covers the mechanics of reachability and retaining paths, the DevTools diff workflow for finding uncollected objects, the leak_tracker model of leak types (not-disposed, not-GCed, and GCed-late), the culprit-vs-victim distinction that focuses your fix on the right object, and the role of WeakReference in breaking unintended retention. It assumes familiarity with heap snapshots and the general DevTools memory workflow covered in the previous chapter.
Key point: An uncollected object is not a GC failure—it is a reachability problem. The fix is always to break the reference that keeps the object alive, and the retaining path tells you which reference to break.
Why leak detection exists as a discipline
The GC’s guarantee and its limit. Dart’s garbage collector guarantees that every unreachable object will eventually be collected. It makes no guarantee about reachable objects. If a TextEditingController is disposed but still referenced by a field in a long-lived State object, the GC sees a reachable controller and leaves it alone. The memory it holds—listeners, text buffers, internal structures—remains allocated. This is not a bug in the GC; it is the boundary of its responsibility. The application must ensure that objects it no longer needs are no longer reachable.
Disposal and collection are different events. A disposable object (one with a dispose() method) can be disposed without being collected, and collected without being disposed. Flutter’s leak_tracker framework formalizes this: proper memory management means disposal is followed by collection within a small number of GC cycles . When disposal happens but collection does not, something retains the object. When collection happens without disposal, the object’s disposable contents leaked memory before the GC stepped in. Tracking both events catches both failure modes.
The culprit-victim problem. A single leak often manifests as many uncollected objects. A retained State object holds a BuildContext, which holds an Element tree, which holds widgets. The State is the culprit; the BuildContext and Element are victims . Fixing the victim is wasted effort: even if you break the reference from the victim, the culprit still retains it. Leak detection tools identify culprits—leaked objects that are not victims of another leaked object—so you fix the root cause rather than chasing symptoms.
Manual detection does not scale. DevTools snapshots are excellent for investigation: they show you exactly what is retained and why. But they require a human to capture, diff, and interpret. They cannot run in CI on every commit. The leak_tracker package fills this gap by turning leak detection into an assertion: if a disposable object is created and disposed in a widget test, and it is not collected after a few GC cycles, the test fails . This makes leaks regression-testable.
The WeakReference escape hatch. Sometimes retention is intentional but should not be permanent. A cache should hold a reference to a computed value, but that reference should not prevent the value from being collected if memory pressure demands it. WeakReference<T> provides exactly this: a reference that does not count as a strong reference for GC purposes . When all strong references disappear, the weak reference’s target becomes null. Caches built on weak references purge themselves automatically.
a. Reachability, retaining paths, and uncollected objects
Every Dart application has a root object that directly or indirectly references all other allocated objects . An object is reachable if a chain of references exists from the root to the object. The sequence of references in that chain is the object’s retaining path. An object with at least one retaining path cannot be collected, regardless of whether the application still needs it.
The critical insight for leak detection is that you do not need to understand the entire heap. You need to find the single reference in a retaining path that, if broken, would make the object unreachable. In DevTools, selecting a leaked instance shows its retaining path from a GC root. The path typically ends at a field in a long-lived object—a static list, a singleton, a State that outlives its widget.
// A controller that is disposed but still reachable.
// The leak is not that the controller was never disposed;
// it is that _activeController still references it.
class ControllerHolder {
// This static field is a GC root through the class object.
static Controller? _activeController;
static void register(Controller c) {
_activeController = c; // Reference stored.
}
// Missing: static void unregister() { _activeController = null; }
}
class Controller {
void dispose() {
// Cleanup happens here, but the object remains reachable
// through ControllerHolder._activeController.
print('Controller disposed');
}
}
void main() {
final controller = Controller();
ControllerHolder.register(controller);
controller.dispose(); // Disposed, but not collected.
// A heap snapshot after GC will show the Controller instance
// still present, with a retaining path through the static field.
}
In the DevTools diff view, the Controller class count does not decrease after disposal and GC. Selecting the instance reveals the retaining path: root → ControllerHolder (class) → _activeController → Controller. The fix is to null the static field, either in dispose or in a dedicated unregister method. The Controller object itself is correctly implemented; the holder is the culprit.
b. The leak_tracker model: disposal, collection, and leak types
Flutter’s leak_tracker package (included in Flutter SDK >= 3.18) categorizes leaks by the relationship between two events: disposal and GC . The model assumes that proper memory management means disposal is followed by collection within a bounded number of GC cycles. Any deviation is a leak.
Not-disposed leaks. A disposable object was garbage collected without dispose() being called. The GC reclaimed the object’s memory, but any resources the object held outside the Dart heap (native memory, platform channels, file handles) were never explicitly released. The fix is straightforward: call dispose() before the object becomes unreachable.
Not-GCed leaks. A disposable object was disposed, but remained reachable through a retaining path after several GC cycles. This is the classic Dart leak: the object is no longer needed, its dispose() method ran, but something still references it. The fix is to null or remove the reference that retains it . The retaining path from a DevTools snapshot identifies the exact reference.
GCed-late leaks. The object was disposed and eventually collected, but later than expected. The retaining path existed for some period and then disappeared. This is a less severe not-GCed leak, often caused by a timer or async operation that held a reference temporarily. The fix is the same: identify and remove the temporary retention.
// Demonstrating the three leak types with leak_tracker instrumentation.
// The package is typically used in widget tests, not production code.
import 'package:leak_tracker/leak_tracker.dart';
class TrackedResource {
TrackedResource() {
// Report creation to the leak tracker.
LeakTracking.dispatchObjectCreated(
library: 'my_app',
className: 'TrackedResource',
object: this,
);
}
void dispose() {
// Report disposal to the leak tracker.
LeakTracking.dispatchObjectDisposed(object: this);
}
}
// In a widget test with leak tracking enabled:
// testWidgets('resource lifecycle', (tester) async {
// final r = TrackedResource();
// r.dispose();
// // If r is still reachable after the test's GC cycles,
// // leak_tracker reports a not-GCed leak.
// });
The instrumentation is explicit: each disposable reports its own creation and disposal. The tracker then monitors GC events and correlates them with the reported lifecycle events. In a test environment, this correlation produces pass/fail assertions. In a running application (experimental), it produces console warnings .
c. WeakReference: retention without prevention
WeakReference<T> holds a reference to an object without preventing its collection . When the garbage collector determines that an object is only weakly reachable—no strong references exist—it clears the weak reference’s target to null and collects the object. The weak reference itself remains valid; it simply no longer points to anything.
The primary use case is caching. A cache should accelerate access to computed values without extending their lifetime beyond what the application needs. If the cache held strong references, it would grow unboundedly, retaining every value ever computed. A weak-reference cache holds values until memory pressure or natural GC timing collects them, then recomputes on the next request.
// A cache that does not prevent garbage collection of its values.
// When nothing else holds the cached result, GC clears the weak
// reference, and the next access recomputes.
class WeakCache<K, V extends Object> {
final _store = <K, WeakReference<V>>{};
V get(K key, V Function() compute) {
final weak = _store[key];
final cached = weak?.target;
if (cached != null) {
return cached; // Value still alive; reuse it.
}
// Value was collected or never cached; compute fresh.
final value = compute();
_store[key] = WeakReference(value);
return value;
}
}
void main() {
final cache = WeakCache<String, List<int>>();
final result = cache.get('data', () => [1, 2, 3]);
print(result); // [1, 2, 3]
// If nothing else holds result, GC may clear the weak reference.
// The next cache.get('data', ...) recomputes instead of returning
// a stale reference.
}
Not all objects can be weakly referenced. Numbers, strings, booleans, records, and null are rejected by the WeakReference constructor because they do not support the identity semantics required . The cache must store reference types; primitive values cannot be weakly cached. This limitation is minor in practice: the values worth caching are typically mutable structures or large immutable objects that already require careful lifecycle management.
Complete Example Session
// ============================================
// PART 1: THE LEAKY SERVICE
// ============================================
import 'dart:async';
// A service that maintains a static list of active sessions.
// Sessions are added but never removed. This is the culprit.
class SessionRegistry {
// Static list is a GC root through the class object.
static final _sessions = <Session>[];
static void register(Session session) {
_sessions.add(session); // Strong reference stored forever.
}
// Missing: static void unregister(Session session) {
// _sessions.remove(session);
// }
}
// ============================================
// PART 2: THE SESSION OBJECT
// ============================================
class Session {
final String id;
// A large buffer that makes the leak visible in retained size.
final List<int> _buffer = List.filled(10000, 0);
Session(this.id);
void dispose() {
// Cleanup of internal resources.
// But the Session object itself remains reachable
// through SessionRegistry._sessions.
print('Session $id disposed');
}
}
// ============================================
// PART 3: SIMULATING THE LEAK
// ============================================
void createAndDisposeSessions() {
for (var i = 0; i < 50; i++) {
final session = Session('session_$i');
SessionRegistry.register(session);
session.dispose(); // Disposed, but still registered.
// The Session object is now a not-GCed leak:
// disposed but retained by the static list.
}
print('50 sessions created and disposed.');
}
// ============================================
// PART 4: THE FIX — UNREGISTER ON DISPOSE
// ============================================
// In the fixed version, Session.dispose also unregisters:
//
// void dispose() {
// SessionRegistry.unregister(this);
// print('Session $id disposed');
// }
//
// And SessionRegistry gets:
//
// static void unregister(Session session) {
// _sessions.remove(session);
// }
// ============================================
// PART 5: WEAK CACHE FOR EXPENSIVE COMPUTATION
// ============================================
class ExpensiveReport {
final String content;
ExpensiveReport(this.content);
}
// Cache reports weakly. If a report is not otherwise referenced,
// GC collects it and the next request recomputes.
final _reportCache = <String, WeakReference<ExpensiveReport>>{};
ExpensiveReport getReport(String id) {
final cached = _reportCache[id]?.target;
if (cached != null) return cached;
final report = ExpensiveReport('Report data for $id');
_reportCache[id] = WeakReference(report);
return report;
}
// ============================================
// PART 6: MAIN — TRIGGERING BOTH PATTERNS
// ============================================
void main() async {
print('--- Leak simulation ---');
createAndDisposeSessions();
print('--- Weak cache usage ---');
final r1 = getReport('monthly');
print('First request: ${r1.content}');
// r1 remains strongly referenced here, so cache hit works.
final r2 = getReport('monthly');
print('Second request: ${r2.content}');
print('Same instance: ${identical(r1, r2)}'); // true
// Drop the strong reference; GC may clear the weak cache entry.
// After GC, getReport('monthly') recomputes.
await Future.delayed(Duration(seconds: 1));
print('Weak cache entries: ${_reportCache.length}');
}
// ============================================
// PART 7: DEVTOOLS SNAPSHOT WORKFLOW
// ============================================
// Manual steps:
// 1. Launch: dart run --observe main.dart
// 2. Open DevTools, go to Memory.
// 3. Enable advanced settings to show GC button.
// 4. Baseline snapshot.
// 5. Execute createAndDisposeSessions().
// 6. Force GC, take second snapshot.
// 7. Diff: Session count = +50 (should be 0).
// 8. Select a Session, inspect retaining path:
// root → SessionRegistry (class) → _sessions → Session.
// 9. The path identifies the culprit: the static list.
// ============================================
// PART 8: LEAK_TRACKER TEST INTEGRATION
// ============================================
// In test/flutter_test_config.dart:
//
// import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
//
// Future<void> testExecutable(FutureOr<void> Function() testMain) async {
// LeakTesting.enable();
// LeakTesting.settings = LeakTesting.settings
// .withIgnored(createdByTestHelpers: true);
// await testMain();
// }
//
// Then in a widget test, any instrumented disposable that is
// not GCed after disposal fails the test automatically.
// ============================================
// PART 9: THE RETAINING PATH AS DIAGNOSTIC
// ============================================
// The retaining path answers: "What must I change to make
// this object collectable?"
//
// Common retaining path endings:
// static field → static list → leaked object
// long-lived State → field → controller (not disposed)
// global singleton → map → closure → captured object
// StreamSubscription → callback → BuildContext
//
// The fix is always: break the last strong reference in the path.
// ============================================
// PART 10: VERIFICATION
// ============================================
// After fixing Session.dispose to unregister:
// 1. Re-run the leak simulation.
// 2. Snapshot before and after GC.
// 3. Diff shows Session delta = 0.
// 4. Retaining path no longer exists for disposed sessions.
// The fix is verified by the same measurement that found the leak.
This session demonstrates the complete leak lifecycle: a static registry retains disposed sessions, creating not-GCed leaks. DevTools diffing reveals the growing Session count, and the retaining path identifies SessionRegistry._sessions as the culprit. The WeakReference cache shows the alternative pattern: retention without prevention. The fix—unregistering on dispose—is verified by repeating the snapshot diff.
Quick Reference
Leak Types in leak_tracker
| Type | Disposal | GC | Fix |
|---|---|---|---|
| Not-disposed | Never called | Collected | Call dispose() |
| Not-GCed | Called | Not collected | Break retaining reference |
| GCed-late | Called | Collected late | Break temporary reference |
Retaining Path Common Culprits
| Culprit Location | Example | Fix |
|---|---|---|
| Static field | static List<Session> _sessions | Remove in dispose |
| Global singleton | final bus = EventBus() | Unsubscribe listeners |
| Long-lived State | State._controller | Null in dispose |
| StreamSubscription | stream.listen(callback) | subscription.cancel() |
| Timer | Timer.periodic(...) | timer.cancel() |
DevTools Diff Workflow
| Step | Action | Expected Result |
|---|---|---|
| 1 | Baseline snapshot | Record initial counts |
| 2 | Exercise lifecycle | Create and dispose objects |
| 3 | Force GC | Collect unreachable objects |
| 4 | Second snapshot | Record post-GC state |
| 5 | Diff | Leaked class shows positive delta |
| 6 | Retaining path | Identify culprit reference |
WeakReference Rules
| Rule | Detail |
|---|---|
| Allowed targets | Objects supporting Expando keys |
| Disallowed targets | Numbers, strings, booleans, records, null |
| Cleared when | Only weak references remain |
target property | Object or null after collection |
Culprit vs Victim
| Term | Definition | Action |
|---|---|---|
| Culprit | Leaked object not held by another leaked object | Fix this |
| Victim | Leaked object held by a culprit | Fixes automatically when culprit fixed |
Best Practices
✅ Do This:
// Unregister from static/global lists in dispose(). // ✅
void dispose() { SessionRegistry.unregister(this); }
// Null out fields that reference disposable objects. // ✅
@override void dispose() { _controller = null; super.dispose(); }
// Cancel subscriptions in the same method that created them. // ✅
_sub = stream.listen(...); // paired with _sub?.cancel() in dispose.
// Use WeakReference for cache entries. // ✅
_cache[key] = WeakReference(value);
// Enable leak_tracker in widget tests. // ✅
LeakTesting.enable(); // in test/flutter_test_config.dart.
// Inspect retaining paths, not just instance counts. // ✅
final path = instance.retainingPath; // find the culprit reference.
❌ Don’t Do This:
// Rely on GC to collect disposed objects. // ❌
controller.dispose(); // still referenced by a static field.
// Hold BuildContext in a long-lived closure. // ❌
late BuildContext _ctx; // captured in a callback.
// Cache with strong references without eviction. // ❌
final _cache = <String, BigObject>{}; // grows unboundedly.
// Skip GC before diffing snapshots. // ❌
final after = takeSnapshot(); // garbage inflates counts.
// Fix victims instead of culprits. // ❌
// The BuildContext is retained by the State; fixing
// the BuildContext reference alone does nothing.
// Use WeakReference for primitives. // ❌
WeakReference(42); // throws: numbers not supported.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Fixing victim instead of culprit | Retaining path shows victim, not root cause | Trace path to its origin |
| Disposal without unregistration | Object cleaned up but still referenced | Add remove/unregister in dispose |
| WeakReference on primitives | Constructor rejects unsupported types | Cache reference types only |
| Ignoring retained size | Counting instances misses large buffers | Check retained size in diff |
| Skipping leak_tracker in tests | Manual snapshots do not prevent regressions | Enable in test config |
| Not forcing GC before diff | Uncollected garbage looks like leak | Click GC button in DevTools |
Real-World Examples
1. Static List Retaining Disposed State
static final _allStates = <MyState>[]; // Culprit: never removes.
// Fix: _allStates.remove(this) in dispose().
2. StreamSubscription Not Canceled
_sub = stream.listen(_onData); // Retains callback → State.
@override void dispose() { _sub?.cancel(); } // Fix.
3. Timer Holding BuildContext
Timer.periodic(Duration(seconds:1), (_) => setState(...)); // Retains State.
// Fix: cancel in dispose, check mounted before setState.
4. Global Cache Without Eviction
final _cache = <String, UserProfile>{}; // Strong refs grow forever.
// Fix: use WeakReference or explicit eviction policy.
5. Closure Capturing Large Scope
final handler = () => use(myHugeObject.name); // Retains myHugeObject.
// Fix: capture only the needed field, not the whole object.
6. ChangeNotifier Listener Not Removed
notifier.addListener(_update); // Retains _update → State.
// Fix: notifier.removeListener(_update) in dispose.
7. WeakReference Cache for Computed Values
final _cache = <String, WeakReference<Report>>{};
// Entry cleared when no strong references remain.
8. LeakTracker Test Failure
// testWidgets automatically detects not-GCed disposables.
// Fix: null the reference, then re-run test.
9. Retaining Path Through Singleton
class Singleton { static final instance = Singleton(); }
// instance.field → leaked object. Fix: null field when done.
10. Verifying Fix with Diff
// Before: Session delta = +50 after GC.
// After: Session delta = 0. Fix confirmed.
Visual
Retaining Path to Culprit
┌──────────────────────────────────────────────┐
│ RETAINING PATH ANATOMY │
│ │
│ GC Root: SessionRegistry class object │
│ │ │
│ ▼ │
│ _sessions (static List<Session>) │
│ │ │
│ ├──► Session#0 (disposed, retained) │
│ ├──► Session#1 (disposed, retained) │
│ └──► Session#49 (disposed, retained) │
│ │
│ Culprit: the static list that holds them. │
│ Fix: remove from list in Session.dispose(). │
│ Victims (Session objects) collect naturally │
│ once the culprit releases them. │
│ │
└──────────────────────────────────────────────┘
Leak Type Decision Flow
┌──────────────────────────────────────────────┐
│ DISPOSAL × COLLECTION │
│ │
│ Was dispose() called? │
│ │ │
│ ├── NO ──► Was object collected? │
│ │ ├── YES ──► not-disposed│
│ │ └── NO ──► (still ok) │
│ │ │
│ └── YES ──► Was object collected? │
│ ├── YES, late ──► GCed-late│
│ ├── YES, promptly ──► OK│
│ └── NO ──► not-GCed │
│ │
│ not-GCed is the primary target: │
│ find the retaining path, break it. │
│ │
└──────────────────────────────────────────────┘
WeakReference Lifecycle
┌──────────────────────────────────────────────┐
│ WEAK CACHE BEHAVIOR │
│ │
│ 1. compute() → value │
│ cache[key] = WeakReference(value) │
│ │
│ 2. Strong references exist? │
│ ├── YES: target returns value (hit) │
│ └── NO: GC clears, target = null │
│ │
│ 3. Next get(key): │
│ target == null → compute() again │
│ │
│ Memory grows only with live values. │
│ No eviction policy required. │
│ │
└──────────────────────────────────────────────┘
Culprit vs Victim
┌──────────────────────────────────────────────┐
│ CULPRIT-VICTIM RELATIONSHIP │
│ │
│ Culprit (static list) │
│ │ │
│ ├──► Victim A (disposed State) │
│ │ │ │
│ │ └──► Victim B (BuildContext) │
│ │ │
│ └──► Victim C (disposed Controller) │
│ │
│ Fixing B or C alone: no effect. │
│ B and C are retained by A, A by the list. │
│ Fixing the list releases A; A releases B,C. │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Reachable object | Has at least one retaining path from GC root |
| Retaining path | Chain of references preventing collection |
| Not-disposed leak | Collected without dispose() |
| Not-GCed leak | Disposed but still reachable |
| GCed-late leak | Disposed, collected later than expected |
| Culprit | Leaked object not held by another leaked object |
| Victim | Leaked object held by a culprit |
| WeakReference | Reference that does not prevent collection |
| WeakReference target | null after collection |
| leak_tracker | Package for instrumented leak detection |
| DevTools Diff | Compare snapshots to find growing classes |
Key takeaways:
- Reachability is the only thing that prevents collection. An object is collected if and only if it is unreachable. Every leak is a reference that should have been broken.
- The retaining path identifies the culprit. Do not fix the leaked object; fix the reference that retains it. The path from GC root to object shows exactly which reference to remove.
- Disposal and collection are independent events. Disposing an object does not make it collectable. Unregistering, nulling, and canceling are what make it collectable.
- The culprit-victim distinction focuses the fix. Fixing a victim while the culprit remains is wasted effort. Leak tools identify culprits so you fix the root cause once.
- WeakReference enables retention without prevention. Caches built on weak references purge themselves when values are no longer needed elsewhere.
- leak_tracker makes leaks regression-testable. Widget tests with leak tracking enabled fail when a disposed object is not collected, catching regressions before they reach production.
- DevTools diffing is manual but precise. Snapshot, exercise, GC, snapshot, diff. The delta reveals which classes grew; the retaining path reveals why.
- The fix is always the same shape. Break the strong reference: remove from list, null the field, cancel the subscription, remove the listener.
- Verify with the same tool that found the leak. A fix that is not measured is a fix that might not work.
Remember: Memory leaks in Dart are not failures of the garbage collector—they are failures of reachability management. The GC collects everything unreachable; the application’s responsibility is to make unneeded objects unreachable. DevTools’ snapshot diffing and retaining paths reveal which objects survived and what holds them. The leak_tracker package turns this into automated assertions for disposable objects. WeakReference provides a controlled exception for caches that should not extend lifetimes. The common thread is reachability: find the reference, break the reference, verify the collection. Every leak fix follows this pattern.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!