skip to content

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%

answer

  1. BuildContext is the Element
  2. closure outlives the widget
  3. capture the value, not context
  4. unregister in dispose
  5. the context rule from the DevTools docs

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.

solid answer

~40 s

A `BuildContext` is the widget's **element**, and the element references its widget, its `State` if it has one, and its children, so capturing `context` in a closure captures a lot. If that closure is handed to something that outlives the screen (a singleton's callback field, a global event bus, a static list, a long-lived stream listener), the whole subtree stays reachable after the route is popped. The fix is to **capture values, not the context**: resolve `Theme.of(context)` or a localized string first and let the closure use that, and when a callback must outlive `build`, register it in `initState` and remove it in `dispose`. The DevTools guidance sums it up: passing `context` to a closure is fine as long as the closure does not outlive the widget.

code

dart · 46 lines
dart
import 'package:flutter/material.dart';

class AppEvents {
  AppEvents._();
  static final AppEvents instance = AppEvents._();
  final List<VoidCallback> _handlers = <VoidCallback>[];
  void add(VoidCallback h) => _handlers.add(h);
  void remove(VoidCallback h) => _handlers.remove(h);
}

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

  @override
  State<BannerHost> createState() => _BannerHostState();
}

class _BannerHostState extends State<BannerHost> {
  Color _accent = Colors.transparent;

  // BAD (not used): AppEvents.instance.add(() => show(Theme.of(context)));
  // inside build() would add a context-capturing closure on every build.

  @override
  void initState() {
    super.initState();
    AppEvents.instance.add(_onEvent);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _accent = Theme.of(context).colorScheme.primary; // capture the value
  }

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

  @override
  void dispose() {
    AppEvents.instance.remove(_onEvent); // same tear-off, bounded lifetime
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => ColoredBox(color: _accent);
}

go deeper

for a junior

Remember that BuildContext is the element and that a closure capturing it should not be stored in anything that outlives the screen.

for a middle

Explain what the element reaches (widget, State, subtree) and the two fixes: capture resolved values, or register in initState and unregister in dispose.

for a senior

Show you can find such a leak through a retaining path that passes through a closure context, and that you keep services free of BuildContext by design.

for a principal

Frame it as an API design rule: services expose streams or callbacks the UI subscribes to and releases, never accepting context, so leaks cannot be written by accident.

## Why context is heavy In Flutter, every widget in the tree is backed by an **element**, and the `BuildContext` passed to `build` **is** that element. An element keeps references to: - its current **widget** configuration; - its **`State`**, for a stateful widget; - its **parent and children**, and through them much of the subtree. So `context` is a handle to a large, short-lived graph that should become garbage when the screen closes. Closures make it easy to capture that handle without noticing: any lambda that calls `Theme.of(context)`, `Navigator.of(context)` or `ScaffoldMessenger.of(context)` keeps a reference to the element in its closure context. ## When it becomes a leak A captured `context` is harmless while the closure lives no longer than the widget. It leaks when the closure is **stored somewhere that outlives it**: 1. a callback field on a **singleton** service or global event bus; 2. a listener added to a **long-lived** `ChangeNotifier` or stream and never removed; 3. a **static** list of handlers; 4. a pending callback kept by a plugin or platform object. As long as that holder is reachable, the closure is reachable, and so are the element, the `State` and the subtree. Each visit to the screen adds another copy. The DevTools memory docs single out closures and `BuildContext` for exactly this reason. ## The two fixes | Situation | Leak-prone | Leak-free | |---|---|---| | the closure needs a value derived from context | `() => apply(Theme.of(context))` | `final theme = Theme.of(context);` then `() => apply(theme)` | | the closure must be called later by a long-lived object | registered in `build`, never removed | registered in `initState`, removed in `dispose` | The first fix works because the closure now captures only the resolved value, such as the `ThemeData`, which is long-lived and shared, instead of the short-lived element. The second fix keeps the reference but bounds its lifetime to the screen. ## Related habits - **Do not register in `build`.** `build` can run many times; registering there adds a new closure each time and makes a clean removal almost impossible. - **Prefer method tear-offs for callbacks you must remove.** `service.addListener(_onEvent)` and `service.removeListener(_onEvent)` match; two lambdas do not. - **Check `mounted` before using context after an `await`.** That guard is about correctness, not memory: it stops code from touching an element that is already unmounted. - **Do not hand `context` to services.** Pass the data or a callback the widget owns, so the service never holds the element. ## How it shows up in DevTools In a **Diff Snapshots** comparison after opening and closing the screen several times, the leaked screen's `State` and element classes grow by the number of visits. The **retaining path** of one instance runs from a root through the long-lived holder, then a closure context, to the element: the closure is the edge to cut. ## The rule to remember If the closure does not outlive the widget, capturing `context` is fine. If it might, capture the values you need instead, or tie the registration to `initState` and `dispose`.

  • Is it a leak to use context inside an onPressed closure in a Flutter build method?
    No. The closure is stored by the button widget, which lives in the same subtree and is dropped with it, so the closure never outlives the element it captures. The problem only appears when the closure is handed to an object that lives longer than the screen.
  • Why does resolving Theme.of(context) before building a Flutter closure avoid the leak?
    The closure then captures only the resolved `ThemeData`, a long-lived object shared across the app, instead of the short-lived element. Even if the closure is kept somewhere long-lived, it no longer reaches the element, the State or the subtree.

Capturing context is like giving a courier the key to your whole house when they only needed one parcel: as long as the courier keeps the key, the house cannot be sold. Hand over the parcel (the resolved value) instead.

saying these in an interview costs you the question

  • Any use of context inside a closure leaks memory.
  • BuildContext is a small value object, so capturing it costs nothing.
  • Checking mounted after an await prevents memory leaks from captured context.
  • Registering a listener in build() is fine because build() cleans up old closures.
  • Passing BuildContext into a service singleton is a clean way to reach the Navigator.