← Back to blog
Dart Internals & Performance · Part 3 of 8
September 7, 202613 min read

Garbage Collection & Memory Management in Dart

DartPerformanceFlutter

Garbage Collection & Memory Management in Dart

This is Part 3 of the Dart Internals & Performance series. In Part 1 we saw that even AOT-compiled Dart runs on a small runtime. The busiest part of that runtime is the garbage collector — and in Flutter it runs constantly, because every frame you build allocates a blizzard of short-lived objects.

Here's the surprising part: Flutter rebuilds entire widget subtrees dozens of times per second, creating thousands of Widget objects that live for a single frame and then become garbage. In many languages that allocation pattern would be a performance disaster. In Dart it's fine — by design. The GC is tuned for exactly this.

Understand it, and you'll know why "creating lots of widgets" is cheap, why a memory leak in Dart almost always means "you kept a reference you forgot about," and how to keep your app from getting GC pauses mid-animation.

Assumes the runtime model from Part 1. We're on Dart 3.12.


Why you have a GC at all

In C, you malloc memory and must free it yourself. Forget, and you leak; free too early, and you crash. Dart has no free. Instead, the runtime tracks which objects are still reachable from your program and automatically reclaims the rest.

Reachability is everything. An object is "alive" if it can be reached by following references starting from the roots — local variables on the stack, static fields, and global state. If nothing reachable points to an object, it's garbage, and the GC may reclaim its memory.

void main() {
  var user = User('Ada');  // 'user' (a root) → the User object is reachable
  user = User('Babbage');  // the 'Ada' User is now unreachable → garbage
  // GC will eventually reclaim the 'Ada' object's memory. You did nothing.
}

You never call a free function. You just stop referencing things, and the GC notices.


The one insight that explains Dart's GC: most objects die young

Decades of research found a pattern so reliable it's called the generational hypothesis:

The vast majority of objects die very young. A few objects live a long time; most are created, used briefly, and discarded almost immediately.

Flutter is the poster child for this. Think about a single frame:

@override
Widget build(BuildContext context) {
  return Padding(                       // created this frame...
    padding: const EdgeInsets.all(8),
    child: Column(                      // ...and this...
      children: [
        Text('Score: $score'),         // ...and this...
        Text('Lives: $lives'),         // ...and this.
      ],
    ),
  );
  // Next frame, build() runs again and makes BRAND NEW widget objects.
  // The ones above are now garbage — they lived a few milliseconds.
}

Thousands of objects, born and dead within one frame. A GC that treated them like long-lived objects would choke. So Dart, like most modern runtimes, uses a generational collector that handles young and old objects completely differently.

Analogy — the desk and the archive. Picture your workspace:

  • A small desk (the nursery / "new space") where you do active work. It fills up fast with scratch paper. Clearing it is trivial: sweep everything still in use into a clean tray and bin the rest — takes a second.
  • A large archive (the "old space") for the few documents that proved important enough to keep. You reorganize it rarely and carefully, because it's big.

Most paper never leaves the desk. Only the rare keeper gets promoted to the archive.


Generation 1: the nursery (new space)

New objects are allocated in new space, a small region collected by a fast algorithm called a scavenger (a copying / semi-space collector).

How it stays fast:

  • Allocation is a pointer bump. New space is a contiguous block with a "next free" pointer. Allocating an object = advance the pointer. That's nearly as cheap as stack allocation — this is why making lots of widgets is cheap.
  • Collection copies survivors, ignores the dead. New space is split into two halves. When the active half fills, the scavenger copies the still-reachable objects to the other half and declares the entire old half free in one shot. It never visits dead objects at all.
new space:  [ from-half: A B C D E ... full ]   [ to-half: empty ]
                    │ scavenge: copy only LIVE objects →
            [ from-half: now all free ]          [ to-half: A  D ]
                                                  (B, C, E were dead — never touched)

Why this is brilliant for Flutter: the cost of a scavenge is proportional to the number of survivors, not the amount of garbage. When 95% of this frame's widgets are dead, the scavenger only pays for the 5% that live. Generating mountains of short-lived garbage is genuinely cheap.

Objects that survive a couple of scavenges are assumed to be long-lived and get promoted to old space.


Generation 2: old space (the archive)

Objects that live long enough are moved to old space, managed by a mark-sweep collector (with compaction to fight fragmentation):

  1. Mark. Starting from the roots, trace every reachable object and mark it.
  2. Sweep. Reclaim the memory of everything not marked.
  3. Compact (when needed). Slide live objects together so free memory is one contiguous block again.

Old-space collection is more expensive (it scans more), so Dart works hard to avoid making it pause your app:

  • Concurrent marking. Much of the marking runs on a separate helper thread, concurrently with your Dart code, so the "stop-the-world" portion is short.
  • Parallel work. Phases use multiple threads where possible.

The upshot: long-lived objects (your app's persistent state, caches, singletons) are collected rarely and as smoothly as possible, while the churn of short-lived objects is handled by the cheap scavenger.


The Flutter superpower: idle-time GC

Here's a detail that shows how deeply the GC is co-designed with Flutter. The Flutter engine tells the Dart runtime when the app is idle — for example, between frames when there's nothing to draw, or when the user isn't interacting.

Flutter schedules GC work during these idle moments, so collection happens when no frame is being produced — keeping the garbage collector out of the way of your 60/120 fps rendering.

That's why a well-behaved Flutter app can allocate aggressively and still hit frame budget: the GC is nudged to clean up in the gaps, not in the middle of an animation.


What GC does NOT save you from: leaks via retained references

A garbage collector reclaims unreachable objects. It cannot reclaim objects you're still (accidentally) holding onto. That's a memory leak in a GC'd language: live references to data you no longer need. The GC is working perfectly — you are the bug.

The classic Flutter offenders:

1. Forgotten subscriptions / controllers. A StreamSubscription, AnimationController, TextEditingController, Timer, or listener that you create but never dispose keeps its State (and everything it references) alive.

class _MyWidgetState extends State<MyWidget> {
  late final StreamSubscription _sub;
  final _controller = TextEditingController();

  @override
  void initState() {
    super.initState();
    _sub = stream.listen(_onData);
  }

  @override
  void dispose() {
    _sub.cancel();        // ❌ forget this → the State leaks
    _controller.dispose(); // ❌ forget this → the controller leaks
    super.dispose();
  }
}

Rule of thumb: anything with a dispose() / cancel() / close() must be cleaned up — almost always in State.dispose(). If you allocate it, you own its teardown.

2. Closures capturing this. A long-lived object (a global, a singleton, a static list) that holds a callback capturing your widget's State keeps that whole State — and its subtree — alive.

// 'GlobalBus' outlives the screen. This closure captures 'this' (the State),
// so the State can never be collected while the bus holds the callback.
GlobalBus.instance.onEvent(() => setState(() {})); // ❌ leak unless you remove it

3. Growing caches with no eviction. A static final Map cache = {} you only ever add to will grow forever. Every cached value is reachable, so the GC keeps all of it.

class ImageCache {
  static final _cache = <String, Uint8List>{}; // grows unbounded → effective leak
  static void put(String k, Uint8List v) => _cache[k] = v; // never evicts
}

The fix is always the same: break the reference. Dispose subscriptions/controllers, remove listeners, bound your caches (e.g. LRU), and don't let long-lived objects capture short-lived ones.


When you genuinely need "don't keep this alive": weak references

Sometimes you want to reference an object without preventing its collection — e.g. an auxiliary cache keyed by objects you don't own. Dart gives you WeakReference<T> and Finalizer<T>.

// A WeakReference does NOT keep its target alive.
final cached = WeakReference<Image>(bigImage);

// Later: the target may already be gone.
final img = cached.target; // null if it's been collected
if (img != null) use(img);
// A Finalizer lets you run cleanup AFTER an object is collected
// (e.g. free a native resource). Best-effort, not guaranteed timing.
final finalizer = Finalizer<int>((handle) => releaseNative(handle));

Use these sparingly. WeakReference is for caches/observers that must not extend lifetime; Finalizer is mostly for releasing native resources tied to a Dart object's lifetime — which connects directly to FFI in Part 5. For everyday app code, deterministic dispose() is clearer and preferred.


Isolates and memory: no shared heap

One Dart-specific fact with big GC implications: each isolate has its own heap and its own GC. Isolates don't share mutable memory — they communicate by copying messages (or transferring via TransferableTypedData).

Consequence: an isolate's garbage is collected independently, and a heavy GC in one isolate doesn't pause another. Offloading CPU-heavy work to an isolate (from the async series) also moves its allocation churn off the UI isolate's GC. This is a real performance lever we'll use in Part 6.


Measuring memory: don't guess

You diagnose memory with DevTools, not vibes. The Memory view shows:

  • Heap size over time (watch for a line that only ever climbs — a leak signature).
  • Allocation tracing — what's being allocated and where.
  • Heap snapshots + diffs — capture two snapshots and see what's retained between them.
  • The retaining-path of an object — exactly which references keep a "should-be-dead" object alive.

Workflow for a suspected leak: reproduce the action (e.g. push and pop a screen) several times, take a heap snapshot, and check whether instances of that screen's State are piling up. If they are, follow the retaining path to the reference you forgot to release. We'll do this hands-on in Part 6.


Practice Challenges

Challenge 1 — Is it garbage? After this runs, which User objects are eligible for collection?

var a = User('1');
var b = User('2');
a = b;
Show solution

The User('1') object is now unreachable (nothing references it after a = b) → eligible for collection. User('2') is still referenced by both a and balive.

Challenge 2 — Why is making widgets cheap? In one or two sentences, explain why a build() method allocating hundreds of widgets every frame doesn't tank performance.

Show solution

Those widgets are short-lived and live in new space, where allocation is a cheap pointer bump and the scavenger only pays for survivors (almost none), copying them and freeing the rest in one shot. Flutter also schedules GC during idle time between frames. Mountains of short-lived garbage are cheap by design.

Challenge 3 — Spot the leak. What leaks here and how do you fix it?

class _ChartState extends State<Chart> {
  late final Timer _timer;
  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
  }
}
Show solution

The _timer is never cancelled, so it keeps firing and keeps the State (and its widget subtree) reachable even after the widget is removed — a leak. Fix by adding dispose:

@override
void dispose() {
  _timer.cancel();
  super.dispose();
}

Challenge 4 — Generational reasoning. An object survives several scavenges. What happens to it, and why does the runtime do that?

Show solution

It gets promoted to old space. Surviving multiple scavenges is strong evidence it's long-lived (generational hypothesis), so moving it out of new space stops the scavenger from repeatedly copying it and lets the (rarer) old-space collector manage it instead.

Challenge 5 — Weak vs dispose. You're caching decoded images keyed by URL and don't want the cache to keep images alive on its own. Which tool, and why not a normal Map?

Show solution

Use a cache of WeakReference<Image> (or a bounded/LRU cache). A normal Map<String, Image> keeps every value strongly reachable, so the GC can never reclaim them — an unbounded cache becomes an effective leak. WeakReference lets the GC collect an image when nothing else needs it, and you re-decode on a miss.


Questions to test yourself

Q1 (basic). What determines whether an object is garbage in Dart?

Show answer

Reachability. If an object can't be reached by following references from the roots (stack locals, statics, globals), it's garbage and eligible for collection. There's no manual free.

Q2 (basic). What is the generational hypothesis, and how does Dart's GC exploit it?

Show answer

The hypothesis: most objects die young. Dart exploits it with a generational GC — a cheap copying scavenger for short-lived objects in new space, and a mark-sweep/compact collector for the few long-lived objects in old space.

Q3 (intermediate). Why is allocating in new space so cheap, and why is a scavenge cheap even when lots of garbage exists?

Show answer

Allocation is a pointer bump in a contiguous region. A scavenge copies only the live survivors to the other half and frees the rest wholesale, so its cost is proportional to survivors, not garbage — when most objects are dead, the scavenger barely does any work.

Q4 (intermediate). A GC'd language "can't have memory leaks." Why is that false in Dart?

Show answer

The GC only reclaims unreachable objects. If you keep an unwanted object reachable — an undisposed subscription/controller, a closure capturing this held by a long-lived object, or an unbounded cache — it stays alive forever. That's a leak caused by retained references, not a GC failure.

Q5 (advanced). How does Flutter keep old-space GC from causing jank during animations?

Show answer

Two ways: the old-space collector uses concurrent marking (and parallel phases) so most work happens off the main thread with only short stop-the-world pauses; and the Flutter engine signals idle periods so the runtime schedules GC between frames rather than mid-animation. Together they keep collection out of the frame budget.

Q6 (advanced). How do isolates change the memory/GC picture, and how can that improve UI smoothness?

Show answer

Each isolate has its own heap and GC, with no shared mutable memory (messages are copied/transferred). So GC in one isolate never pauses another. Moving CPU- and allocation-heavy work to a background isolate keeps that allocation churn — and its GC — off the UI isolate, protecting frame times.


Wrapping up

  • Dart has no manual free — the GC reclaims unreachable objects automatically.
  • It's generational, built on "most objects die young": a fast copying scavenger for new space, mark-sweep/compact for old space, with promotion in between.
  • New-space allocation is a pointer bump and scavenging costs only what survives — which is why Flutter's per-frame widget churn is cheap.
  • Concurrent marking plus idle-time GC keep collection out of your frame budget.
  • The GC can't fix retained-reference leaks — dispose controllers/subscriptions, don't let long-lived objects capture short-lived ones, and bound your caches. Use WeakReference/Finalizer only when you truly need them. Diagnose with DevTools heap snapshots and retaining paths.

In Part 4 we look at the other side of the allocation coin: const constructors and compile-time constants — how building objects at compile time means zero allocation and zero GC pressure at runtime, and how Flutter leans on it for huge wins.