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?
answer
- chart floor climbs per visit
- snapshot, N open-close cycles, snapshot
- delta that scales with N
- retaining path to the holder
- re-diff after the fix
basics
~20 sSnapshot 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.
solid answer
~50 sI first confirm the trend on the memory chart: the Dart heap should return to its level after the chat closes, and here it does not. Then **Diff Snapshots**: a snapshot at the chat list, open and close the chat, say, five times, a second snapshot, and a diff filtered to my package. The leak shows as classes growing in step with the visits, for example **+5 `_ChatScreenState`** plus their messages and controllers. I select one instance and read its **retaining path**, which ends at the long-lived holder: typically the socket service's stream holding an uncancelled `StreamSubscription`, or a listener on an app-wide notifier. I fix that owner in `dispose()`, repeat the same diff to see the delta return to zero, and add a widget test with leak tracking so it cannot come back. If I do not know who creates the growing class, Trace Instances gives me the allocation site.
code
dart · 13 linesimport 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/chat/chat_screen.dart';
void main() {
// Runs with LeakTesting.enable() from test/flutter_test_config.dart.
testWidgets('chat screen releases its resources when popped', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: ChatScreen(roomId: 'general')));
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
await tester.pumpAndSettle();
expect(find.byType(ChatScreen), findsNothing);
});
}go deeper
Know the tool sequence: memory chart, then Diff Snapshots before and after opening and closing the screen, then look at which classes grew.
Explain why you repeat the visit several times, how to read instance deltas and what a retaining path shows.
Demonstrate the full diagnosis: separate cache growth from leaks, find the culprit on the retaining path, fix ownership, verify with the same diff and lock it in with a leak-tracked test.
Discuss prevention at scale: service APIs that return subscriptions the UI must cancel, ownership conventions for controllers, and leak tracking as a CI gate.
## Step 1: confirm it is a leak, not a cache Open the **memory view** with the app running in debug or profile mode. Watch the chart while you open and close the chat screen a few times. A healthy screen allocates while open and the **Dart heap returns close to its previous level** after it closes and a GC runs. A leak shows a floor that steps up with each visit. Not every growth is a leak. An image cache, a message cache or a lazily created service grows once and then stays flat. The signal you want is growth that **scales with the number of visits**. ## Step 2: diff snapshots around repeated visits 1. Navigate to the chat list, the baseline, and take a snapshot in **Diff Snapshots**. 2. Open the chat and go back, **five times**. 3. Take a second snapshot and diff it against the first. 4. Use **Filter classes and packages** to show your own package first, then framework classes. 5. Sort by instance delta. Typical leaked-screen signature: | Class | Delta | Meaning | |---|---|---| | `_ChatScreenState` | +5 | one per visit: the screen itself is retained | | `ChatMessage` | +5 x messages per visit | retained through the State's fields | | `TextEditingController`, `ScrollController` | +5 each | retained with the State | The controllers are usually **victims**; the question is what holds the `State`. ## Step 3: read the retaining path Select `_ChatScreenState` and inspect one instance's **retaining path**, the chain of references from a root to it. Read it from the root end and stop at the first object that should outlive the screen: - a **service singleton** exposing a message stream, whose stream controller holds your **`StreamSubscription`**, whose callback captures the `State`; - an app-wide **`ChangeNotifier`** such as a connection-status notifier, whose listener list holds your callback; - the **scheduler** holding a running `Ticker` from an undisposed `AnimationController`, such as a typing indicator; - a **static** list or event bus holding a closure that captured `context`. That object is the culprit. The `State`, its controllers and messages are what it drags along. ## Step 4: fix the ownership, not the symptom - Store the subscription and call `cancel()` in `dispose()`. - Remove the listener in `dispose()` with the **same** function you added. - Dispose the animation controller before `super.dispose()`. - Keep `context` out of anything the service stores. Avoid clearing the message list in `dispose()` as a "fix": it shrinks the victim while the `State` still leaks. ## Step 5: verify and prevent 1. Repeat the exact same diff; the `_ChatScreenState` delta should be **0**. 2. If a growing class has no obvious creator, use **Trace Instances** on it to find the allocating call stack. 3. Add a widget test that pumps the chat, pops it and runs with leak tracking enabled through `LeakTesting.enable()`, so an undisposed controller fails CI. ## Pitfalls in the investigation - **One visit proves little.** Use several, so the leak stands out from one-off allocations. - **Absolute sizes in debug mode are inflated** by debug instrumentation; compare deltas, not totals. - **Native memory is separate.** Decoded images and platform buffers appear as native memory on the chart, not as Dart instances in the diff. - **Fix culprits first.** Victims disappear on their own once the holder lets go.
- The diff shows +5 TextEditingController and +5 _ChatScreenState after five Flutter chat visits; which do you fix first, and why?The State. The controllers are almost certainly retained through the State's fields, so they are victims. The retaining path of a `_ChatScreenState` instance leads to the real holder, such as an uncancelled subscription on a service stream. Once that reference is released, the State and its controllers become unreachable together.
- Why is clearing the Flutter chat screen's message list in dispose() not a fix for this leak?It reduces how much the leak costs per visit but leaves the `State` itself retained by the long-lived holder. The instance delta stays at one per visit, and anything else the State references still leaks. The fix is removing the reference that reaches the State.
saying these in an interview costs you the question
- Any memory increase after opening a screen once proves a leak.
- The retained controllers are the root cause, so disposing them twice fixes it.
- Clearing the messages list in dispose() solves a leaked State.
- Decoded images should appear as Dart instances in the snapshot diff.
- Absolute heap size in debug mode is the number to compare against release.