skip to content

Heap Snapshots & Leaks

The DevTools memory view profiles allocations, diffs heap snapshots and traces instances, and leak_tracker flags undisposed objects. Interviewers ask which controllers leak and why.

part ofFlutteroverview, primer and where to startread it →
on this pageshow

explore

questions

5

In a Flutter app, which undisposed controllers, listeners and stream subscriptions actually leak memory, and why do they leak?

level: middleimportance: must knowfreq 55%

answer

  1. long-lived holds short-lived
  2. callback captures the State
  3. cancel the StreamSubscription
  4. removeListener with the same function
  5. a running Ticker stays scheduled

basics

~20 s

They leak when something longer-lived still reaches them: a subscription on a service's stream, a listener added to a global ChangeNotifier, a running AnimationController's ticker or a periodic Timer. Their callbacks capture the State, so the closed screen's State and subtree stay alive.

solid answer

~40 s

The garbage collector frees anything unreachable, so a forgotten `dispose` leaks when a **longer-lived object still references the short-lived one** or a callback into it. The usual culprits: a `StreamSubscription` on an app-wide stream that is never `cancel()`ed; `addListener` on a global `ChangeNotifier` or `ValueNotifier` without `removeListener`; an `AnimationController` whose ticker is still running, because the scheduler keeps calling it; and a `Timer.periodic` never cancelled. Each holds a closure that captures the `State`, and through it the element and the whole closed subtree. A `TextEditingController` referenced only by its `State` usually becomes unreachable with it, but I still dispose it: it releases what it holds, and leak_tracker reports a disposable collected without `dispose()` as a not-disposed leak.

code

dart · 50 lines
dart
import 'dart:async';

import 'package:flutter/material.dart';

class SessionService {
  SessionService._();
  static final SessionService instance = SessionService._();

  final StreamController<String> _events = StreamController<String>.broadcast();
  Stream<String> get events => _events.stream;
  final ValueNotifier<bool> online = ValueNotifier<bool>(true);
}

class ProfileScreen extends StatefulWidget {
  const ProfileScreen({super.key});

  @override
  State<ProfileScreen> createState() => _ProfileScreenState();
}

class _ProfileScreenState extends State<ProfileScreen>
    with SingleTickerProviderStateMixin {
  late final AnimationController _pulse;
  late final StreamSubscription<String> _events;
  String _last = '';

  @override
  void initState() {
    super.initState();
    _pulse = AnimationController(vsync: this, duration: const Duration(seconds: 1))
      ..repeat();
    _events = SessionService.instance.events.listen((e) => setState(() => _last = e));
    SessionService.instance.online.addListener(_onOnline);
  }

  void _onOnline() => setState(() {});

  @override
  void dispose() {
    _events.cancel(); // the singleton's controller would otherwise keep this State
    SessionService.instance.online.removeListener(_onOnline); // same tear-off
    _pulse.dispose(); // stops the repeating ticker
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(opacity: _pulse, child: Text(_last));
  }
}

go deeper

for a junior

Recall the pairs: listen and cancel, addListener and removeListener, create a controller and dispose it, start a Timer and cancel it.

for a middle

Explain the mechanism: a longer-lived object holds a callback that captures the State, which keeps the element and subtree reachable after the route pops.

for a senior

Show you can tell a real leak from a lint-level miss, find the long-lived holder through a retaining path, and fix ownership rather than sprinkling dispose calls.

for a principal

Discuss ownership conventions for a codebase: who creates and disposes controllers, how services expose streams, and leak tests that enforce it.

## The rule behind every Flutter leak Dart's garbage collector reclaims objects that are no longer **reachable** from a root. A Flutter screen's `State`, its element and the widgets below it become unreachable when the route is popped, **unless** something that lives longer still points at them. So the useful question is not "did I forget `dispose()`?" but "**does a longer-lived object still hold a reference into this screen?**" The reference is almost always a **callback**. `stream.listen(...)`, `notifier.addListener(...)` and `Timer.periodic(...)` all store a function, and a closure or method tear-off that uses `setState`, a field or `context` captures the `State` (`this`). The `State` references its element (its `context`), which references the subtree. ## The usual culprits | Object | Who keeps it alive | What leaks | Fix in `dispose()` | |---|---|---|---| | `StreamSubscription` on an app-wide stream | the service's stream controller | the `onData` closure, the `State`, the subtree | `subscription.cancel()` | | listener on a global `ChangeNotifier` or `ValueNotifier` | the notifier's listener list | the listener and whatever it captures | `removeListener` with the **same** function | | `AnimationController` with a running ticker | the scheduler, which calls an active `Ticker` every frame | the controller, its listeners, the `State` | `controller.dispose()` before `super.dispose()` | | `Timer.periodic` | the event loop, until cancelled | the timer callback and its captures | `timer.cancel()` | Three details interviewers probe: - **`removeListener` needs the identical function.** Passing a new lambda removes nothing; keep a method tear-off or store the closure in a field. - **A running ticker at dispose fails loudly in debug.** `SingleTickerProviderStateMixin` reports that the `State` "was disposed with an active Ticker" when an undisposed controller's ticker is still active. - **Using a disposed notifier throws.** Debug builds report "A ... was used after being disposed", which catches the opposite mistake: disposing an object someone else still uses. ## When a missing dispose does not leak memory A `TextEditingController`, `ScrollController` or `FocusNode` created in `initState` and referenced **only** by the `State` becomes unreachable together with it, so the GC can collect it. You still dispose it: 1. It may hold listeners or other resources that are released in `dispose()`. 2. It documents ownership: whoever creates it disposes it. 3. Tools treat it as a leak. **leak_tracker** defines a *not-disposed* leak as a disposable object that was garbage collected without being disposed, and Flutter's own disposables are instrumented to report it. The inverse matters too: a controller **passed in** from a parent is not yours to dispose; disposing it breaks the owner. ## Recognising it in DevTools - In **Diff Snapshots**, a leaked screen shows as extra `State` instances (for example `_ProfileScreenState`) after you open and close it several times. - The **retaining path** of one instance ends at the long-lived holder: a service singleton, a stream controller's subscription, a notifier's listener list or the scheduler. - After the fix, the same diff returns to the baseline count. ## A checklist for every State - Everything you `listen` to, you `cancel`. - Everything you `addListener` to on an object you do not own, you `removeListener` from with the same function. - Every controller you create, you `dispose`, before `super.dispose()`. - Every `Timer` you start, you `cancel`. - Never hand a closure that captures `context` or `this` to an object that outlives the screen without a matching unregister.

  • Why does calling removeListener(() => setState(() {})) in a Flutter State's dispose not remove the listener you added?
    `removeListener` looks for the same function object that was added. A new lambda is a different object, so nothing matches and the original listener, which captures the `State`, stays registered on the long-lived notifier. Register a method tear-off such as `_onOnline` or keep the closure in a field and pass that same reference to both calls.
  • A Flutter State creates a TextEditingController and never disposes it; does that leak memory?
    Usually not by itself: if only the `State` references it, both become unreachable together and can be collected. It is still a defect: `dispose()` releases what the controller holds, ownership becomes unclear, and leak_tracker reports it as a not-disposed leak because a disposable was collected without being disposed.

saying these in an interview costs you the question

  • Every object without a dispose() call stays in memory forever.
  • The garbage collector frees a State as soon as its route is popped, whatever still references it.
  • Removing a listener with a newly written identical lambda works.
  • You should dispose a controller that a parent widget passed in.
  • Cancelling a StreamSubscription is optional when the stream is a broadcast stream.
open as a page

In Flutter DevTools, what do the memory view's Profile Memory, Diff Snapshots and Trace Instances tabs each help you find?

level: juniorimportance: should knowfreq 35%

basics

~20 s

Profile Memory lists current allocation by class; Diff Snapshots compares heap snapshots taken before and after an interaction to show which classes grew and what retains them; Trace Instances records the call stacks that allocate the classes you select.

open as a page

In Flutter, why can a closure that captures BuildContext leak memory, and how do you write the same code without the leak?

level: middleimportance: should knowfreq 40%

basics

~20 s

A BuildContext is the widget's Element, which reaches its State and subtree. A closure capturing it, handed to a longer-lived object, keeps that whole subtree alive. Read what you need first (final theme = Theme.of(context)) and capture that value, or unregister the closure in dispose.

open as a page

Memory in a Flutter app grows every time users open and close the chat screen; how would you find and confirm the leak with DevTools?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Snapshot at the chat list, open and close the chat N times, snapshot again and diff; classes growing by N, such as the chat State, are the leak. Their retaining path names the long-lived holder, typically an uncancelled subscription or listener. Fix it and re-diff.

open as a page

In Flutter, what does leak_tracker detect, and how do you enable it so testWidgets fails on undisposed objects?

level: seniorimportance: should knowfreq 25%

basics

~20 s

leak_tracker watches instrumented disposables and reports not-disposed leaks (collected without dispose), plus experimental not-GCed and GCed-late leaks. Add leak_tracker_flutter_testing as a dev dependency and call LeakTesting.enable() in test/flutter_test_config.dart; testWidgets then fails the file on leaks.

open as a page