A DevTools playground: learn Flutter performance by breaking things on purpose

I built an open-source app that's a menu of screens — each one hides a real performance or memory bug, or looks broken but is healthy by design. A hands-on lab for reading Flutter DevTools frame by frame.

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:


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…ModeWhy
Rebuilds, widget treeDebugThe rebuild counter and Inspector only exist in debug
Jank, CPU, ms per frameProfileIn debug the timings lie — the code runs unoptimized
Memory / leaksDebug or ProfileLeaks are representative in both
Raster / GPUProfile + real deviceEmulator 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:

  1. Detect the symptom (a red bar, memory that keeps climbing).
  2. Locate the source with the right tool (table below).
  3. Fix it.
  4. Validate the symptom is gone.
Problem typeSymptomRight tool
Excessive rebuildsbuild() runs when it shouldn’tRebuild Stats + Count widget builds (Performance, debug)
Heavy compute (UI/CPU)high UI-thread time, jankCPU Profiler (Bottom Up / self time)
Compositing (GPU)high Raster time, UI ~0Frame Analysis (profile) + Highlight Repaints (debug)
Memory leakmemory grows and never dropsMemory → 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 Build event 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 Opacity is 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:

  1. Open the Memory tab. The live chart on top plots total heap over time; the toolbar has a GC button that forces garbage collection.
  2. Switch to the Diff Snapshots sub-tab.
  3. Get to a clean baseline (say, the app’s Home). Click GC, then Take snapshot — call it Snapshot 1.
  4. 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.)
  5. Return to the same baseline state. Click GC again, then Take snapshotSnapshot 2.
  6. Select Snapshot 2 and set the Diff with selector to Snapshot 1. The table now shows only what changed between the two.
  7. 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.
  8. 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 global StreamController, a ValueNotifier listener — 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.

Rebuild Stats with Count widget builds enabled: MetricCard, Card, Icon and Text at 24 per frame

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.

Red UI-thread frames on every keystroke, Frame Analysis showing Build as the longest phase

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.

Frame Analysis: Raster Jank Detected, raster phase dominating the frame

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.

Memory Diff Snapshots: State instances with New 20 and Released 0

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:

Retaining path ending in a Closure Context, the reference that keeps the State alive

Verdict: all real. Each is a missing cleanup in dispose — the initStatedispose 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.

A bounded set of _ReportTabState instances (one per visited tab), Released 0

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.

Flutter DevTools Profile Memory with the monitor running: a small heap and a class table where nothing accumulates

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.

One red frame surrounded by healthy blue frames, ~57 FPS average

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:

  1. 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?).
  2. 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 the README, since there’s no solution to write for them.
  3. Diff main against the solutions branch to see the applied fixes, small and focused.
  4. 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.

Language · Idioma

English Español