In a Flutter app, which undisposed controllers, listeners and stream subscriptions actually leak memory, and why do they leak?
answer
- long-lived holds short-lived
- callback captures the State
- cancel the StreamSubscription
- removeListener with the same function
- a running Ticker stays scheduled
basics
~20 sThey leak when something longer-lived still reaches them: a subscription on a service's stream, a listener added to a global ChangeNotifier, a running AnimationController's ticker or a periodic Timer. Their callbacks capture the State, so the closed screen's State and subtree stay alive.
solid answer
~40 sThe garbage collector frees anything unreachable, so a forgotten `dispose` leaks when a **longer-lived object still references the short-lived one** or a callback into it. The usual culprits: a `StreamSubscription` on an app-wide stream that is never `cancel()`ed; `addListener` on a global `ChangeNotifier` or `ValueNotifier` without `removeListener`; an `AnimationController` whose ticker is still running, because the scheduler keeps calling it; and a `Timer.periodic` never cancelled. Each holds a closure that captures the `State`, and through it the element and the whole closed subtree. A `TextEditingController` referenced only by its `State` usually becomes unreachable with it, but I still dispose it: it releases what it holds, and leak_tracker reports a disposable collected without `dispose()` as a not-disposed leak.
code
dart · 50 linesimport 'dart:async';
import 'package:flutter/material.dart';
class SessionService {
SessionService._();
static final SessionService instance = SessionService._();
final StreamController<String> _events = StreamController<String>.broadcast();
Stream<String> get events => _events.stream;
final ValueNotifier<bool> online = ValueNotifier<bool>(true);
}
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _pulse;
late final StreamSubscription<String> _events;
String _last = '';
@override
void initState() {
super.initState();
_pulse = AnimationController(vsync: this, duration: const Duration(seconds: 1))
..repeat();
_events = SessionService.instance.events.listen((e) => setState(() => _last = e));
SessionService.instance.online.addListener(_onOnline);
}
void _onOnline() => setState(() {});
@override
void dispose() {
_events.cancel(); // the singleton's controller would otherwise keep this State
SessionService.instance.online.removeListener(_onOnline); // same tear-off
_pulse.dispose(); // stops the repeating ticker
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(opacity: _pulse, child: Text(_last));
}
}go deeper
Recall the pairs: listen and cancel, addListener and removeListener, create a controller and dispose it, start a Timer and cancel it.
Explain the mechanism: a longer-lived object holds a callback that captures the State, which keeps the element and subtree reachable after the route pops.
Show you can tell a real leak from a lint-level miss, find the long-lived holder through a retaining path, and fix ownership rather than sprinkling dispose calls.
Discuss ownership conventions for a codebase: who creates and disposes controllers, how services expose streams, and leak tests that enforce it.
## The rule behind every Flutter leak Dart's garbage collector reclaims objects that are no longer **reachable** from a root. A Flutter screen's `State`, its element and the widgets below it become unreachable when the route is popped, **unless** something that lives longer still points at them. So the useful question is not "did I forget `dispose()`?" but "**does a longer-lived object still hold a reference into this screen?**" The reference is almost always a **callback**. `stream.listen(...)`, `notifier.addListener(...)` and `Timer.periodic(...)` all store a function, and a closure or method tear-off that uses `setState`, a field or `context` captures the `State` (`this`). The `State` references its element (its `context`), which references the subtree. ## The usual culprits | Object | Who keeps it alive | What leaks | Fix in `dispose()` | |---|---|---|---| | `StreamSubscription` on an app-wide stream | the service's stream controller | the `onData` closure, the `State`, the subtree | `subscription.cancel()` | | listener on a global `ChangeNotifier` or `ValueNotifier` | the notifier's listener list | the listener and whatever it captures | `removeListener` with the **same** function | | `AnimationController` with a running ticker | the scheduler, which calls an active `Ticker` every frame | the controller, its listeners, the `State` | `controller.dispose()` before `super.dispose()` | | `Timer.periodic` | the event loop, until cancelled | the timer callback and its captures | `timer.cancel()` | Three details interviewers probe: - **`removeListener` needs the identical function.** Passing a new lambda removes nothing; keep a method tear-off or store the closure in a field. - **A running ticker at dispose fails loudly in debug.** `SingleTickerProviderStateMixin` reports that the `State` "was disposed with an active Ticker" when an undisposed controller's ticker is still active. - **Using a disposed notifier throws.** Debug builds report "A ... was used after being disposed", which catches the opposite mistake: disposing an object someone else still uses. ## When a missing dispose does not leak memory A `TextEditingController`, `ScrollController` or `FocusNode` created in `initState` and referenced **only** by the `State` becomes unreachable together with it, so the GC can collect it. You still dispose it: 1. It may hold listeners or other resources that are released in `dispose()`. 2. It documents ownership: whoever creates it disposes it. 3. Tools treat it as a leak. **leak_tracker** defines a *not-disposed* leak as a disposable object that was garbage collected without being disposed, and Flutter's own disposables are instrumented to report it. The inverse matters too: a controller **passed in** from a parent is not yours to dispose; disposing it breaks the owner. ## Recognising it in DevTools - In **Diff Snapshots**, a leaked screen shows as extra `State` instances (for example `_ProfileScreenState`) after you open and close it several times. - The **retaining path** of one instance ends at the long-lived holder: a service singleton, a stream controller's subscription, a notifier's listener list or the scheduler. - After the fix, the same diff returns to the baseline count. ## A checklist for every State - Everything you `listen` to, you `cancel`. - Everything you `addListener` to on an object you do not own, you `removeListener` from with the same function. - Every controller you create, you `dispose`, before `super.dispose()`. - Every `Timer` you start, you `cancel`. - Never hand a closure that captures `context` or `this` to an object that outlives the screen without a matching unregister.
- Why does calling removeListener(() => setState(() {})) in a Flutter State's dispose not remove the listener you added?`removeListener` looks for the same function object that was added. A new lambda is a different object, so nothing matches and the original listener, which captures the `State`, stays registered on the long-lived notifier. Register a method tear-off such as `_onOnline` or keep the closure in a field and pass that same reference to both calls.
- A Flutter State creates a TextEditingController and never disposes it; does that leak memory?Usually not by itself: if only the `State` references it, both become unreachable together and can be collected. It is still a defect: `dispose()` releases what the controller holds, ownership becomes unclear, and leak_tracker reports it as a not-disposed leak because a disposable was collected without being disposed.
saying these in an interview costs you the question
- Every object without a dispose() call stays in memory forever.
- The garbage collector frees a State as soon as its route is popped, whatever still references it.
- Removing a listener with a newly written identical lambda works.
- You should dispose a controller that a parent widget passed in.
- Cancelling a StreamSubscription is optional when the stream is a broadcast stream.