AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Flutter Memory Leak Detection: Complete Guide (2026)

NFNourin Mahfuj Finick··

Memory leaks in Flutter apps don't crash your app immediately — they degrade it over days or weeks until users experience jank, frozen screens, and OutOfMemoryError crashes. Unlike null-pointer bugs that fail fast and visibly, memory leaks slip through code review and testing because their effects accumulate gradually. In a 2025 survey of production Flutter apps published on the Flutter Development Blog, memory issues ranked as the second most common cause of negative app store reviews, behind only startup crashes. This guide covers the five most common leak patterns in real Flutter codebases, how to detect each one with DevTools and the LeakTracker API, and why production monitoring catches what local tooling misses.

Understanding Memory Leaks in Flutter

Flutter runs on Dart, which uses a generational garbage collector. The GC splits the heap into two spaces: the nursery (young generation) for short-lived objects, and old generation for objects that survive multiple collection cycles. When your app creates a temporary variable inside a function, Dart allocates it in the nursery and reclaims it cheaply on the next minor GC pass. Objects that live long enough get promoted to old generation, where collection is more expensive because the GC must scan the entire retained object graph.

A memory leak happens when an object the GC should reclaim remains reachable through an unintended reference. Common culprits include:

  • A StreamSubscription still holding a reference to a widget's State after the widget is disposed
  • An AnimationController ticker that was never cancelled
  • A BuildContext captured in a long-running async callback

Over time, leaked objects accumulate in the old generation. Each GC cycle scans more dead objects, freezing the UI thread for longer periods. Frame build times creep past 16ms, users report "laggy scrolling," and eventually the Dart VM runs out of heap and throws OutOfMemoryError.

Top 5 Causes of Memory Leaks in Flutter

1. Unclosed Stream Subscriptions

This is the single most common leak in production Flutter apps. When you call stream.listen(), the returned StreamSubscription holds a reference to your callback — and your callback often closes over State via setState(). If you never cancel the subscription, the entire widget tree rooted at that State stays alive forever.

class LeakyWidget extends StatefulWidget {
  const LeakyWidget({super.key});
  @override
  State<LeakyWidget> createState() => _LeakyWidgetState();
}
 
class _LeakyWidgetState extends State<LeakyWidget> {
  late StreamSubscription _subscription;
 
  @override
  void initState() {
    super.initState();
    _subscription = someStream.listen((data) {
      setState(() { _value = data; }); // Captures State
    });
    // BUG: _subscription is never cancelled
  }
}

Fix: Always cancel subscriptions in dispose():

@override
void dispose() {
  _subscription.cancel();
  super.dispose();
}

For streams you only need once, prefer await for with a StreamController that auto-closes, or use the rxdart package's takeUntil operator to bind the subscription lifecycle to a widget's dispose signal.

2. AnimationControllers Without dispose()

Every AnimationController allocates a Ticker that requests a new frame callback from the engine on every vsync pulse. The TickerProviderStateMixin creates these tickers, but only dispose() tears them down. Forgetting to call super.dispose() — or omitting the dispose entirely — keeps the ticker running even after the widget is removed from the tree.

class _LeakyAnimationState extends State<LeakyAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
 
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 1),
    );
  }
  // BUG: dispose() never called — ticker leaks
}

Fix:

@override
void dispose() {
  _controller.dispose();
  super.dispose();
}

Use AnimatedBuilder or ImplicitlyAnimatedWidget subclasses (like AnimatedOpacity, AnimatedContainer) when you don't need manual ticker control. They handle lifecycle automatically through the framework.

3. Retained BuildContext in Async Callbacks

Capturing BuildContext inside a Future.then() or an async function that may complete after the widget is disposed creates two problems: the context becomes invalid (causing a runtime error if used), and the closure keeps the entire widget subtree alive until the future resolves.

Future.delayed(const Duration(seconds: 10), () {
  ScaffoldMessenger.of(context).showSnackBar(
    const SnackBar(content: Text('Operation complete')),
  );
  // Widget may be gone — context is stale, and State is retained
});

Fix: Check mounted before using context, and avoid long-lived callbacks that close over widget state:

Future.delayed(const Duration(seconds: 10), () {
  if (!mounted) return;
  ScaffoldMessenger.of(context).showSnackBar(
    const SnackBar(content: Text('Operation complete')),
  );
});

For network calls and database operations that may outlive the widget, use a state management solution like Riverpod or Bloc that decouples business logic from widget lifecycle. If you must use callbacks, store a WeakReference to the state or use CancelableOperation from the async package.

4. Heavy Caches Without Eviction Policies

Developers often reach for a simple Map<String, Uint8List> to cache downloaded images or API responses, forgetting that maps grow indefinitely. A list view with 500 items, each with a cached image, can consume hundreds of megabytes without the developer noticing — until the OS kills the app.

final Map<String, Uint8List> _imageCache = {};
// Every image stays in memory forever

Fix: Use an LRU (Least Recently Used) cache with a size limit. The stash package provides one out of the box:

import 'package:stash/stash.dart';
 
final cache = newLruCache<String, Uint8List>(
  maxCacheSize: 50,
);

For image caching specifically, the cached_network_image package handles disk and memory caching with configurable limits. Set maxHeightDiskCache and maxWidthDiskCache to reasonable values for your target devices.

5. Global Event Bus Listeners

Singleton event buses (like EventBus from the event_bus package or custom ChangeNotifier instances stored in globals) are convenient but dangerous. Every widget that subscribes adds itself to a global listener list. If a widget never unsubscribes, the bus holds a permanent reference, and the widget — along with its entire subtree — can never be collected.

Fix: Always pair addListener with removeListener in dispose():

@override
void initState() {
  super.initState();
  eventBus.addListener(_onEvent);
}
 
@override
void dispose() {
  eventBus.removeListener(_onEvent);
  super.dispose();
}

Better yet, use ListenableBuilder (Flutter 3.10+) which automatically manages the listener lifecycle:

ListenableBuilder(
  listenable: eventBus,
  builder: (context, child) {
    return Text('Last event: ${eventBus.lastEvent}');
  },
)

Detecting Memory Leaks with Flutter DevTools

The Flutter DevTools Memory view is your first line of defense during development.

Step 1: Open the Memory Tab. Connect DevTools to your running app (debug or profile mode) and open the Memory tab. You'll see two key charts: the Dart heap (your code's allocations) and the native heap (platform plugins, textures, and engine internals). Profile mode gives more accurate numbers since debug mode adds overhead from the Dart VM service protocol.

Step 2: Take Heap Snapshots. Navigate to a screen you suspect leaks, then navigate away. Manually trigger GC using the "GC" button in DevTools. Take a heap snapshot, then repeat this navigate-away-and-GC cycle 3–5 times. Compare snapshots — if objects from the suspect screen keep growing in count across snapshots, you have a leak. The "Diff" view highlights which classes accumulated instances.

Step 3: Use the LeakTracker API (Flutter 3.22+). Flutter 3.22 introduced a built-in LeakTracker API that automatically detects leaks during widget tests:

import 'package:flutter_test/flutter_test.dart';
import 'package:leak_tracker/leak_tracker.dart';
 
void main() {
  testWidgets('MyWidget does not leak', (tester) async {
    await tester.pumpWidget(const MyWidget());
    await tester.pumpAndSettle();
 
    // Trigger dispose by rebuilding without the widget
    await tester.pumpWidget(const SizedBox());
    await tester.pumpAndSettle();
 
    // LeakTracker reports any undisposed objects
    final leaks = await LeakTracker.collectLeaks();
    expect(leaks, isEmpty);
  });
}

Enable leak tracking in your flutter_test_config.dart:

import 'package:leak_tracker/leak_tracker.dart';
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
 
final leakTracker = LeakTracker(
  settings: LeakTrackerSettings(
    trackNotDisposed: true,
    trackDisposedButNotGCed: true,
  ),
);
 
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  LeakTrackerFlutterTesting.init(leakTracker);
  await testMain();
  await leakTracker.dispose();
}

Run your test suite with --track-leaks to get a report of every leaking object with stack traces showing where it was allocated.

Why Production Monitoring Matters

DevTools and LeakTracker catch leaks in development — but they can't replicate real user behavior. Production apps run on devices with varying memory pressure, OS versions, and usage patterns that no test suite covers. A user who opens 200 product detail pages in a single session triggers cache leaks that a 5-screen test never hits. A user on a low-end device with 2GB RAM hits OOM far sooner than your Pixel 9 Pro test device ever will.

Production monitoring with BugsPulse captures heap growth trends across your entire user base, not just your test device. When memory usage trends upward across sessions — even without crashes — you get an alert before users leave one-star reviews. Combined with session replay, you see exactly which screens and interactions precede a memory spike.

For teams already using Flutter's built-in DevTools, BugsPulse fills the gap between local debugging and production observability. It tracks memory across releases, so you can compare heap profiles before and after a refactor and confirm the fix actually reduced memory pressure for real users.

Conclusion

Memory leaks in Flutter boil down to one rule: everything you create must have a corresponding dispose path. Stream subscriptions need cancel(), animation controllers need dispose(), async callbacks need mounted checks, caches need eviction policies, and event bus listeners need removeListener(). The patterns are consistent — the challenge is catching them before they reach production.

Flutter DevTools and the LeakTracker API give you in-development detection. For production, instrument your app to track memory over time and correlate spikes with specific screens and user flows. Memory leaks are preventable — you just need visibility into where they're happening.