Memory Management in Dart

1. VM Architecture Overview

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 Space (Young)
Scavenger GC — stop-the-world
Short-lived allocations
Semi-space design (from/to)
~1–8 MB typical
Fast bump-pointer alloc
Old Space (Tenured)
Mark-sweep + compaction
Objects surviving 1–2 GCs
Concurrent marking
Page-based freelist
Incremental sweeping
Large Object / Code
Separate region
Objects > 1 page size
JIT-compiled code
Never moved
Freed on major GC
Note
In AOT-compiled Flutter release builds, the JIT compiler is absent. Code space is fixed at build time. GC behavior otherwise remains the same.

2. Allocation and the Scavenger

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:

  1. All objects reachable from roots are copied to the other semi-space (to-space).
  2. Unreachable objects are implicitly freed — the from-space is discarded entirely.
  3. Surviving objects are promoted to old space if they have survived one scavenge.
  4. The roles of from-space and to-space swap.

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.

Tip
Minimize allocations in hot paths (animation callbacks, build() methods, paint()). Every object allocated during a frame is potential scavenge pressure. Reuse objects where possible.

3. Major GC: Mark–Sweep–Compact

Old space is collected via concurrent mark-sweep with optional compaction. The phases:

Marking

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.

Sweeping

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

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

4. Isolates and Memory Isolation

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
}

Transfer vs. Copy

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();

Isolate.run()

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

5. Weak References and Expando

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<V>

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).
Warning
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.

6. Finalizers

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.

Finalizer<T>

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() { /* ... */ }
}
Danger
Finalizer callbacks must not call methods on the collected object — it is in an indeterminate state. Use the token to identify the resource, not the object itself. Finalizers also do not provide guaranteed timing; the GC may delay collection arbitrarily.

NativeFinalizer (FFI)

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).

7. Common Memory Leak Patterns

Uncancelled Stream Subscriptions

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) {}
}

Static and Global References

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;
}

Retained Closures in Timers

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() {}
}

Flutter Widget Leaks via BuildContext and Controllers

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();
}

8. Profiling and Tooling

Dart DevTools Memory Tab

Run your app in debug or profile mode and open DevTools (dart devtools or via the IDE). The Memory tab provides:

flutter_test and leak_tracker

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());
}

dart:developer — postEvent and Service Extensions

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

9. Object Pooling and Allocation Reduction

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);
}
Note
Dart's bump-pointer allocator is already very fast. Pooling adds complexity — only apply it where profiling confirms allocation is a bottleneck. Premature pooling introduces correctness risks (use-after-release, dirty state) for marginal gains.

Quick Reference

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