You can read about Flutter performance all day and still freeze the first time you open DevTools on a real bug: a wall of graphs, three kinds of “memory,” a timeline scrolling past, and no idea which number is the one that matters.
The only cure is reps. So I built a place to get them: performance_demo — an open-source app that’s a menu of innocent-looking screens. Each one either hides a real defect (a performance problem or a memory leak) or is a false positive: it looks broken in DevTools but is perfectly healthy by design. Your job is to open the tools, reproduce the symptom, and decide which is which.
The screen names give nothing away on purpose — “Counter panel,” “Product search,” “Activity feed.” A real bug never announces itself either.
This post is the hands-on companion to my four-part series on Flutter performance. The series explains the why; this is where you drive the tools yourself:
- Part 1 — Rebuilds
- Part 2 — Jank: UI thread vs raster
- Part 3 — Memory leaks
- Part 4 — When NOT to optimize
Getting set up
Clone it and run one of three ways — the mode you pick decides what you can even measure:
git clone https://github.com/GeraSaucedo/performance_demo
cd performance_demo
flutter run --debug # inspect: rebuild counter, widget inspector
flutter run --profile # measure: jank, CPU, memory (realistic timings)
flutter run --release # ship: no tooling
When it launches, the console prints a DevTools URL — open it, or use your IDE’s button.
| You want to measure… | Mode | Why |
|---|---|---|
| Rebuilds, widget tree | Debug | The rebuild counter and Inspector only exist in debug |
| Jank, CPU, ms per frame | Profile | In debug the timings lie — the code runs unoptimized |
| Memory / leaks | Debug or Profile | Leaks are representative in both |
| Raster / GPU | Profile + real device | Emulator GPU times run inflated — fine for reading the pattern, wrong for the numbers |
The one rule that saves you hours: Debug to inspect · Profile to measure · Release to ship.
The loop
Every real case in the playground runs the same four steps. Internalize this and the tool choice becomes automatic:
- Detect the symptom (a red bar, memory that keeps climbing).
- Locate the source with the right tool (table below).
- Fix it.
- Validate the symptom is gone.
| Problem type | Symptom | Right tool |
|---|---|---|
| Excessive rebuilds | build() runs when it shouldn’t | Rebuild Stats + Count widget builds (Performance, debug) |
| Heavy compute (UI/CPU) | high UI-thread time, jank | CPU Profiler (Bottom Up / self time) |
| Compositing (GPU) | high Raster time, UI ~0 | Frame Analysis (profile) + Highlight Repaints (debug) |
| Memory leak | memory grows and never drops | Memory → Diff Snapshots + retaining path |
Driving DevTools: what to click
A short operating guide for the three views this playground uses. (Labels are from recent DevTools; older versions shuffle things around, but the flow is identical.)
Performance — rebuilds and jank
- The Flutter frames chart runs across the top. Every frame is a pair of bars: the UI thread (your Dart code) and the Raster thread (the GPU). A bar turns red when that frame blew its budget — about 16 ms on a 60 Hz screen, half that at 120.
- Click any red frame to fill the Frame Analysis tab below it, which prints hints about what made the frame slow.
- To count rebuilds, switch to the Rebuild Stats tab and tick Count widget builds. That’s the one that gives you the table of widgets with Latest frame / Overall counts.
- Don’t confuse it with Trace widget builds, which lives in the Enhance Tracing
dropdown. That one adds a named
Buildevent per widget to the timeline — useful for seeing where build time goes inside a frame, but it does not produce the counts table. Two different features, similar names. Both are debug-only. - Under More debugging options you can toggle off Render Opacity layers to test
whether an
Opacityis your raster cost. The Flutter Inspector also has a Highlight Repaints toggle that draws a rotating rainbow border around anything repainting. Both are debug-only, so raster work is two passes: measure the timings in profile, identify the widget in debug.
CPU Profiler — which Dart code is slow
- Press Record, reproduce the jank (type, scroll), then Stop.
- Open the Bottom Up tab and sort by Self Time. The row on top with high self time is where the time is actually spent. High Total but low Self → the cost is deeper, keep expanding. Read percentages, not milliseconds — the ms are totals over the whole recording, not per frame.
Memory — snapshots and how to compare them
This is the flow most people fumble, so here it is button by button:
- Open the Memory tab. The live chart on top plots total heap over time; the toolbar has a GC button that forces garbage collection.
- Switch to the Diff Snapshots sub-tab.
- Get to a clean baseline (say, the app’s Home). Click GC, then Take snapshot — call it Snapshot 1.
- Do the thing you suspect leaks: here, tap the screen’s “Recycle 20…” button once or twice. (In a real app: enter and leave the feature 10–20 times.)
- Return to the same baseline state. Click GC again, then Take snapshot — Snapshot 2.
- Select Snapshot 2 and set the Diff with selector to Snapshot 1. The table now shows only what changed between the two.
- Sort by the Delta column (net change in instance count) and find your class. A positive Delta that doesn’t return to zero after GC is the leak.
- Click the class, pick an instance, and read its retaining path in the side panel — the
shortest chain of references keeping it alive. It usually bottoms out at a Closure
Context. DevTools elides the middle of long chains, so the object at the far end — a
Timer, a globalStreamController, aValueNotifierlistener — is the hop you infer from your own code.
Two non-negotiables: force GC before every snapshot (otherwise you count transient garbage that was about to be collected), and take both snapshots in the same app state (otherwise legitimate objects from another screen masquerade as a leak). Watch the live chart for the trend — a baseline that steps up and never comes back is a leak in progress.
Lab A — the real problems (Demos 1–6)
For each: reproduce the symptom, find the guilty frame, then confirm the fix. The applied
fixes live on the repo’s solutions branch; the reasoning lives in the linked essay.
Demo 1 · “Counter panel” — excessive rebuilds
Do this: run in debug, open the screen, go to Performance, switch to the Rebuild Stats tab and tick Count widget builds.
The frame you’re hunting: MetricCard rebuilding on every tick — once for each card
currently on screen — even though the cards never change. Don’t expect the full 300: GridView
is lazy, so only the visible ones get built and the exact count depends on your viewport. What
does happen 300 times per tick is List.generate allocating 300 fresh widget objects.

Verdict: real. The setState sits at the root of the screen, so the whole subtree
rebuilds. Isolate the changing value behind a ValueNotifier, and stop handing the grid a
fresh list of cards on every build — hoist the instances into a field. (const can’t do it
here: the cards take a runtime index, so the call site can’t be const.)
→ the why, in Part 1.
Demo 2 · “Product search” — heavy work on the UI thread
Do this: run in profile, open Performance, and type in the search box. Then in the CPU Profiler press Record, type some more, press Stop, and open Bottom Up sorted by Self Time.
The frame you’re hunting: tall red UI-thread bars per keystroke (raster near zero),
and in the CPU Profiler’s Bottom Up view, _expensiveFilter at the top by self time.

Verdict: real. An O(n²) filter runs inside build() on every keystroke. Precompute
it, drop the O(n²), and if needed push it to an isolate. → Part 2.
Demo 3 · “Activity feed” — eager list + Opacity
Do this: run in profile (real device for the raster part), open the screen, scroll, and read the raster times. Then switch to debug and use Highlight Repaints or toggle off Render Opacity layers to identify which widget is the cost.
The frame you’re hunting: one giant frame at open (all 5000 rows built at once), and a
busy raster thread from an Opacity layer per row.

Verdict: real, on both threads. Use ListView.builder (lazy) and remove the pointless
Opacity. → Part 2.
Demos 4–6 · leaks: timer, stream, animation
Do this: run in profile and follow the snapshot-and-diff flow above (Memory → Diff Snapshots), using the screen’s “Recycle 20…” button as the action between the two snapshots.
The frame you’re hunting: the demo’s State class (_LeakyClockState,
_LiveNotificationsState, _PulsingCardState) with Delta: +N and Released: 0 — it
grew and GC couldn’t reclaim it.

Select an instance and open its retaining path to see the chain holding it — it ends in
the closure of a Timer, a global StreamController, or a ValueNotifier listener:

Verdict: all real. Each is a missing cleanup in dispose — the
initState ↔ dispose symmetry. Demo 6 is the sneaky one: it leaks twice (controller +
listener), and the listener must be a named method to be removable.
→ Part 3.
Lab B — the false positives (Demos 7–9)
These are not bugs, and they are not on the solutions branch — because there’s
nothing to solve. The exercise is the opposite: reproduce the scary symptom, then prove to
yourself it’s benign. This is the muscle most people never train.
Demo 7 · “Tabbed reports” — keepAlive
Symptom: a _ReportTabState instance alive for every tab you’ve visited, with only one
tab on screen. Looks like a leak.
Confirm it’s benign: keep switching and the count stops at four, one per tab — it’s
bounded, and that’s the whole difference. The tab counters are also preserved when you
come back; that retained state is the AutomaticKeepAliveClientMixin doing its job.

Demo 8 · “Live monitor” — sawtooth memory
Symptom: memory climbing steadily while the monitor runs. Looks like a leak.
Confirm it’s benign: watch for 20 seconds — it’s a sawtooth, and the baseline is flat. That’s healthy GC collecting transient lists, not accumulation.

Demo 9 · “Animated dashboard” — isolated jank spike
Symptom: one red frame in the timeline when you tap “Reload data.” Looks like jank.
Confirm it’s benign: it’s one frame from a one-off action, with the frames around it back inside the budget and the average frame rate still essentially at your display’s refresh rate. Sustained jank during interaction is a problem; a lone spike is not.

The through-line of Lab B: bounded and intentional isn’t a leak; a flat baseline isn’t a leak; an isolated spike isn’t jank. Knowing when to close the profiler is a senior skill. → Part 4.
How to actually learn from it
A suggested path, in order:
- Run each screen and try to diagnose it yourself first — reproduce the symptom, pick the tool from the table, form a verdict (real bug or false positive?).
- Only then check your reasoning against the write-ups. The six real defects are in
SOLUTIONS.md(spoilers — that’s the point); the three false positives are documented in theREADME, since there’s no solution to write for them. - Diff
mainagainst thesolutionsbranch to see the applied fixes, small and focused. - Read the matching essay for the mental model behind each one.
The real skill this trains isn’t memorizing fixes — it’s the reflex of turning a symptom into the right question: which thread is red? is this bounded or unbounded? peak or baseline? sustained or one-off? Once that’s automatic, DevTools stops being a wall of graphs and becomes what it’s meant to be: a way to see.
The repo is open — GeraSaucedo/performance_demo. Clone it, break it, fix it, and argue with the false positives.

