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:
disposeis 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:
- From a clean baseline, click the GC button, then Take snapshot — that’s Snapshot 1.
- Run the leak trigger — create and discard a batch of clocks (in the demo, the “Recycle 20 clocks” button).
- Back at the same baseline, click GC again, then Take snapshot — Snapshot 2.
- 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. - Sort by the Delta column and look for
_LeakyClockState.

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:

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:

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:

Ownership matters. The
StreamControlleris global — you don’t own it, so you neverclose()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:

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:

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 anassert, 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:
removeListenerneeds the same function reference you added. That’s why_onTickis a named method, not an inline() { ... }. An anonymous closure you passed toaddListenerwithout 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
Statereachable 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 on | Keep | You turn off in dispose |
|---|---|---|
Timer.periodic(...) | the Timer | timer.cancel() |
stream.listen(...) | the StreamSubscription (not the Stream!) | sub.cancel() |
notifier.addListener(fn) | the function fn itself — so give it a name | notifier.removeListener(fn) |
AnimationController(...) | the controller | controller.dispose() |
The same pattern covers the rest of the usual suspects:
ScrollController, TextEditingController, PageController, TabController, FocusNode
→ .dispose(). A ChangeNotifier/ValueNotifier you created → .dispose(). A
WidgetsBindingObserver → removeObserver().
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:
- Return to a baseline state (say, Home) → GC → Snapshot A.
- Exercise the suspect flow N times — enter and leave a feature 10–20 times.
- Return to the same baseline state → GC → Snapshot B → Diff.
- Any class with a large positive
Deltais 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_trackeris already wired intoflutter 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.

