Dart runs on the Dart VM, which provides managed memory via a generational, incremental, concurrent garbage collector. Every Dart program runs in at least one isolate — an independent memory heap with no shared mutable state between isolates. Communication between isolates happens through message passing; objects are either copied or transferred.
The Dart VM heap is divided into three regions:
New objects are allocated via bump-pointer allocation in new space — the VM maintains a top pointer into the current semi-space and increments it on each allocation. This makes allocation nearly free (O(1), typically a few nanoseconds).
When new space fills, a scavenge (minor GC) runs. It is a stop-the-world, copying collector:
Scavenges are fast (sub-millisecond for typical workloads) because most objects die young. Flutter's 16 ms frame budget means a scavenge of ~0.5 ms is tolerable; a major GC during a frame can cause a visible jank.
build() methods, paint()). Every object allocated during a frame is potential scavenge pressure. Reuse objects where possible.
Old space is collected via concurrent mark-sweep with optional compaction. The phases:
The GC traverses the object graph from roots (stack frames, globals, isolate handles) and marks all reachable objects. Dart's VM performs marking concurrently with the mutator (application thread), using a tri-color invariant (white / grey / black) and a write barrier to track pointer modifications during concurrent marking.
After marking, unreachable (white) objects are swept. The VM rebuilds free lists per page incrementally, amortizing the cost across subsequent allocation requests rather than stopping the world for the full sweep.
Compaction reduces heap fragmentation by sliding live objects together. It requires a stop-the-world pause to update all pointers. The VM triggers compaction heuristically — not every major GC. In practice, Flutter apps on recent Dart versions (~3.x) see compaction rarely and only when fragmentation exceeds a threshold.
| Phase | Concurrent? | Stop-the-world? | Trigger |
|---|---|---|---|
| Scavenge (minor GC) | No | Yes (short) | New space full |
| Concurrent mark | Yes | Initial/final pause only | Old space threshold |
| Incremental sweep | Partial | No | After mark completes |
| Compaction | No | Yes (longer) | High fragmentation |
Each isolate has its own heap. This is both a safety guarantee and a performance architecture decision: no locking is required for heap access within an isolate because it is single-threaded from the GC's perspective.
import 'dart:isolate'; Future<void> main() async { // Each Isolate.spawn() creates a new heap. final receivePort = ReceivePort(); await Isolate.spawn(worker, receivePort.sendPort); final result = await receivePort.first; // object is COPIED across heaps print(result); } void worker(SendPort sendPort) { // Runs in its own isolate — separate GC, separate heap. sendPort.send({'status': 'done', 'count': 42}); // Map is copied, not shared }
By default, objects sent via SendPort.send() are deep-copied. For large data, this is expensive. Use TransferableTypedData to transfer ownership (zero-copy move) of a Uint8List or similar typed data buffer. After transfer, the source buffer becomes invalid in the sending isolate.
import 'dart:isolate'; import 'dart:typed_data'; final data = Uint8List(10 * 1024 * 1024); // 10 MB buffer // Efficient: zero-copy ownership transfer final transferable = TransferableTypedData.fromList([data]); sendPort.send(transferable); // data is no longer accessible here — accessing it throws // On the receiving side: final received = msg as TransferableTypedData; final bytes = received.materialize().asUint8List();
Dart 2.19+ provides Isolate.run(), a convenience wrapper that spawns a short-lived isolate, runs a closure, returns the result, and disposes the isolate. Its heap is fully collected on exit.
final result = await Isolate.run(() { // Heavy computation in a separate heap. return expensiveComputation(); }); // isolate heap freed here
A weak reference does not prevent the GC from collecting its target. Dart provides WeakReference<T> (Dart 2.17+) for this purpose. The .target property returns null once the referent has been collected.
import 'dart:core'; class HeavyResource { final String name; HeavyResource(this.name); } void example() { var resource = HeavyResource('texture-atlas'); final weak = WeakReference<HeavyResource>(resource); print(weak.target?.name); // 'texture-atlas' resource = null; // drop the strong reference // After GC: weak.target == null }
Expando associates extra data with an existing object without preventing its collection — a weak-keyed map. When the key object is collected, the associated value becomes eligible for collection too.
final Expando<int> hitCount = Expando(); void recordHit(Object key) { hitCount[key] = (hitCount[key] ?? 0) + 1; } // When `key` is GC'd, its Expando entry is also removed. // Expando keys must not be strings, numbers, or booleans (interned types).
Expando keys must be non-primitive objects. Strings, integers, doubles, and booleans are canonicalized/interned by the VM and cannot be used as Expando keys — the VM will throw.
A finalizer runs a callback when an object is about to be garbage collected. Dart 2.17 introduced Finalizer<T>; Dart 3.0 added NativeFinalizer for FFI resource cleanup.
Attach an object to a Finalizer. When the attached object is collected and no detach has been called, the finalizer invokes the callback with the token passed at attach time.
final finalizer = Finalizer<String>((token) { print('Collected: $token'); }); class ManagedBuffer { ManagedBuffer(String id) { finalizer.attach(this, id, detach: this); // detach token = this } void dispose() { finalizer.detach(this); // prevent double-free via GC + explicit dispose _releaseResources(); } void _releaseResources() { /* ... */ } }
NativeFinalizer calls a native C function when an object is collected. This is the correct mechanism for freeing memory allocated in native code (via malloc, etc.) when the Dart wrapper is collected.
import 'dart:ffi'; import 'package:ffi/ffi.dart'; final nativeLib = DynamicLibrary.open('libfoo.so'); final freeFn = nativeLib.lookup<NativeFunction<Void Function(Pointer)>>('free_resource'); final nativeFinalizer = NativeFinalizer(freeFn.cast()); class NativeWrapper implements Finalizable { final Pointer<Void> _ptr; NativeWrapper(this._ptr) { nativeFinalizer.attach(this, _ptr.cast(), detach: this); } void dispose() { nativeFinalizer.detach(this); freeFn.asFunction<void Function(Pointer)>()(_ptr); } }
Types that hold native memory should implement Finalizable as a marker, which signals to the VM that the object must not be collected until it leaves scope (preventing premature finalization of live objects).
A StreamSubscription holds a reference to its listener closure. If the closure captures this, the owning object is kept alive as long as the subscription is active.
class Leaky { StreamSubscription? _sub; void init(Stream<int> stream) { // `this` is captured; stream keeps Leaky alive. _sub = stream.listen((v) => _handle(v)); } void dispose() { _sub?.cancel(); // required — breaks the reference chain _sub = null; } void _handle(int v) {} }
Objects stored in static fields or global variables are GC roots. They are never collected for the lifetime of the isolate. Caches stored statically grow unbounded unless explicitly evicted.
// Anti-pattern: unbounded static cache final Map<String, Uint8List> _cache = {}; // Better: bounded cache with eviction final _cache = LinkedHashMap<String, Uint8List>(); const _maxEntries = 50; void cacheSet(String key, Uint8List val) { if (_cache.length >= _maxEntries) _cache.remove(_cache.keys.first); _cache[key] = val; }
A Timer.periodic that captures state will keep that state alive indefinitely. Always cancel periodic timers in dispose().
class Poller { Timer? _timer; void start() { _timer = Timer.periodic(const Duration(seconds: 5), (_) => _poll()); } void dispose() { _timer?.cancel(); // essential _timer = null; } void _poll() {} }
Controllers (AnimationController, TextEditingController, ScrollController, etc.) allocate internal state that must be explicitly disposed. Failing to dispose them causes the controller, its listeners, and any transitive references to be retained by the framework.
class MyWidget extends StatefulWidget { const MyWidget({super.key}); @override State<MyWidget> createState() => _MyWidgetState(); } class _MyWidgetState extends State<MyWidget> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; late final TextEditingController _textCtrl; @override void initState() { super.initState(); _ctrl = AnimationController(vsync: this, duration: const Duration(ms: 300)); _textCtrl = TextEditingController(); } @override void dispose() { _ctrl.dispose(); // releases vsync ticker, listener list _textCtrl.dispose(); // releases text editing state super.dispose(); } @override Widget build(BuildContext context) => const Placeholder(); }
Run your app in debug or profile mode and open DevTools (dart devtools or via the IDE). The Memory tab provides:
The leak_tracker package (integrated into flutter_test in Flutter 3.18+) detects objects that are disposed without being GC'd and objects that are GC'd without being disposed. Enable in tests:
import 'package:flutter_test/flutter_test.dart'; import 'package:leak_tracker/leak_tracker.dart'; void main() { testWidgets('no leaks', (tester) async { await tester.pumpWidget(MyWidget()); await tester.pumpWidget(const SizedBox()); // remove MyWidget }, leakTrackingConfig: const LeakTrackingTestConfig()); }
You can trigger GC programmatically from the VM service protocol for profiling purposes. Do not use this in production code.
import 'dart:developer' as dev; // Request a GC hint (not guaranteed, but useful for test isolation) dev.postEvent('gc', {}); // More reliably: use dart:vm_service via the VM service protocol // vmService.callServiceExtension('ext.dart.io.getOpenFiles', ...);
| Tool | Use Case | Overhead |
|---|---|---|
| DevTools — Heap Snapshot | Object retention analysis | Low (point-in-time) |
| DevTools — Allocation Trace | Find allocation hotspots by class | High (all allocs recorded) |
| DevTools — Memory Timeline | GC frequency, heap growth over time | Low |
leak_tracker |
Dispose/GC lifecycle correctness in tests | Low (test-only) |
| VM Service Protocol | Programmatic heap interrogation, heap snapshots | Medium |
For objects that are allocated and released at high frequency (geometry, event objects, network packets), maintain an object pool to reduce GC pressure.
class Pool<T> { final T Function() _factory; final _available = <T>[]; Pool(this._factory); T acquire() => _available.isNotEmpty ? _available.removeLast() : _factory(); void release(T obj) => _available.add(obj); } // Usage class Vector2 { double x = 0, y = 0; void reset() { x = y = 0; } } final vecPool = Pool(Vector2.new); final v = vecPool.acquire(); try { v.x = 1.0; v.y = 2.0; doWork(v); } finally { v.reset(); vecPool.release(v); }
| Concept | API / Mechanism | Key Behavior |
|---|---|---|
| Minor GC | VM-internal scavenger | Stop-the-world copy; fast; clears new space |
| Major GC | Concurrent mark-sweep | Concurrent mark; incremental sweep; optional compaction |
| Heap isolation | Isolate.spawn() |
Separate heap per isolate; no shared mutable state |
| Zero-copy transfer | TransferableTypedData |
Transfers ownership; source invalidated |
| Weak reference | WeakReference<T> |
.target returns null after GC |
| Weak keyed map | Expando<V> |
No primitive keys; entry removed with key object |
| GC callback | Finalizer<T> |
Callback on collection; no guaranteed timing |
| Native cleanup | NativeFinalizer |
Calls C function on collection; implement Finalizable |
| Leak detection | leak_tracker |
Detects dispose/GC mismatches in tests |
| Short-lived isolate | Isolate.run() |
Heap freed on exit; ideal for one-shot compute |