Memory leaks in Flutter: the initState ↔ dispose symmetry

Dart has a garbage collector, so how can Flutter leak? When something long-lived holds a reference to a State you thought was gone. Three leaks, one rule, and how to catch them in DevTools.

Parts 1 and 2 were about time — frames that rebuild too much or miss their deadline. This one is about a problem that doesn’t show up as a stutter: memory that grows and never comes back.

But wait — Dart has a garbage collector. It reclaims objects nobody uses. So how can a Flutter app leak at all?

That question is the whole post, and the answer is a single sentence: the GC only collects objects that are unreachable. If something long-lived is still holding a reference to a State you thought was destroyed, the GC is right to keep it alive — and that’s your leak. The object isn’t garbage; you accidentally kept a string tied to it.


GC is not dispose

Two words that sound related and do opposite jobs:

  • dispose is called by Flutter, deterministically, whenever a widget is permanently removed from the tree — you can count on it running. Its job is to release the resources you acquired: cancel timers, close subscriptions, drop listeners.
  • The GC is called by the Dart runtime, whenever it likes, and it only removes objects that nothing references anymore.

Here’s the trap: dispose runs whether or not you put anything in it. The State object is unmounted. But if you started a Timer in initState and never cancelled it, that timer is still ticking, its callback still references the State, and so the GC can’t collect the State — because from its point of view the State is still reachable through the live timer. dispose ran; the leak survived, because dispose was empty.

The rule that prevents this is a symmetry: whatever you turn on in initState, turn off in dispose. Let’s watch it break three times.

All three leaks are Demos 4, 5 and 6 of performance_demo, the open-source playground I walk through in its own post; the fixes below are its solutions branch. One note if you’re arriving from Part 2, where I insisted on profile mode: that was because debug builds distort timings. Leak hunting is different — retention looks identical in either mode, so use whichever you’re already in. Debug does have one advantage worth knowing about, though: some leaks stop being silent there, because the framework’s own assertions kick in. Leak 3 is exactly that case.


Leak 1: a Timer that’s never cancelled

Here’s the complete demo — a screen with a Recycle 20 clocks button that creates and discards clocks (via an Overlay) so you can watch them pile up in DevTools. Drop it in and set home: const Demo4TimerLeak():

import 'dart:async';
import 'package:flutter/material.dart';

class Demo4TimerLeak extends StatefulWidget {
  const Demo4TimerLeak({super.key});

  @override
  State<Demo4TimerLeak> createState() => _Demo4TimerLeakState();
}

class _Demo4TimerLeakState extends State<Demo4TimerLeak> {
  int _recycled = 0;
  bool _busy = false;

  Future<void> _recycle() async {
    setState(() => _busy = true);
    final overlay = Overlay.of(context);
    for (var i = 0; i < 20; i++) {
      final entry = OverlayEntry(builder: (_) => const LeakyClock());
      overlay.insert(entry);
      await Future<void>.delayed(const Duration(milliseconds: 30));
      entry.remove(); // Unmounts the LeakyClock -> calls its dispose().
    }
    if (!mounted) return;
    setState(() {
      _recycled += 20;
      _busy = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Session clock')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const LeakyClock(showLabel: true),
            const SizedBox(height: 32),
            Text('Clocks created and discarded: $_recycled'),
            const SizedBox(height: 16),
            FilledButton.icon(
              onPressed: _busy ? null : _recycle,
              icon: const Icon(Icons.refresh),
              label: const Text('Recycle 20 clocks'),
            ),
          ],
        ),
      ),
    );
  }
}

class LeakyClock extends StatefulWidget {
  const LeakyClock({super.key, this.showLabel = false});

  final bool showLabel;

  @override
  State<LeakyClock> createState() => _LeakyClockState();
}

class _LeakyClockState extends State<LeakyClock> {
  int _seconds = 0;

  @override
  void initState() {
    super.initState();
    // Stored in a local variable: nothing ever cancels it. It keeps firing forever.
    Timer.periodic(const Duration(seconds: 1), (_) {
      if (mounted) setState(() => _seconds++);
    });
  }

  // dispose() uses the default one: it does NOT cancel the timer.

  @override
  Widget build(BuildContext context) {
    // The recycled copies are invisible; they only exist for the timer.
    if (!widget.showLabel) return const SizedBox.shrink();
    return Text('⏱  $_seconds s', style: Theme.of(context).textTheme.displaySmall);
  }
}

Every clock you create and discard leaves a live timer behind, and each live timer pins its State in memory.

Look closely at the if (mounted) guard inside that callback, because it’s the reason this leak is so easy to ship. Without it, the first tick after the clock was discarded would call setState on an unmounted State, and Flutter would throw — a loud error, right in your console, pointing at the problem. The guard makes that error disappear without fixing anything: the timer still fires every second, the State is still retained, and now nothing complains. It’s a defensive check that quietly converts a crash into a silent leak. You’ll find the same shape in the next two demos.

Catching it in DevTools

Open the Memory tab and switch to the Diff Snapshots sub-tab. The recipe never changes:

  1. From a clean baseline, click the GC button, then Take snapshot — that’s Snapshot 1.
  2. Run the leak trigger — create and discard a batch of clocks (in the demo, the “Recycle 20 clocks” button).
  3. Back at the same baseline, click GC again, then Take snapshotSnapshot 2.
  4. Select Snapshot 2 and point the Diff with selector at Snapshot 1 — DevTools labels them main-1, main-2, … — so the table shows only what changed.
  5. Sort by the Delta column and look for _LeakyClockState.

Flutter DevTools Diff Snapshots: _LeakyClockState with New 20, Released 0, and its retaining path

You’ll see the instance count climb and never drop, even after forcing GC: here 20 new _LeakyClockState instances, Released 0 — and since Delta is just New minus Released, that’s a delta of 20 that nothing will ever reclaim. To confirm the culprit, click the class and read its retaining path in the panel below (visible in the same screenshot) — the chain of references keeping it alive. Here it bottoms out in a Closure Context: the timer’s callback.

One thing to know before you trust these paths: DevTools elides the middle of long chains, so the object at the far end — the timer itself — is not spelled out for you. That last hop is always one you infer from your own code. You’ll read a path this way twice more below.

The fix

Store the timer, cancel it in dispose:

Timer? _timer;

@override
void initState() {
  super.initState();
  _timer = Timer.periodic(const Duration(seconds: 1), (_) {
    if (mounted) setState(() => _seconds++);
  });
}

@override
void dispose() {
  _timer?.cancel(); // breaks the retention chain → GC can now collect the State
  super.dispose();
}

Run the same snapshot-and-diff after the fix and _LeakyClockState is simply gone from the table — nothing is retained anymore:

Flutter DevTools Diff Snapshots after the fix: the diff table is empty, no _LeakyClockState


Leak 2: a StreamSubscription that’s never cancelled

Same shape, different object. This time the widget listens to a global event stream. Both this leak and the next lean on a tiny global bus. Here it is as its own file — the two widgets below import it, so drop this in first:

// global_bus.dart — long-lived global objects shared by Demos 5 and 6.
import 'dart:async';
import 'package:flutter/foundation.dart';

class GlobalBus {
  GlobalBus._();
  static final GlobalBus instance = GlobalBus._();

  final StreamController<int> events = StreamController<int>.broadcast(); // Demo 5
  final ValueNotifier<int> ticker = ValueNotifier<int>(0);               // Demo 6

  Timer? _heartbeat;
  int _counter = 0;

  void ensureStarted() {
    _heartbeat ??= Timer.periodic(const Duration(seconds: 1), (_) {
      _counter++;
      events.add(_counter);
      ticker.value = _counter;
    });
  }
}

The leaky widget subscribes in initState and never cancels. In the repo it has its own host screen, Demo5StreamLeak“Live notifications”, with a Recycle 20 subscribers button and a GlobalBus.instance.ensureStarted() call in its initState so events flow. It’s the same recycle harness as Demo 4 with a different widget inside, and it’s the screen you’ll see in the captures below:

import 'package:flutter/material.dart';

import 'global_bus.dart';

class LiveNotifications extends StatefulWidget {
  const LiveNotifications({super.key, this.showLabel = false});

  final bool showLabel;

  @override
  State<LiveNotifications> createState() => _LiveNotificationsState();
}

class _LiveNotificationsState extends State<LiveNotifications> {
  int _lastEvent = 0;

  @override
  void initState() {
    super.initState();
    // .listen(...) is neither stored nor cancelled. Its return value is lost,
    // but the global StreamController keeps the callback (and this State) alive.
    GlobalBus.instance.events.stream.listen((value) {
      if (mounted) setState(() => _lastEvent = value);
    });
  }

  // Default dispose(): does NOT cancel the subscription.

  @override
  Widget build(BuildContext context) {
    if (!widget.showLabel) return const SizedBox.shrink();
    return Text('🔔  event $_lastEvent',
        style: Theme.of(context).textTheme.displaySmall);
  }
}

The retainer here is worth naming: it’s the global, long-lived StreamController. Because it lives for the whole app, anything it holds lives that long too. Every subscriber you create and discard stays retained by that one global object. In the diff you’ll see _LiveNotificationsState pile up (New 20, Released 0), and the retaining path bottom out in the same Closure Context as before — this time your .listen() callback, held at the far end of the chain by the global controller that DevTools doesn’t print:

Flutter DevTools Diff Snapshots: _LiveNotificationsState with New 20, Released 0, and a retaining path ending in Closure Context

The fix — and a distinction that bites people

You do not cancel a Stream. You cancel the StreamSubscription that .listen() hands you back. Store it, cancel it in dispose — and note it lives in dart:async, which package:flutter/material.dart does not re-export, so the fix needs one more import than the broken version did:

import 'dart:async'; // StreamSubscription lives here

StreamSubscription<int>? _sub;

@override
void initState() {
  super.initState();
  _sub = GlobalBus.instance.events.stream.listen((value) {
    if (mounted) setState(() => _lastEvent = value);
  });
}

@override
void dispose() {
  _sub?.cancel(); // disconnects the callback from the global controller
  super.dispose();
}

Snapshot, recycle, snapshot again, and _LiveNotificationsState is gone from the diff — the global controller is no longer holding anything of yours:

Flutter DevTools Diff Snapshots after cancelling the subscription: the diff table is empty, no _LiveNotificationsState

Ownership matters. The StreamController is global — you don’t own it, so you never close() it. You only own your subscription, so you cancel that. The rule: if you created it, you dispose it; if you only subscribed to someone else’s, you just unsubscribe.


Leak 3: an AnimationController and a listener

The trickiest, because it leaks two ways at once. Its host screen in the repo is Demo6AnimationLeak“Animated card”, with a Recycle 20 cards button — which is the screen in the captures further down:

import 'package:flutter/material.dart';

import 'global_bus.dart';

class PulsingCard extends StatefulWidget {
  const PulsingCard({super.key, this.showLabel = false});

  final bool showLabel;

  @override
  State<PulsingCard> createState() => _PulsingCardState();
}

class _PulsingCardState extends State<PulsingCard>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 900),
    )..repeat(reverse: true);

    // A listener on a global notifier — never removed.
    GlobalBus.instance.ticker.addListener(_onTick);
  }

  void _onTick() {
    // Does nothing visible, but keeps a reference to this State.
  }

  // Default dispose(): frees neither the controller nor the listener.

  @override
  Widget build(BuildContext context) {
    if (!widget.showLabel) return const SizedBox.shrink();
    return ScaleTransition(
      scale: Tween<double>(begin: 0.8, end: 1.2).animate(_controller),
      child: Container(
        width: 120,
        height: 120,
        decoration: BoxDecoration(
          color: Colors.indigo,
          borderRadius: BorderRadius.circular(16),
        ),
        child: const Icon(Icons.favorite, color: Colors.white, size: 48),
      ),
    );
  }
}

In the diff, _PulsingCardState piles up like the rest (New 20, Released 0), and its retaining path again ends at a _Closure — this time the listener you handed to the global notifier:

Flutter DevTools Diff Snapshots: _PulsingCardState with New 20, Released 0, retained via a closure

But that closure is only half the story. The AnimationController is the other half: it’s still repeat()ing, which keeps its Ticker active, and an active ticker stays registered with the scheduler — which reaches back to this State through the TickerProvider mixin. Two independent retainers, so you need two independent cleanups, and forgetting either keeps the leak open:

@override
void dispose() {
  GlobalBus.instance.ticker.removeListener(_onTick); // 1. our listener on a shared object
  _controller.dispose();                             // 2. the controller we own
  super.dispose();
}

With both cuts made, _PulsingCardState drops out of the diff just like the other two:

Flutter DevTools Diff Snapshots after removing the listener and disposing the controller: the diff table is empty, no _PulsingCardState

This is the one leak the framework catches for you — but only in debug. SingleTickerProviderStateMixin.dispose() throws “…was disposed with an active Ticker” if the controller is still running. The check lives inside an assert, so it’s compiled out of profile builds: in debug this leak announces itself loudly, in profile it goes back to being a silent retention you find in a snapshot.

Two subtleties hide here:

  • removeListener needs the same function reference you added. That’s why _onTick is a named method, not an inline () { ... }. An anonymous closure you passed to addListener without storing it is impossible to remove later — a guaranteed leak. This is the single most treacherous variant of the whole family.
  • Every reference counts. With two retainers, cleaning up only one still leaves the State reachable through the other. The leak closes only when all strings are cut.

The symmetry, in one table

Every one of these leaks is the same mistake: you turned something on and never turned it off. Keep whatever “turning on” returns, and undo it in dispose:

You turn onKeepYou turn off in dispose
Timer.periodic(...)the Timertimer.cancel()
stream.listen(...)the StreamSubscription (not the Stream!)sub.cancel()
notifier.addListener(fn)the function fn itself — so give it a namenotifier.removeListener(fn)
AnimationController(...)the controllercontroller.dispose()

The same pattern covers the rest of the usual suspects: ScrollController, TextEditingController, PageController, TabController, FocusNode.dispose(). A ChangeNotifier/ValueNotifier you created → .dispose(). A WidgetsBindingObserverremoveObserver().


Hunting leaks across a whole flow

The “Recycle” button is a convenience for a demo. In a real app you hunt leaks at the flow level, and the diff recipe generalizes cleanly:

  1. Return to a baseline state (say, Home) → GC → Snapshot A.
  2. Exercise the suspect flow N times — enter and leave a feature 10–20 times.
  3. Return to the same baseline state → GC → Snapshot B → Diff.
  4. Any class with a large positive Delta is your leak. Open its retaining path to find the timer, stream, or listener holding it.

The live memory graph makes the trend obvious before you even take a snapshot — a baseline that steps up with each repetition and never falls back is a leak in progress.

Two things make or break the whole recipe: take both snapshots in the same state (otherwise legitimate objects from another screen masquerade as a leak), and force GC before each one (otherwise you’re counting transient garbage that was about to be collected anyway). To automate any of this, Flutter’s leak_tracker is already wired into flutter test, where it flags objects your widget tests leave undisposed — pointing it at a running app takes explicit setup.


Closing

Flutter leaks not despite the garbage collector but because of it: the GC faithfully keeps anything that’s still referenced, and a forgotten timer, subscription, or listener is exactly such a reference. dispose ran just fine — the leak is the cleanup you left out of it. Keep the symmetry (on in initState, off in dispose) and DevTools’ snapshot diff turns leaks from a mystery into a class name with a Released: 0 next to it.

We’ve now covered the three real problem types: excessive rebuilds, jank, and leaks. But DevTools will happily show you things that look like all three and are perfectly healthy. Knowing when not to act on them is its own skill — arguably the senior one — and it’s where we finish, in Part 4.

Language · Idioma

English Español