AlanJereb.com
Coding

Flutter – Detecting memory leaks – Blueprints

Mar 22nd, 2025

This is the second blog post in the

Flutter – Detecting memory leaks

series. If you missed the first one and want to learn about the inner workings of Dart’s Garbage Collector, you can do so

.

In this article, I will provide code examples of how to manually dispose of objects that Dart’s GC won’t handle automatically. I will keep it as short as possible, one example per section.

Blueprints for disposing non-collectable objects

The examples cover all of the referenced non-collectable objects from the first article, but the last, as the disposal of third-party libraries is tied to the selected library’s documentation.

Objects with dispose method on them

Plenty of Flutter objects have the

dispose

or

cancel

method on them. Be sure first to check if your potential leaking object has one. The AnimationController used in this example is one of them and is tied to the framework’s tickers. If these resources are not properly released, it can lead to memory leaks.

class MyWidget extends StatefulWidget {
  @override
  _MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 1),
    )..repeat();
  }

  @override
  void dispose() {
    _controller.dispose(); // Dispose the AnimationController
    super.dispose();
  }
...
}

Global variables

Global variables are accessible throughout the app and have no specific end-of-lifecycle defined. That’s why you must set them to

null

when their use is no longer needed.

class AppState {
  static var largeData = List<int>.filled(1000000, 0);
}

void cleanupGlobalVariables() {
  AppState.largeData = null; // Free up memory if it is no longer needed
}

Event listeners/subscriptions

The stream's sole purpose is to pipe down the stream data when and where it is needed. It is not tied to a single use, and Dart's GC cannot know when the stream's data is no longer needed. Cancel subscriptions to streams when they are no longer needed to prevent memory leaks.

class MyWidget extends StatefulWidget {
  @override
  _MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  StreamSubscription<int>? _subscription;

  @override
  void initState() {
    super.initState();
    _subscription = someStream.listen((data) {
      print('Stream sent data: $data');
    });
  }

  @override
  void dispose() {
    _subscription?.cancel(); // Cancel the subscription
    super.dispose();
  }
...
}

Closures capturing build context

Flutter’s build context (

BuildContext

) is a short-lived and large object. Never use

BuildContext

inside closures if the closure OUTLIVES the widget. If you need context inside a closure, assign the context to a variable and then use that variable inside the closure. Dart’s GC automatically collects variables.

// BAD
Widget build(BuildContext context) {
  final printer = () => print(someFunctionUsingContext(context));
  usePrinter(printer);
}

// GOOD
Widget build(BuildContext context) {
  final result = someFunctionUsingContext(context); // variable gets GCed
  final printer = () => print(result);
  usePrinter(printer);
}

Caches and maps

The situation with caches and maps is a bit similar to the global variables. They hold onto objects even when you no longer need them by keeping strong references to them. Ensuring caches and maps won’t cause unwanted memory leaks includes several approaches, all viable and dependent on your use case.

  • Clearing the cache/map when an object or entire cache is not needed anymore
cache.remove('key'); // Remove an object
cache.clear(); // Clear the entire cache

  • Using weak references to prevent caches from making `strong references` and preventing Dart’s GC from cleaning it. Note that as soon as no one uses the cache’s values (strongly referencing them), they become candidates for Dart’s GC.
final weakCache = WeakMap<Key, Value>();
weakCache[key] = BigObject(); // Doesn’t prevent garbage collection

  • Limiting the size of a cache/map and removing old entries when it gets full.
final cache = LinkedHashMap<String, BigObject>();
void addToCache(String key, BigObject value) {
  if (cache.length >= 100) {
    cache.remove(cache.keys.first); // Remove the oldest object
  }
  cache[key] = value;
}

  • Using Flutter’s built-in caching mechanism.
final imageCache = PaintingBinding.instance.imageCache;
imageCache.clear(); // Clear the image cache

Timers and periodic callbacks

When a timer is created, the objects in its callback form a strong reference to it. When the timer’s purpose is outlived, these strong references still exist, and without manual disposal, they will cause memory leaks.

class MyWidget extends StatefulWidget {
  @override
  _MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  Timer _timer;

  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(Duration(seconds: 1), (timer) {
      print("Tick-tock!");
    });
  }

  @override
  void dispose() {
    _timer.cancel(); // Cancel the timer when the widget is disposed
    super.dispose();
  }
...

Native resources

Targeted platform-specific methods need to be handled/disposed of properly. A developer must manually handle acquiring and then releasing the resource to prevent memory leaks.

class WakeLockScreen extends StatefulWidget {
  @override
  _WakeLockScreenState createState() => _WakeLockScreenState();
}

class _WakeLockScreenState extends State<WakeLockScreen> {
  static const platform = MethodChannel('com.example.wakelock');

  @override
  void initState() {
    super.initState();
    _acquireWakeLock();
  }

  Future<void> _acquireWakeLock() async {
    try {
      await platform.invokeMethod('acquireWakeLock');
    } on PlatformException catch (e) {
      print("Failed to acquire WakeLock: ${e.message}");
    }
  }

  Future<void> _releaseWakeLock() async {
    try {
      await platform.invokeMethod('releaseWakeLock');
    } on PlatformException catch (e) {
      print("Failed to release WakeLock: ${e.message}");
    }
  }

  @override
  void dispose() {
    _releaseWakeLock(); // Release the WakeLock when the widget is disposed
    super.dispose();
  }
...

Is your code still leaking?

If you believe you disposed of all non-collectable objects, but your app is still leaking, it is time to use tools that help you detect where your app is leaking. First, let’s go over the simplest one,

.