Why does your app feel slow? Two stutters, two different fixes

Typing in a search box stutters. A list lags the moment it opens. Both are jank — but one is your Dart code and the other is the GPU, and DevTools tells them apart. Everything you ship has to fit in 16 milliseconds.

In Part 1 we made rebuilds smaller so Flutter stops redoing work it doesn’t need to. But sometimes the work is genuinely necessary — it’s just too heavy to fit in the time a frame has. That’s jank: a frame that misses its deadline, and the visible stutter that follows.

To understand jank you need one number and two threads.

The number is your frame budget, and the screen sets it, not you. At 60 Hz a frame lasts about 16 ms; at 90 Hz about 11 ms; at 120 Hz about 8 ms. Everything needed to produce that frame has to fit inside it. Go over, and the frame is late — the screen stutters. So it’s worth knowing what your target devices actually run at: code that’s comfortable at 60 Hz can be janky at 120. It’s also what “a better frame rate” really means: not a number that climbs forever — the refresh rate is a ceiling you can’t beat — but closing the gap between what you’re getting and what the hardware already offers. Both fixes below do exactly that.

The two threads are where that work happens:

  • The UI thread runs your Dart code: build(), layout, your logic. This is where rebuilds and computations live.
  • The raster thread (the GPU side) turns the result into pixels: compositing layers, applying effects, drawing.

Jank can come from either one, and the fix is completely different depending on which. DevTools’ job is to tell you which thread blew the budget. Let’s look at one problem on each.

Two things before we start. Measure in profile modeflutter run --profile. Timings in debug builds lie, because the code runs unoptimized. (Part 1 counted rebuilds in debug; profile is for measuring time.) And read every number in the screenshots below as a reference point, not a target: they come from one run on one Android emulator, and yours will land somewhere else depending on CPU, Flutter version, and emulator vs real device. What transfers between machines is the shape — which phase dominates the frame, and roughly by how much.

Both problems are Demo 2 and Demo 3 of performance_demo, the open-source playground I walk through in its own post — clone it if you’d rather reproduce the traces yourself than read them. The fixes shown here are its solutions branch.


Problem 1: a laggy search box (UI thread)

A product search over a catalog of 4000 items. You type, and the cursor and the list stutter with every keystroke. Here’s the offending screen — a complete, runnable file (home: const Demo2HeavyBuild()):

import 'package:flutter/material.dart';

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

  @override
  State<Demo2HeavyBuild> createState() => _Demo2HeavyBuildState();
}

class _Demo2HeavyBuildState extends State<Demo2HeavyBuild> {
  final TextEditingController _search = TextEditingController();

  final List<String> _catalog = List.generate(
    4000,
    (i) => 'Product ${(i * 7919) % 4000} batch ${i % 97}',
  );

  @override
  void dispose() {
    _search.dispose();
    super.dispose();
  }

  // Artificially heavy: for each product it walks the WHOLE catalog again
  // (O(n²)) doing string work. And it runs on the UI thread.
  List<String> _expensiveFilter(String query) {
    final result = <String>[];
    for (final item in _catalog) {
      var score = 0;
      for (final other in _catalog) {            // <-- the inner loop: O(n²)
        if (other.codeUnitAt(0) == item.codeUnitAt(0)) score += other.length;
      }
      if (query.isEmpty || item.toLowerCase().contains(query.toLowerCase())) {
        result.add('$item  ·  score $score');
      }
    }
    return result;
  }

  @override
  Widget build(BuildContext context) {
    // The expensive computation runs HERE, on every rebuild (every keystroke).
    final filtered = _expensiveFilter(_search.text);

    return Scaffold(
      appBar: AppBar(title: const Text('Product search')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(12),
            child: TextField(
              controller: _search,
              decoration: const InputDecoration(
                prefixIcon: Icon(Icons.search),
                hintText: 'Type to filter…',
                border: OutlineInputBorder(),
              ),
              onChanged: (_) => setState(() {}),
            ),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: filtered.length,
              itemBuilder: (context, i) => ListTile(
                dense: true,
                title: Text(filtered[i]),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Seeing it

Open Performance and type a few characters. Every keystroke produces a frame well over 16 ms — a tall red bar on the UI thread, while the raster thread sits near zero.

Flutter DevTools Frame Analysis: UI Jank Detected, Build 28.7 ms vs Raster 2.9 ms, 46 FPS

The Frame Analysis tab spells it out: “Build was the longest UI phase” — in that capture, roughly ten times more build than raster. The absolute milliseconds are incidental; the ratio is the diagnosis. That split — UI thread high, raster near zero — is the fingerprint of “my Dart code is too slow,” and it looks the same on any hardware even when the digits don’t. To pin down which code, record the CPU Profiler while you type and sort Bottom Up by self time: _expensiveFilter dominates the recording.

Reading the CPU Profiler. Its milliseconds are totals over the whole recording, not per-frame — don’t compare them to the 16 ms budget. Use percentages to find the culprit. High Total time + low Self time → the cost is deeper, keep drilling down. High Self time → the time is spent right here. _expensiveFilter will show high self time: that’s where it burns.

Why it’s slow

Two things compound. First, _expensiveFilter is O(n²) over 4000 items — that’s 16 million iterations per call. Second, it runs inside build(), so it fires on every keystroke, on the UI thread, blocking the very frames that should be showing your keystroke.

Fixing it

There are three independent moves, in order of impact:

  1. Don’t compute in build(). build() should be cheap and mostly declarative. Compute the filtered list when the query actually changes (in onChanged), store it in state, and let build() just read it.
  2. Kill the O(n²) per keystroke. Here the score doesn’t even depend on the query — so compute all scores once in initState, and every filter after that is a single O(n) pass. Be honest about what this buys, though: the 16 million iterations still happen, they just happen one time instead of once per keystroke. You’ve traded a stutter on every keystroke for a single hitch when the screen opens — which is exactly why there’s a step 3.
  3. If it’s still heavy, get it off the UI thread. Move the work to a background isolate with compute(), and/or debounce the input so you don’t recompute on every keystroke while the user is mid-word.
class _Demo2HeavyBuildState extends State<Demo2HeavyBuild> {
  final TextEditingController _search = TextEditingController();

  final List<String> _catalog = List.generate(
    4000,
    (i) => 'Product ${(i * 7919) % 4000} batch ${i % 97}',
  );

  // The score does NOT depend on the query, so it's computed a single time.
  late final List<int> _scores;

  // Already-filtered result. build() only READS this; it never computes it.
  List<String> _filtered = [];

  @override
  void initState() {
    super.initState();
    _scores = _computeScores(); // O(n²) once, not on every keystroke
    _filtered = _filter('');
  }

  @override
  void dispose() {
    _search.dispose();
    super.dispose();
  }

  List<int> _computeScores() {
    return [
      for (final item in _catalog)
        _catalog
            .where((o) => o.codeUnitAt(0) == item.codeUnitAt(0))
            .fold(0, (sum, o) => sum + o.length),
    ];
  }

  // Cheap O(n) pass — safe to run on every keystroke.
  List<String> _filter(String query) {
    final q = query.toLowerCase();
    final result = <String>[];
    for (var i = 0; i < _catalog.length; i++) {
      final item = _catalog[i];
      if (q.isEmpty || item.toLowerCase().contains(q)) {
        result.add('$item  ·  score ${_scores[i]}');
      }
    }
    return result;
  }

  @override
  Widget build(BuildContext context) {
    final filtered = _filtered; // build only reads the already-filtered list

    return Scaffold(
      appBar: AppBar(title: const Text('Product search')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(12),
            child: TextField(
              controller: _search,
              decoration: const InputDecoration(
                prefixIcon: Icon(Icons.search),
                hintText: 'Type to filter…',
                border: OutlineInputBorder(),
              ),
              // Recompute only when the query changes, not inside build().
              onChanged: (value) => setState(() => _filtered = _filter(value)),
            ),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: filtered.length,
              itemBuilder: (context, i) => ListTile(
                dense: true,
                title: Text(filtered[i]),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Record again: build time collapses to a small fraction of what it was, the UI-thread frames sit back under budget, and DevTools stops reporting jank. The average frame rate goes from visibly short of the display’s refresh rate to sitting right against it — that’s the gap, closed. Typing is smooth:

Flutter DevTools Frame Analysis after the fix: no jank detected, 59 FPS, Build 1.5 ms


Problem 2: a list that lags on open (UI and raster)

Now a different symptom with a different cause. An activity feed of 5000 rows: opening it hitches, and scrolling isn’t quite smooth. Here’s the code — a complete file (home: const Demo3ListViewNoBuilder()):

import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Activity feed')),
      // All children are created in this same frame, before anything is shown.
      body: ListView(
        children: List.generate(5000, (i) => FeedRow(index: i)),
      ),
    );
  }
}

class FeedRow extends StatelessWidget {
  const FeedRow({super.key, required this.index});

  final int index;

  @override
  Widget build(BuildContext context) {
    // Opacity wraps the content in an expensive compositing layer.
    return Opacity(
      opacity: 0.99,
      child: Container(
        margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: Colors.indigo.shade50,
          borderRadius: BorderRadius.circular(10),
          boxShadow: const [
            BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 2)),
          ],
        ),
        child: Row(
          children: [
            CircleAvatar(child: Text('${index % 100}')),
            const SizedBox(width: 12),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('Event #$index',
                      style: const TextStyle(fontWeight: FontWeight.bold)),
                  Text('Activity logged in the system · id $index'),
                ],
              ),
            ),
            const Icon(Icons.more_vert),
          ],
        ),
      ),
    );
  }
}

This one has a bug on each thread.

The UI-thread half: ListView(children: ...)

ListView(children: [...]) builds every child up front — a dozen or so fit on screen, depending on the device, and the other ~4,990 get built anyway. In Performance you’ll see one enormous frame the moment the screen opens: the cost of instantiating 5000 rows nobody can see yet.

The fix is the laziest possible list. ListView.builder only builds the rows that are actually visible, as they scroll into view:

ListView.builder(
  itemCount: 5000,
  itemBuilder: (context, index) => FeedRow(index: index),
)

The raster-thread half: Opacity (and the shadow)

Opacity (when it’s animated or wrapping non-trivial content) forces Flutter to render its child into a separate offscreen layer and then blend it back — an expensive saveLayer operation on the GPU. Do that per row and the raster thread climbs. On a screen whose Dart code is already cheap, that’s the exact inverse of Problem 1 — raster busy, UI thread idle. Here it sits on top of the ListView bug, so you get both at once.

Here the Opacity(opacity: 0.99) adds nothing visible — remove it. When you genuinely need constant transparency, bake the alpha into the color — Color.withValues(alpha: 0.99) on a Container(color:), not the withOpacity you’ll find in older answers, which has been deprecated since Flutter 3.27. The Frame Analysis tab gives you the raster cost, and the Inspector’s “Highlight Repaints” shows you which layers are doing the compositing — that second one is debug-only, so it’s a separate pass from the profile-mode timings:

Flutter DevTools Frame Analysis: Raster Jank Detected, Raster 52.4 ms vs Paint 0.3 ms, 49 FPS

The split is the mirror image of Problem 1 — raster runs orders of magnitude above paint, with the UI phases for this frame down in the noise. (The UI thread isn’t idle overall — those blue bars in the chart are the row-building cost from the section above. This particular frame is pure raster.) Again: read the direction, not the digits. Drop the Opacity, drop the blurred boxShadow — the next-biggest raster cost per row — and switch to ListView.builder, and the raster phase falls back under budget. The frame rate closes the same gap it did in Problem 1, from a different direction:

Flutter DevTools Frame Analysis after the fix: no jank detected, 59 FPS, Raster 3.0 ms

Opacity isn’t the only trigger: ShaderMask, ColorFilter, BackdropFilter (blur) and antialiased clips all pay for saveLayer too. A blurred BoxShadow doesn’t strictly need it, but the mask-filter blur behind it is its own raster bill — which is why dropping the shadow helped. And beware the obvious-looking escape hatch: AnimatedOpacity and FadeTransition are not cheaper. They still composite a layer for every intermediate value; FadeTransition only spares you the subtree rebuild. The genuinely cheap route is not creating the layer at all — alpha baked into a color, or Opacity around a single leaf widget instead of a whole card.

Raster is the phase you most need real hardware for. The captures above are from an Android emulator, where GPU times run inflated. They’re fine for reading the shape of the problem — raster dominating while the UI phases sit in the noise — but the figure in that screenshot is not what a phone would report, and a low-end phone won’t match a flagship either. Before you conclude that a given layer is too expensive to ship, measure it on the devices you actually target.


The one habit to keep

Every performance investigation starts with the same question: which thread blew the budget?

What you see in PerformanceWhere the cost isReach for
UI thread high, raster ~0Your Dart code (build, logic, compute)CPU Profiler
Raster high, UI thread ~0Compositing / painting on the GPUFrame Analysis (profile), Highlight Repaints (debug)
Both highTwo independent bugs, as in Problem 2Fix one, measure again, then the other

Answer that first, and you never waste an afternoon optimizing the wrong side. High UI thread → your code, use the CPU Profiler. High raster → your layers, look at compositing. Everything today was jank, but the causes had nothing in common: a nested loop, an eager list, a compositing layer per row. You never had to guess which was which — DevTools pointed at the thread, and the thread pointed at the fix.


Closing

Jank is a frame that missed its ~16 ms deadline. It comes from the UI thread (your Dart code — move heavy work out of build(), off the frame, or onto an isolate) or from the raster thread (compositing — drop unnecessary saveLayer triggers like Opacity, and build lists lazily with .builder) — or, as in Problem 2, from both at once. The skill isn’t memorizing fixes; it’s reading which thread is red and picking the matching tool.

So far we’ve fought CPU and GPU time. In Part 3 we change dimension entirely and go after memory: the leaks that don’t stutter your frames but slowly eat your app alive — and the initStatedispose symmetry that prevents nearly all of them.

Language · Idioma

English Español