When NOT to optimize: reading a false positive in DevTools

Four States alive when only one is visible. Memory climbing every 400 ms. A red frame on reload. All three look like bugs in DevTools. All three are healthy by design — and knowing that is the senior skill.

Across this series we learned to spot three real problems: excessive rebuilds, jank, and memory leaks. Now the harder skill — the one that separates someone who uses DevTools from someone who reads it.

DevTools will show you things that look exactly like those problems and are completely healthy. Four State objects alive when only one is on screen. Memory climbing steadily. A red frame in the timeline. Each of these sets off the same alarm you learned to trust — and each is working as intended.

Chasing a false positive isn’t neutral. You spend hours, you add complexity, and often you break a feature that was fine. Knowing when to close DevTools and walk away is as valuable as knowing how to fix. Here are the three most common traps, and how to prove to yourself that there’s nothing to fix.

All three are Demos 7, 8 and 9 of performance_demo — and unlike the six demos before them, they have no counterpart on the solutions branch, because there is nothing to solve. If you’d rather drive the tools yourself than read about them, the playground post is the guided tour. One note on the listings below: each screen in the repo also prints a one-line reminder of its own lesson on screen — you can see it in the captures — which I’ve trimmed here to keep the code short.


False positive 1: keepAlive tabs “leaking” State

A tabbed report screen — Sales, Costs, Shipping, Returns. You profile memory, switch through the tabs, come back, take a diff… and find several _ReportTabState instances alive at once — one for every tab you’ve visited — even though only one tab is on screen. They pile up and GC won’t release them. That’s the leak signature from Part 3. Isn’t it?

Here’s the complete demo (home: const Demo7KeepAliveTabs()):

import 'package:flutter/material.dart';

class Demo7KeepAliveTabs extends StatelessWidget {
  const Demo7KeepAliveTabs({super.key});

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 4,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('Tabbed reports'),
          bottom: const TabBar(
            tabs: [
              Tab(text: 'Sales'),
              Tab(text: 'Costs'),
              Tab(text: 'Shipping'),
              Tab(text: 'Returns'),
            ],
          ),
        ),
        body: const TabBarView(
          children: [
            ReportTab(label: 'Sales'),
            ReportTab(label: 'Costs'),
            ReportTab(label: 'Shipping'),
            ReportTab(label: 'Returns'),
          ],
        ),
      ),
    );
  }
}

class ReportTab extends StatefulWidget {
  const ReportTab({super.key, required this.label});

  final String label;

  @override
  State<ReportTab> createState() => _ReportTabState();
}

class _ReportTabState extends State<ReportTab>
    with AutomaticKeepAliveClientMixin {
  int _counter = 0;

  // Keep the State alive when switching tabs (to preserve the state).
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context); // required by AutomaticKeepAliveClientMixin
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('Report for ${widget.label}',
              style: Theme.of(context).textTheme.headlineSmall),
          const SizedBox(height: 16),
          Text('Counter: $_counter'),
          const SizedBox(height: 8),
          FilledButton(
            onPressed: () => setState(() => _counter++),
            child: const Text('Add'),
          ),
        ],
      ),
    );
  }
}

The AutomaticKeepAliveClientMixin with wantKeepAlive => true is keeping those States alive on purpose. That’s the entire point of the mixin: it preserves each tab’s state — the counter, the scroll position, a half-filled form — so switching away and back doesn’t reset it.

Why it’s not a leak

Two tests distinguish this from a real leak:

  • It’s bounded. There are four tabs, so at most four States. The diff counts what’s new since your baseline snapshot, so you’ll see one per tab you actually visited — three, in the capture below — and it stops at four no matter how long you keep switching. A real leak grows without limit as you repeat the action; that was the Delta: +N, +N, +N you watched climb in Part 3. Bounded is not a leak.

Flutter DevTools Diff Snapshots: a bounded set of _ReportTabState instances (one per visited tab), Released 0, retained by the Flutter keepAlive machinery

  • The state is wanted. Add to the counter on one tab, switch away, come back: the counter is preserved. That preserved value is the retained State doing its job. Remove the mixin and you’d see a single live instance — and lose the counter every time you switch.

Bounded and intentional retention is a cache, not a leak. The question isn’t “is it still in memory?” but “does it grow forever, and is anyone relying on it being there?”


False positive 2: sawtooth memory

A live monitor screen. Start it, and every 400 ms it generates a large temporary list. Watch the memory graph and it climbs and climbs — the alarm from Part 3 again.

Here’s the complete demo (home: const Demo8Sawtooth()):

import 'dart:async';
import 'package:flutter/material.dart';

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

  @override
  State<Demo8Sawtooth> createState() => _Demo8SawtoothState();
}

class _Demo8SawtoothState extends State<Demo8Sawtooth> {
  Timer? _timer;
  bool _running = false;
  int _ticks = 0;
  int _lastSize = 0;

  void _toggle() {
    setState(() {
      _running = !_running;
      if (_running) {
        _timer = Timer.periodic(const Duration(milliseconds: 400), (_) {
          // Temporary garbage: created, used trivially, out of scope by the next tick.
          final temp = List.generate(300000, (i) => 'sample $i · batch $_ticks');
          _lastSize = temp.length;
          setState(() => _ticks++);
          // `temp` dies here → the GC will collect it → sawtooth.
        });
      } else {
        _timer?.cancel();
      }
    });
  }

  @override
  void dispose() {
    _timer?.cancel(); // clean: this demo does not leak
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Live monitor')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(_running ? Icons.monitor_heart : Icons.monitor_heart_outlined,
                size: 64, color: Colors.indigo),
            const SizedBox(height: 16),
            Text('Updates: $_ticks'),
            Text('Last batch: $_lastSize samples'),
            const SizedBox(height: 16),
            FilledButton.icon(
              onPressed: _toggle,
              icon: Icon(_running ? Icons.stop : Icons.play_arrow),
              label: Text(_running ? 'Stop monitor' : 'Start monitor'),
            ),
          ],
        ),
      ),
    );
  }
}

Why it’s not a leak

Watch the graph for 20 seconds instead of 2. It doesn’t climb — it makes a sawtooth: memory rises as each list is allocated, then drops sharply when the GC collects it, over and over. What matters isn’t the peaks, it’s the baseline — the valleys the graph keeps falling back to. Here the baseline is flat.

That shape is something you have to watch live — a screenshot flattens the teeth into a thin band, because the chart’s scale is sized for hundreds of megabytes and this heap sits under ten. What a still frame does show: a small heap, and the app’s own classes sitting flat.

Flutter DevTools Profile Memory with the monitor running: a heap under 10 MB and a class table where no class is accumulating

A real leak has a baseline that steps up and never comes back down — each valley higher than the last. A sawtooth with a flat baseline is just healthy garbage collection doing exactly what it’s for: short-lived objects being born and collected on schedule. Nothing accumulates.

The diff settles it either way: GC → Snapshot A (monitor running) → wait → GC → Snapshot B → Diff. No class is growing. Stop the monitor and memory settles. (And the timer is cancelled in dispose — so the screen itself doesn’t leak either.)

The rule: sawtooth with a flat baseline = healthy GC. A baseline that only steps up = leak. Judge memory by the valleys, not the peaks.


False positive 3: an isolated jank spike

Run an animated dashboard in profile mode. A spinner turns smoothly — a steady run of short blue bars. Then you tap “Reload data” and one red frame appears in the timeline. Jank, per Part 2. Do we optimize?

Here’s the complete demo (home: const Demo9IsolatedJank()):

import 'package:flutter/material.dart';

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

  @override
  State<Demo9IsolatedJank> createState() => _Demo9IsolatedJankState();
}

class _Demo9IsolatedJankState extends State<Demo9IsolatedJank>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  int _reloads = 0;
  int _checksum = 0;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    )..repeat();
  }

  @override
  void dispose() {
    _controller.dispose(); // clean: this demo does not leak
    super.dispose();
  }

  void _reload() {
    // One-off work (~one frame): sort a large list.
    final data = List.generate(500000, (i) => (i * 7919) % 500000);
    data.sort();
    setState(() {
      _reloads++;
      _checksum = data[data.length ~/ 2]; // use the result so it isn't elided
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Animated dashboard')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            RotationTransition(
              turns: _controller,
              child: const Icon(Icons.settings, size: 96, color: Colors.indigo),
            ),
            const SizedBox(height: 24),
            Text('Reloads: $_reloads  ·  checksum: $_checksum'),
            const SizedBox(height: 16),
            FilledButton.icon(
              onPressed: _reload,
              icon: const Icon(Icons.refresh),
              label: const Text('Reload data'),
            ),
          ],
        ),
      ),
    );
  }
}

Why it’s not a problem

It’s one red frame, caused by a one-off action — loading data on a button press, entering a screen — with the frames around it back inside the budget. (The one right after it runs a bit taller — the setState landing — but stays under the jank threshold.) The animation doesn’t stutter before or after, and the average frame rate stays essentially at your display’s refresh rate: one spike averaged across a whole recording barely moves it, which is another way of saying nobody will ever perceive it.

Flutter DevTools Performance: one red frame (1224) surrounded by healthy blue frames, 57 FPS average

That’s the distinction that matters: isolated spike vs sustained jank. The jank worth chasing is the sustained kind — many red frames in a row while the user interacts. Compare it with the search box in Part 2, which stuttered on every keystroke: that was sustained, that was a real problem. A lone slow frame from a one-time computation is not.

Sustained jank (many red frames in a row during interaction) = fix it. Isolated spike (one red frame from a one-off action, average frame rate still essentially at the display’s refresh rate) = ignore it. The first frame after a route transition is slow too — ignore that one on principle.

If the one-off work were big enough to freeze the animation for a noticeable beat, you’d move it off the UI thread (compute()/isolate, as in Part 2). But a single frame nobody perceives isn’t worth the complexity.


The habit underneath all three

Notice what each false positive shares: a symptom that pattern-matches a real problem, and a second question that dissolves it.

Looks likeThe tell that it’s benign
A leak (States pile up)Bounded and intentionally retained (keepAlive, a cache)
A leak (memory climbing)Sawtooth with a flat baseline — the GC is collecting
Jank (a red frame)Isolated and one-off, average frame rate still at target

A few more you’ll meet in the wild:

  • The navigation stack. Screens you push without pop are still alive — legitimately, they’re on the stack. Going back releases them.
  • The image cache. Flutter caches decoded images on purpose; memory rises but it’s bounded and released under pressure.
  • Uncollected garbage. Forget to force GC before a snapshot and you’re counting transient objects that were already on their way out — the sawtooth’s peaks frozen in a still frame.

Closing the series

Four parts, one mental model.

  • Part 1 — rebuilds: setState rebuilds the owner’s subtree, so isolate the state and reuse the static instances — const where the arguments allow it, a hoisted field where they don’t.
  • Part 2 — jank: a frame missed ~16 ms; read which thread went red (your Dart code vs the GPU) and pick the matching tool.
  • Part 3 — leaks: the GC keeps whatever is referenced, so mirror every initState with a dispose.
  • Part 4 — false positives: a symptom can be healthy by design, so learn the second question that tells you to walk away.

The thread through all of it: DevTools doesn’t hand you answers, it hands you symptoms. The skill is turning a symptom into the right question — which thread, bounded or unbounded, peak or baseline, sustained or one-off — and, just as often, concluding there’s nothing to fix. Optimizing what’s already fine is its own kind of bug. The best performance work sometimes ends with closing the profiler and shipping.

Language · Idioma

English Español