Mobile Development Bug Report

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.

John Nguyen John Nguyen @devbugs · March 21, 2025 · 8 min read · 51 Views
Banner: Flutter app memory climbing as screens are opened and closed without disposing controllers

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

  1. Run the app in profile mode on a device: flutter run --profile.
  2. Open DevTools, Memory tab, and take a heap snapshot on the order list.
  3. Open and close order details 20 times, press the GC button, and take a second snapshot.
  4. 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.imageCache was 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, not pushReplacement chains; 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 autoDispose providers and BLoC's close() give a lifecycle hook, but only if you put the subscription inside them. Moving the leak into a provider without ref.onDispose just moves the leak.
  • flutter_hooks. useAnimationController and useStreamSubscription-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_subscriptions and close_sinks lints from the Dart linter are enabled in analysis_options.yaml; they flag subscriptions and stream controllers stored in fields that are never cancelled or closed.
  • Code review rule: anything created in initState must appear in dispose() 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 inside build().
  • Use mounted checks only as a guard, not as the fix.
  • Enable leak_tracker in widget tests and the cancel_subscriptions lint.
  • Diff heap snapshots after repeated navigation before each release.

References

Add a label

#flutter #memory-leak #dart #devtools #leak-tracker
Written by
John Nguyen @devbugs

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.