Skip to main content

Let’s Talk About Memory Leaks In Dart And Flutter

· 21 min read
Majid Hajian
Google Developer Expert

Since 2006, when I started working in software development, I have faced the problem of memory leaks, particularly when using languages like Dart. The integration of Streams and asynchronous programming as a default feature in Dart introduces a level of difficulty in addressing this matter.

Memory leaks can be annoying as they are hard to spot and recreate compared to syntax errors or logic bugs that usually show up in the development phase. When it comes to memory leaks in Flutter applications, it's getting even more complex with the addition layers of widgets and controllers combined with listeners over time which may result performance issues or unexpected crashes for users after prolonged use.

Unfortunately, identifying all memory leaks isn't always straightforward when it comes to analysis since memory usage can vary based on runtime behavior patterns; nevertheless, tools like DCM and its Rules can sometimes help detect patterns that lead to memory leaks and prevent such issues.

In this article, we will explore Dart and Flutter memory leaks, how they occur, their significance in coding practices, solution to debug memory leaks and identify them such as DevTools and Leak Tracker and shifting left by relying on static analyzer tools such DCM and how they can prevent such leaks from sneaking into your code base.

Common Understanding of Memory Leaks

When an application continues to occupy memory that is no longer required and prevents the system from releasing it for purposes, it can lead to memory leaks. This causes the application to consume memory gradually, resulting in decreased performance crashes, or possible memory insufficiency. Efficient memory management is particularly more important for mobile applications, as mobile devices possess limited resources.

How Memory Leaks Happen

In a programming language like Dart, which includes memory management via a garbage collector (GC), memory leaks may appear relatively easy initially since the GC is responsible for removing unused objects.

However, sometimes more is needed. For example, if an object is still being referred to— in a way— the garbage collector will not remove it from memory regardless of how long it has been idle. That's why programmers must take charge and actively handle the lifespan of objects such as listeners, controllers, and stream subscriptions.

Let me give you an example: imagine having a ScrollController linked to a ListView. If the widget gets deleted from the layout but the ScrollController is not appropriately handled and disposed of, it will retain references, preventing memory cleanup. In applications that run for long periods, such as those found in e-commerce, health, and fitness apps, or social networking sites, these minor memory leaks can accumulate and lead to noticeable declines in performance over time.

DevTools Memory Overview

Memory Leak vs. Memory Bloat

So far, we have seen an overview of memory leaks but I would talk about another term that sometimes you may get confused, Memory Bloat. Let's understand both terms in this section before we go deeper.

If an application fails to release memory for elements like listeners or controllers, it causes a Memory Leak problem, where the garbage collector cannot free up memory as needed over time, leading to app crashes.

When a system experiences memory bloats, it utilizes more memory than needed, which can adversely impact performance but does not lead to crashes. This issue may emerge from actions like loading images, keeping streams open for durations, or managing data structures in an ineffective manner.

When thinking about app performance problems, memory leaks, and memory bloats are worries that can impact how smoothly the app functions. In comparison, memory leaks are more serious as they tend to accumulate, which could result in the app crashing in the run.

Memory Bloat vs LeakMemory Bloat vs Leak

Understanding Dart GC

Now, let’s have a quick review of Dart GC.

Dart’s memory management system relies on a generational garbage collector (GC) to automatically handle memory allocation and deallocation, ensuring that objects no longer in use are freed from memory.

Dart’s garbage collector operates in two primary phases, designed to manage both short-lived and long-lived objects efficiently:

Young Space Scavenger:

This phase focuses on cleaning up short-lived objects, such as temporary objects created during widget builds. New objects are allocated in a "young space" (or nursery) within memory. When this space fills up, the scavenger collects unused (dead) objects and frees their memory while copying the remaining live objects to another part of the memory. This process happens quickly and efficiently, ensuring that temporary objects don’t cause memory bloat.

Parallel Mark-Sweep:

When items have been around for a while, they become part of the memory space managed by the "mark sweep" collector, which works slowly but ensures that long-standing objects are taken care of correctly over extended periods. This stage of the process is where remaining objects are assessed for their use or disuse, and only those no longer needed are cleared out to avoid memory leaks and free up space occupied by objects that have outlived their purpose.

Flutter's engine incorporates the garbage collector to operate during downtime when the app is inactive with the user to minimize any performance disruptions caused by garbage collection activities and ensure smooth user interactions with the app.

But we now have a key question, which we will discuss in the next section.

Why Can't the Garbage Collector Prevent All Memory Leaks?

Dart GC cannot prevent all memory leaks because it can only free objects no longer referenced. Some objects, like disposable and memory-risky objects, require explicit management from developers to avoid memory leaks.

Dart Garbage Collector FlowDart Garbage Collector Flow

But what does this mean? Let’s see in detail.

Disposable objects, such as StreamSubscriptions, Timers, Controllers, and Listeners, have a dispose() method. If these objects are not disposed of when they are no longer needed, they remain in memory, causing a leak because the GC cannot automatically dispose of them.


class LeakExample extends StatefulWidget {

_LeakExampleState createState() => _LeakExampleState();
}

class _LeakExampleState extends State<LeakExample> {
Timer? _timer;


void initState() {
super.initState();
_timer = Timer.periodic(Duration(seconds: 1), (timer) {
print(timer.tick);
});
}


void dispose() {
_timer?.cancel(); // Properly dispose of the Timer
super.dispose();
}


Widget build(BuildContext context) {
return Container();
}
}

Objects with memory-risky tend to retain data or refer to objects in some cases. Even though the Dart garbage collector takes care of most of the cleaning-up process objects that pose a risk, memory needs consideration as they might linger in the memory if not adequately controlled by the developer.

Let me give you a few examples of these types of objects.

Long-Lived References:

Objects stored in global variables, static fields, or as part of long-living classes can prevent memory from being released, even when those objects are no longer needed.

class MyAppState {
static List<String> cachedData = []; // Holds references to large data
}

void cacheData(String data) {
MyAppState.cachedData.add(data); // Data remains in memory indefinitely
}

Objects Captured in Closures:

Closures can unintentionally capture references to large objects, keeping them alive in memory. Suppose a closure references a short-lived object (such as a BuildContext in Flutter or enter widget tree). In that case, it prevents the garbage collector from reclaiming that memory as long as the closure exists.


Widget build(BuildContext context) {
final theme = Theme.of(context);
final handler = () => print(theme);

return ElevatedButton(
onPressed: handler,
child: Text('Apply Theme'),
);
}

In this example, capturing a long-lived object like Theme is safe. However, capturing a BuildContext would prevent the context from being garbage collected, potentially leading to a memory leak if the closure outlives the widget's lifecycle.

Build Context Memory

Large Collections:

Lists, Maps, or other collections that grow large over time without being correctly trimmed or cleared can lead to memory bloat or leaks.

List<int> numbers = List.generate(1000000, (index) => index);

// If numbers list is not cleared or trimmed, it stays in memory

That’s why understanding the lifecycle of widgets, render objects, etc, in Flutter development becomes essential.

The Lifecycle of Widgets and Its Impact on Memory

In Flutter development, tasks involving creating and deleting widgets occur regularly, particularly during hot reload sessions or while engaging with application interface components. However, crucial objects corresponding to these widgets, such as listeners and controllers, are not automatically removed from memory when the widget is destroyed‌‌. If the dispose() function is not invoked for these associated objects when a widget is deleted‌ , they remain in memory, providing issues and inefficiencies in the application's performance.

Dart Garbage Collector FlowDart Garbage Collector Flow

The Timer example above is an excellent example of how to manage such cleanup using the disposal method.

Detect and Debug Memory Leaks with DevTools

The Memory view in Flutter and Dart DevTools offers tools for identifying and troubleshooting memory leaks and excessive memory usage in your app’s codebase. Let’s have a quick overview of how it works, but you can always refer to Flutter Documentation for more details.

Monitor Memory Usage:

The memory view display shows a graph of the application's memory usage at any given time. You can observe the volume of data stored in the heap (Dart objects) and native memory (external resources), allowing you to spot increases or gradual memory expansion—signs that could point to possible system leaks.

Monitor Memory Usage

Profile Memory Allocation:

The Profile Memory section shows how memory is currently allocated based on class and type breakdowns. You can update this information during garbage collection (GC), which helps you identify the objects created and kept in memory.

Profile Memory Allocation

Take Heap Snapshots:

The Diff Snapshots feature allows you to take snapshots of memory before and after specific app interactions. By comparing these snapshots, you can detect memory leaks by observing objects that remain allocated even after they should have been disposed of.

Take Heap Snapshots

Trace Object Allocations:

The Trace Instances tab helps track where and when particular objects are allocated. By selecting a class to trace, you can identify which methods are responsible for allocating objects that could lead to memory leaks.

Trace Object Allocations

Getting familiar with DevTools for debugging and finding memory leaks in your application is always a great idea. You can check Flutter DevTools Memory Features and learn more.

Leak Tracking with leak_tracker_flutter_testing

Having automated tools to spot leaks during development can be pretty handy. One effective tool for this task is the leak_tracker_flutter_testing package, which simplifies the detection of memory leaks in your widget tests. Just to remind you that this package will soon be available in DevTools, and everyone can easily use it with other memory detection features. Let’s quickly review how it works.

As described, the main reason for utilizing Leak Tracking is to identify memory leaks that can be challenging to spot during the development phase, especially for memory-risky objects, as I explained earlier.

The Leak Tracker's focus is to assist developers in preventing scenarios where objects such as StreamSubscription, AnimationController, Timer, or ScrollController are not disposed of properly. This becomes even more important in large applications, where widgets and state management are complex and manually tracking memory becomes error-prone.

The core of the leak_tracker approach revolves around object instrumentation, where objects are tracked from their creation to their disposal. If the object is not disposed of correctly or is retained in memory for too long after its expected lifecycle, the leak_tracker flags this as a potential leak.

Using this package is straightforward; let me reveal it quickly, but leave the details to you to explore more.

In your pubspec.yaml, add leak_tracker_flutter_testing under dev_dependencies:

dev_dependencies:
leak_tracker_flutter_testing: any

Modify your flutter_test_config.dart to enable leak tracking globally for your widget tests. You can read more about the Flutter test library's test configuration here.

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();
}

This ensures that Leak Tracking is active during your tests and automatically monitors disposable objects and their lifecycles. You should be good to go. However, you can adjust each test's Leak Tracking settings if you wish.

testWidgets('Widget test with leak tracking', (WidgetTester tester) async {
await tester.pumpWidget(MyWidget()); // The widget under test

// Add interactions or verifications here
}, experimentalLeakTesting: LeakTesting.settings);

The argument experimentalLeakTestingis set is used to Leak Track objects created during test execution.

Essentially, this package would help you with three main categories of potential leaks:

  • Not-Disposed Objects: An object with a dispose() method (like a ScrollController or StreamSubscription) is not disposed of when it’s no longer needed.
  • Disposed But Not GC’ed Objects: When an object is disposed of but remains in memory because it is still referenced somewhere in the code, it prevents the garbage collector from cleaning it up.
  • Late GC’ed Objects: When an object is disposed of and eventually garbage collected, but not as quickly as expected due to long reference paths holding it in memory longer than necessary.

While having DevTools features and the Leak Track package is great, it is always better to focus on shifting left. What I mean is that the earlier you detect a pattern for memory leaks and potential bugs with memory during development, the better and less costly it is! That’s where the static analyzer and, in particular, DCM rules play a key role.

Shifting Left Leak TrackerShifting Left Leak Tracker

Preventing Memory Leaks in Flutter and Dart

In this article, I have explained several high-level patterns that may lead to memory leaks in Flutter and Dart applications, some of which also requires you to run your code or application to detect the memory leaks. However, I would like to be more specific in this section and explore common issues and patterns that can be detected and prevented without even running your code or app.

Let me also emphasize that DCM comes with over 350 rules, some of which are great for warning when these issues occur in your code. Where possible, I will introduce the related DCM rule to prevent the problems that we are following together in this section.

1. Addressing Disposing Listeners & Controllers

In Flutter, a frequent cause of memory leaks is failing to remove listeners, such as those tied to ChangeNotifier, ScrollController, or ValueNotifier. If you forget to remove these listeners when the widget is destroyed, they remain in memory, consuming resources unnecessarily.

Adding a listener in initState() must be removed in dispose() to prevent a memory leak.

class MyWidget extends StatefulWidget {

_MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
final ValueNotifier<int> _valueNotifier = ValueNotifier(0);


void initState() {
super.initState();
_valueNotifier.addListener(_onValueChange);
}

void _onValueChange() {
print('Value changed: ${_valueNotifier.value}');
}


void dispose() {
_valueNotifier.removeListener(_onValueChange); // Ensures listener is removed
_valueNotifier.dispose(); // Also disposes of the ValueNotifier itself
super.dispose();
}


Widget build(BuildContext context) {
return Container();
}
}

That’s where the always-remove-listener rule comes in handy.

dart_code_metrics:
rules:
- always-remove-listener

This rule warns if listeners are not properly removed when no longer needed. As I mentioned, every listener added manually must typically be removed using the dispose method. If listeners are added in didUpdateWidget or update dependencies, they also need to be removed from these methods, as otherwise, widgets end up with multiple listeners.

class ShinyWidget {
final someListener = Listener();
final anotherListener = Listener();

const ShinyWidget();
}

class _ShinyWidgetState extends State {
final _someListener = Listener();
final _disposedListener = Listener();

const _ShinyWidgetState();


void initState() {
super.initState();

_someListener.addListener(listener);
_disposedListener.addListener(listener);
widget.anotherListener.addListener(listener);
}


void didUpdateWidget(ShinyWidget oldWidget) {
widget.someListener.addListener(listener);
oldWidget.someListener.removeListener(listener);

widget.anotherListener.removeListener(listener); // remove listener before listen again.
widget.anotherListener.addListener(listener);

_someListener.removeListener(listener); // remove listener before listen again.
_someListener.addListener(listener);
}

void dispose() { // dispose and remove all listeners
_someListener.removeListener(listener);
_disposedListener.dispose();
widget.anotherListener.removeListener(listener);

super.dispose();
}

void listener() {
// ...
}
}

Check out this video for a quick explanation of this rule.

Another DCM rule that can be handy to warn you when fields like controllers are not disposed of properly is dispose-fields. This rule will trigger for any field that has dispose, close, or cancel methods not called inside the widget's dispose.

class SomeDisposable implements Disposable {

void dispose() {}
}

class _ShinyWidgetState extends State<ShinyWidget> {
final _someDisposable = SomeDisposable();
final _anotherDisposable = SomeDisposable();

void dispose() {
_someDisposable.dispose();
_anotherDisposable.dispose(); // Correct, disposed

super.dispose();
}
}

This rule comes with two configurations. Set ignore-blocs (default is false) to ignore bloc fields.

dart_code_metrics:
rules:
- dispose-fields:
ignore-blocs: false
ignore-get-x: false

Enabling this option can be helpful if you provide your blocs via BlocProvider (and it closes the passed bloc automatically). Set ignore-get-x (default is false) to ignore Rx classes from GetX.

Another handy rule related to finding inappropriate handling of fields is dispose-class-fields .

dart_code_metrics:
rules:
- dispose-class-fields:
methods:
- someCustomDispose

This is similar to the previous rule; however, expect that it is designed to work partially on Flutter implementation.

2. Avoiding Unassigned Stream Subscriptions & Instances

Another typical issue I have seen is about unassigned subscriptions and instances. Let me give you an example that you probably have seen or even have written.

void fn() {
final stream = Stream.fromIterable([1, 2, 3]);

stream.listen((event) {
print(event);
});
}

Or perhaps using LongPressGestureRecognizer as follows:

TextSpan(
...
recognizer: LongPressGestureRecognizer() // LINT: This instance is not disposed.
..onLongPress = _handlePress,
),

Failing to assign stream subscriptions to variables or neglecting to dispose of objects can lead to resources remaining unnecessarily active in memory. This consumes memory and can cause unexpected behavior in your application.

The solution here is to ensure that you assign the instances and streams and dispose of them properly. For example,

void fn() {
final stream = Stream.fromIterable([1, 2, 3]);

final savedSubscription = stream.listen((event) {
print(event);
});

...

savedSubscription.cancel(); // Correct, assigned and later disposed
}

Here, we cancel a subscription when it’s not needed anymore, or in the following example,

class _SomeState extends State<Some> {
late LongPressGestureRecognizer _longPressRecognizer;


void initState() {
super.initState();

_longPressRecognizer = LongPressGestureRecognizer()
..onLongPress = _handlePress;
}


void dispose() {
_longPressRecognizer.dispose(); // Correct, disposed

super.dispose();
}


Widget build(BuildContext context) {
...
TextSpan(
...
recognizer: _longPressRecognizer,
),
...
}
}

We are probably assigned to _longPressRecognizer and disposed of using the dispose method.

Remembering these can be challenging. That’s why DCM rules, avoid-undisposed-instances, and avoid-unassigned-stream-subscriptions are here to help.

When enabling avoid-undisposed-instances , it warns when an instance with a dispose method is not assigned to a variable.

dart_code_metrics:
rules:
- avoid-undisposed-instances:
ignored-instances:
- LongPressGestureRecognizer

This rule comes with configuration. Set ignored-instances (default is empty) to ignore instances intended to be undisposed. Like in the previous example, now

TextSpan(
...
recognizer: LongPressGestureRecognizer() // Correct, ignored
..onLongPress = _handlePress,
),

When enabling avoid-unassigned-stream-subscriptions , it warns when a stream subscription is not assigned to a variable.

dart_code_metrics:
rules:
- avoid-unassigned-stream-subscriptions

You can also leverage DCM rules specific to packages such as Provider, Riverpod, or BloC. For example, enablingdispose-provided-instances will warn you when an instance with a dispose method created inside a provider does not have the dispose method called in ref.onDispose.

Provider.autoDispose((ref) {
final instance = DisposableService();

ref.onDispose(instance.dispose); // Correct, 'disposed' method is added to 'ref.onDispose'

return instance;
});

class DisposableService {
void dispose() {}
}

Or, if you are using the GetX package, you can use rules such as avoid-mutable-rx-variables , avoid-mutable-rx-variables, avoid-getx-rx-inside-build , dispose-getx-fields and always-remove-getx-listener.

3. Optimization

Earlier in this article, I mentioned memory bloating. Let me give you an example of where we can optimize and manage memory better.

In Dart, concatenating lists of bytes using operators like + results in inefficient memory allocation. Each time two lists are concatenated, the system needs to allocate new memory for the combined list and copy over the contents.

void run() {
List<int> buffer = Uint8List(0);
for (var i = 0; i < 100; i++) {
final Uint8List chunk = chunks[i];
final List<int> intChunk = chunks[i];

buffer = buffer + chunk
buffer = buffer + intChunk
buffer += chunk
buffer += intChunk
}
}

This approach becomes problematic when handling a large number of byte chunks, as the constant reallocation leads to memory fragmentation and reduced performance.

Using lists concatenation, addAll, or spread operator can significantly affect the performance.


/*
* Performance benchmark of different ways to append data to a list.
* https://gist.github.com/PlugFox/9849994d1f229967ef5dc408cb6b7647
*
* BytesBuilder | builder.add(chunk) | 7 us.
* AddAll | list.addAll(chunk) | 594 us.
* Spread | [...list, ...chunk] | 1016446 us.
* Concatenation | list + chunk | 1005022 us.
*/

BytesBuilder is more efficient and performant and is recommended for larger sets of byte chunks.

void run() {
final buffer = BytesBuilder();
for (var i = 0; i < 100; i++) {
buffer.add(chunks[i]);
}
buffer.toBytes();
}

That’s where prefer-bytes-builder comes in handy.

dart_code_metrics:
rules:
- prefer-bytes-builder

Even if you don’t know this situation, you can leverage the best practices provided by DCM rules and ensure your logic remains as efficient and performant as possible.

Another optimization you may have heard of is using a const constructor in Dart. Let’s explore a bit.

In Dart, constant constructors provide a way to create immutable objects, meaning the object's instance variables cannot be changed after creation. This is especially useful when creating objects representing fixed values, such as a Person object where specific attributes should remain constant once set.

class Person {
var name;
var age;

Person({this.name, this.age});
}

void main() {
var person = Person(name: 'Majid', age: 30);
print("Name: ${person.name}, Age: ${person.age}");
}

By making use of const, you can create an immutable Person object. All fields must be marked as final, meaning they can only be assigned once, and the constructor itself is prefixed with const.

class Person {
final String name;
final int age;

const Person({this.name, this.age});
}

void main() {
const person = Person(name: 'Majid', age: 30);
print("Name: ${person.name}, Age: ${person.age}");
}

This is particularly important for optimizing memory management, as constant objects are created at compile time and reused throughout the program, avoiding unnecessary runtime memory allocation.

That’s where prefer-declaring-const-constructor rule comes in place.

dart_code_metrics:
rules:
- prefer-declaring-const-constructor:
allow-one: true
ignore-abstract: true

When you enable this rule, it will warn when a class with no non-final fields has a non-constant constructor declaration.

This rule comes with. ignore-abstract to true will help you ignore abstract classes.

// LINT: Prefer declaring a const constructor.
abstract interface class Extended{ }

And allow one to true will check if there is one const constructor.

class Square {
const Square(this.sideLength);

// Correct, already has one const constructor
Square.fromRadius(double radius) : sideLength = 2 * radius;

final double sideLength;
}

If you use the Bloc package, you may have heard the term "buildWhen.” Specifying buildWhen helps avoid unnecessary rebuilds and improves overall performance. That’s where another DCM rule, avoid-empty-build-when comes in handy.

You may not know about this issue or unintentionally forget to implement it without this rule.

// LINT: Avoid empty 'buildWhen'. 
BlocConsumer<BlocA, BlocAState>(
builder: (context, state) {
...
},
);

// LINT: Avoid empty 'buildWhen'.
BlocBuilder<BlocA, BlocAState>(
builder: (context, state) {
...
},
);

But by enabling this rule, you will see the LINT warning and can act accordingly.

BlocConsumer<BlocA, BlocAState>(
buildWhen: (previous, current) {
...
},
builder: (context, state) {
...
},
);

BlocBuilder<BlocA, BlocAState>(
buildWhen: (previousState, state) {
...
},
builder: (context, state) {
...
},
);

I encourage you to check out the rules on DCM documentation for more information and exploration.

Conclusion

Memory leaks in Dart and Flutter applications can significantly impact performance and user experience, especially in long-running apps. While Dart's garbage collector helps manage memory, developers must be careful about properly disposing of objects, particularly listeners, controllers, and stream subscriptions.

While using DevTools to monitor memory usage and profile allocations and leak_tracker_flutter_testing package for automated leak detection in widget tests is great, shifting left by leveraging static analysis tools like DCM with specific rules to catch potential memory leak patterns early in development can become more efficient.

This article was only a touch on the surface. In the subsequent article, I hope to dig deeper into different performance benchmarks and provide more details about memory management and different techniques in Flutter and Dart. Meanwhile, stay in touch with me and check out dcm.dev for more information.