Flutter memory grows on every screen visit: undisposed controllers and subscriptions
Our Flutter app gained about 9 MB of Dart heap every time a user opened and closed the order details screen, until older Android phones killed it. DevTools showed dozens of retained State objects held alive by a stream subscription and an AnimationController that were never disposed.
Crash reports from our delivery driver app showed a cluster of out-of-memory terminations on Android devices with 3 GB of RAM. The pattern from breadcrumbs was consistent: drivers opened the order details screen, went back to the list, and repeated this 40 to 80 times in a shift. Memory never came back down.
In debug runs we also saw this assertion now and then when a push update arrived:
setState() called after dispose(): _OrderDetailsState#3c1a7(lifecycle state: defunct, not mounted)
This error happens if you call setState() on a State object for a widget that no longer appears in the widget tree.
Reproducing it
- Run the app in profile mode on a device:
flutter run --profile. - Open DevTools, Memory tab, and take a heap snapshot on the order list.
- Open and close order details 20 times, press the GC button, and take a second snapshot.
- Diff the snapshots and filter by class name
_OrderDetailsState.
The diff showed 20 live instances of _OrderDetailsState, 20 AnimationController objects, and 20 _ControllerSubscription instances. Dart heap had grown from 41 MB to 218 MB. With the screen closed, there should be zero instances.
What we ruled out
- Image cache.
PaintingBinding.instance.imageCachewas within its default limits (1000 images, 100 MB) and clearing it did not free the State objects. - The navigator keeping routes. We used
Navigator.pop, notpushReplacementchains; the route stack in the widget inspector had two routes, as expected. - Profile vs debug. The leak reproduced identically in profile and release builds, so it was not a debug-only artifact.
Root cause
_OrderDetailsState subscribed to a long-lived broadcast stream on our app-wide OrderUpdatesService singleton in initState and never cancelled the subscription. The singleton's stream controller held a reference to the listener closure, which captured this (the State), which held its element, widget, controllers and the order data. Every visit added another permanently reachable State. A pulsing status indicator's AnimationController and a Timer.periodic that refreshed the ETA were also never disposed, so each leaked screen kept ticking in the background and calling setState on a defunct State.
What is happening under the hood
Dart is garbage-collected: an object is freed only when nothing reachable from a GC root points to it. Singletons, static fields, and anything registered with them are reachable for the lifetime of the isolate. A State object becomes garbage after its element is unmounted, unless something long-lived still references it.
The common leak sources in Flutter all share that shape:
| Resource | What holds it | Release in dispose() |
|---|---|---|
StreamSubscription |
The stream's controller | subscription.cancel() |
AnimationController |
The ticker from TickerProviderStateMixin |
controller.dispose() |
TextEditingController, ScrollController, FocusNode |
Listeners and attached objects | dispose() |
Timer / Timer.periodic |
The event loop | timer.cancel() |
addListener on a ChangeNotifier that outlives you |
The notifier's listener list | removeListener(...) |
If a ticker is still active when the State is disposed, Flutter asserts in debug builds with:
_OrderDetailsState#3c1a7(ticker active) was disposed with an active Ticker.
_OrderDetailsState created a Ticker via its SingleTickerProviderStateMixin, but at the time dispose() was called on the mixin, that Ticker was still active.
We had seen that message in debug runs and written it off as hot-reload noise. It was the clearest clue we had: dispose() was never overridden, so the controller kept its ticker registered with the scheduler, and the scheduler kept the State reachable.
Environment
| Component | Version / value |
|---|---|
| Flutter | 3.29.0 stable |
| Dart | 3.7.0 |
| Devices | Android 12 and 13, 3 GB RAM; iOS 17 iPhone 11 |
| State management | Plain StatefulWidget plus a singleton service with a broadcast StreamController |
| Tooling | Flutter DevTools Memory view, leak_tracker via flutter_test |
Minimal reproduction
class OrderUpdatesService {
OrderUpdatesService._();
static final instance = OrderUpdatesService._();
final _controller = StreamController<OrderUpdate>.broadcast();
Stream<OrderUpdate> get updates => _controller.stream;
}
class OrderDetails extends StatefulWidget {
const OrderDetails({super.key, required this.orderId});
final String orderId;
@override
State<OrderDetails> createState() => _OrderDetailsState();
}
class _OrderDetailsState extends State<OrderDetails>
with SingleTickerProviderStateMixin {
late final AnimationController _pulse =
AnimationController(vsync: this, duration: const Duration(seconds: 1))
..repeat(reverse: true);
OrderUpdate? _latest;
@override
void initState() {
super.initState();
OrderUpdatesService.instance.updates
.where((u) => u.orderId == widget.orderId)
.listen((u) => setState(() => _latest = u)); // never cancelled
Timer.periodic(const Duration(seconds: 30), (_) => setState(() {})); // never cancelled
}
@override
Widget build(BuildContext context) =>
FadeTransition(opacity: _pulse, child: Text(_latest?.status ?? 'loading'));
}
Who is affected
Any Flutter app where a State subscribes to something that outlives it: app-wide services, BLoCs or notifiers provided above the route, platform event channels, Firebase listeners, or periodic timers. Short-lived screens that are visited many times leak the most.
The fix
Keep a handle to everything started in initState and release it in dispose(), in reverse order of creation, before super.dispose():
class _OrderDetailsState extends State<OrderDetails>
with SingleTickerProviderStateMixin {
late final AnimationController _pulse;
late final StreamSubscription<OrderUpdate> _updatesSub;
late final Timer _etaTimer;
OrderUpdate? _latest;
@override
void initState() {
super.initState();
_pulse = AnimationController(vsync: this, duration: const Duration(seconds: 1))
..repeat(reverse: true);
_updatesSub = OrderUpdatesService.instance.updates
.where((u) => u.orderId == widget.orderId)
.listen(_onUpdate);
_etaTimer = Timer.periodic(const Duration(seconds: 30), (_) => _refreshEta());
}
void _onUpdate(OrderUpdate u) {
if (!mounted) return;
setState(() => _latest = u);
}
void _refreshEta() {
if (!mounted) return;
setState(() {});
}
@override
void dispose() {
_etaTimer.cancel();
_updatesSub.cancel();
_pulse.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) =>
FadeTransition(opacity: _pulse, child: Text(_latest?.status ?? 'loading'));
}
The mounted checks are a safety net for events already queued when dispose runs; they do not replace cancelling. A mounted check without cancel() hides the assertion while the leak continues.
Where the widget only renders the latest value of a stream, we replaced manual subscriptions with StreamBuilder, which subscribes and unsubscribes with the element's lifecycle:
StreamBuilder<OrderUpdate>(
stream: _orderStream, // created once in initState, not in build
builder: (context, snap) => Text(snap.data?.status ?? 'loading'),
)
Creating the stream inside build would resubscribe on every rebuild, which is its own bug.
Alternatives and trade-offs
- Scoped services. If the service did not have to be a singleton, providing it per route (and disposing it with the route) would make leaks less likely. Our update service genuinely is app-wide.
- State management libraries with auto-dispose. Riverpod's
autoDisposeproviders and BLoC'sclose()give a lifecycle hook, but only if you put the subscription inside them. Moving the leak into a provider withoutref.onDisposejust moves the leak. flutter_hooks.useAnimationControlleranduseStreamSubscription-style hooks dispose automatically. Nice for new code, a large refactor for old code.
How to test the fix
Flutter's leak_tracker integrates with flutter_test. We enabled it for all widget tests through test/flutter_test_config.dart:
import 'dart:async';
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
LeakTesting.enable();
LeakTesting.settings = LeakTesting.settings.withIgnored(createdByTestHelpers: true);
await testMain();
}
Then a widget test opens and closes the screen; with tracking enabled, a forgotten dispose() on a tracked object fails the test with a "not disposed" leak report:
testWidgets('order details releases its resources', (tester) async {
await tester.pumpWidget(const MaterialApp(home: OrderList()));
await tester.tap(find.text('Order 1001'));
await tester.pump(const Duration(milliseconds: 500));
await tester.pageBack();
await tester.pump(const Duration(seconds: 1));
expect(find.byType(OrderDetails), findsNothing);
});
flutter test test/order_details_test.dart
We pump fixed durations because pumpAndSettle would time out while the pulse animation repeats. leak_tracker detects objects that implement its tracking, such as AnimationController and TextEditingController; it does not detect uncancelled stream subscriptions, so we also assert in a unit test that OrderUpdatesService has no listeners after the screen closes (exposed through a hasListener getter on its controller).
Confirming in production
We repeated the DevTools snapshot diff on a release candidate: after 20 visits, zero _OrderDetailsState instances survived a GC and Dart heap returned to 43 MB. In the field, Android low-memory kills reported by Firebase Crashlytics for the driver app dropped from about 310 per day to 12 per day over the following two weeks, and average session memory reported by our in-app sampler went from a steadily rising line to flat.
Regression guard
- Leak tracking is on for every widget test in CI.
- The
cancel_subscriptionsandclose_sinkslints from the Dart linter are enabled inanalysis_options.yaml; they flag subscriptions and stream controllers stored in fields that are never cancelled or closed. - Code review rule: anything created in
initStatemust appear indispose()in the same diff.
Security implications
None for data exposure. Leaked State objects held order details, including customer addresses, in memory far longer than needed; that memory is private to the app process, but shorter lifetimes reduce what a heap dump on a compromised or debuggable device would reveal. Fixing the leak also removes a cheap denial of service: a user could previously crash the app by navigating back and forth.
Prevention checklist
- Store every subscription, controller, timer and listener in a field and release it in
dispose(). - Never call
listen()or create controllers insidebuild(). - Use
mountedchecks only as a guard, not as the fix. - Enable
leak_trackerin widget tests and thecancel_subscriptionslint. - Diff heap snapshots after repeated navigation before each release.
References
- Flutter docs, Use the Memory view in DevTools: https://docs.flutter.dev/tools/devtools/memory
- Flutter API, State.dispose: https://api.flutter.dev/flutter/widgets/State/dispose.html
- leak_tracker on GitHub: https://github.com/dart-lang/leak_tracker
- Dart linter rule cancel_subscriptions: https://dart.dev/tools/linter-rules/cancel_subscriptions
- Dart API, StreamSubscription.cancel: https://api.dart.dev/stable/dart-async/StreamSubscription/cancel.html
Add a label
Embed this post
<iframe src="https://www.hibug.me/flutter-memory-leak-undisposed-controllers" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.