← All posts

6 min read

Fixing Jank in Flutter Apps: A Practical Guide to Smooth 60fps Performance

How I find and fix jank in Flutter apps with profile mode and DevTools: UI vs raster thread, expensive builds, saveLayer, oversized images and blocking work.

  • Flutter
  • Performance
  • DevTools
Cover illustration for Fixing Jank in Flutter Apps: A Practical Guide to Smooth 60fps Performance

“The app feels laggy” may be the least actionable bug report there is, and one of the most common. Nobody says which screen, which gesture or which phone. It just feels slow.

Usually the cause is jank: frames that take longer than the display’s budget to produce, so the animation stutters. At 60Hz you get about 16ms per frame. At 120Hz you get about 8ms. Miss that budget and the user notices, even if they can’t say why.

Here is the process I use to turn “feels laggy” into a specific fix. Most of it is measuring. Very little of it is guessing.

Rule one: never profile in debug mode

Debug builds run Dart in JIT mode with assertions and extra checks turned on. They are supposed to be slow. A list that stutters in debug might be perfectly smooth in release.

Always measure in profile mode, on a real device, ideally a mid-range Android phone rather than your shiny flagship:

flutter run --profile

Profile mode compiles the app like a release build but keeps enough instrumentation for DevTools to connect. Simulators and emulators don’t count either. Their GPU behavior is nothing like a real phone’s.

Reading the DevTools Performance view

Open DevTools from the terminal link or your IDE and go to the Performance tab. The Flutter frames chart at the top shows one bar per frame, split into two parts:

  • UI thread: running your Dart code, meaning build, layout and the creation of the layer tree.
  • Raster thread: turning that layer tree into GPU commands and drawing pixels.

Frames over budget are highlighted. Click a slow one and the timeline below shows what took the time. That split is the most useful diagnostic you have, because each side has different causes:

Slow thread Usual suspects Typical fixes
UI Rebuilding too much, expensive build methods, heavy sync work (JSON parsing, sorting), building long lists eagerly const widgets, smaller rebuild scopes, ListView.builder, move work to an isolate
Raster saveLayer from Opacity/ShaderMask/some clips, big blurs and shadows, oversized images, shader compilation Avoid unnecessary layers, RepaintBoundary, resize images, keep Impeller on

In the Performance view, the Enhance tracing options (track widget builds, layouts and paints) add detail to the timeline. Turn them on when you need to know which widget is rebuilding. They add overhead of their own, so leave them off for normal timing.

For a quick on-device view, MaterialApp(showPerformanceOverlay: true) draws the UI and raster graphs right over your app.

UI thread fixes

Rebuild less

The most common problem I find in codebases I inherit is a setState or a state listener high in the tree that rebuilds an entire screen when one counter changes. Push state down so it lives next to the widgets that use it. With Riverpod, use select to watch one field instead of a whole object. With BLoC, use buildWhen or BlocSelector (more on both in Riverpod vs BLoC).

Use const constructors

A const widget is created once and reused, and Flutter can skip rebuilding that subtree entirely. Turn on the prefer_const_constructors lint and let the analyzer nag you. It’s a small win per widget that adds up across a screen.

Keep build methods cheap

build can run on every frame of an animation. Formatting dates, filtering lists or building regexes inside it is work you repeat for nothing. Compute derived data when the state changes, not when the widget draws.

Build lists lazily

ListView(children: items.map(...).toList()) builds every item up front, including the 900 that are off screen. ListView.builder builds only what’s visible:

ListView.builder(
  itemCount: orders.length,
  itemBuilder: (context, index) => OrderTile(order: orders[index]),
)

If every item has the same height, set itemExtent (or prototypeItem) too, so the list doesn’t have to measure children to work out scroll positions.

Move heavy work off the main isolate

Parsing a 2MB JSON response on the UI isolate will drop frames no matter how clean your widgets are. Isolate.run makes background work a one-liner:

Future<List<Order>> parseOrders(String body) {
  return Isolate.run(() {
    final list = jsonDecode(body) as List<dynamic>;
    return list
        .map((e) => Order.fromJson(e as Map<String, dynamic>))
        .toList();
  });
}

Flutter’s compute function does the same thing and also works on the web, where Isolate.run isn’t supported. Don’t spawn an isolate for tiny payloads, though. Copying data between isolates has a cost, and below a certain size it’s slower than just doing the work.

Raster thread fixes

Watch out for saveLayer

Some effects force Flutter to render into an offscreen buffer and composite it back, which is expensive. Opacity with a value between 0 and 1, ShaderMask, ColorFilter and clips with Clip.antiAliasWithSaveLayer are the usual culprits.

Practical swaps:

  • To fade an image or a solid color, change the color’s alpha (color.withValues(alpha: 0.5)) instead of wrapping it in Opacity.
  • To animate opacity, use FadeTransition or AnimatedOpacity, which are optimized for this.
  • Only clip when you need to. A rounded Container with BoxDecoration(borderRadius: ...) doesn’t need a ClipRRect around it.

Resize images to their display size

A 4000×3000 photo shown in a 100-pixel thumbnail still gets decoded at full resolution unless you tell Flutter otherwise. That costs memory and raster time. Tell it the size you need:

Image.network(
  product.imageUrl,
  width: 100,
  height: 100,
  cacheWidth: 300, // roughly display size × device pixel ratio
  fit: BoxFit.cover,
)

DevTools has a Highlight oversized images toggle in the Flutter Inspector that inverts the colors of any image decoded much larger than it’s displayed. On image-heavy apps it’s often eye-opening. Better still, ask the backend for appropriately sized thumbnails.

Isolate repaints with RepaintBoundary

If a small animated widget, such as a spinner or a progress ring, sits inside a large static area, each frame may repaint the whole area. Wrapping the animated part in a RepaintBoundary gives it its own layer so only that layer repaints. Don’t sprinkle these everywhere, because every boundary costs memory. Add one where DevTools shows needless repainting.

Shader compilation jank and Impeller

The classic Flutter jank was the first run of an animation stuttering while shaders compiled, then running smoothly afterwards. Flutter’s newer rendering engine, Impeller, fixes this by precompiling its shaders at build time. Impeller is the default on iOS and on modern Android devices in current Flutter releases, and older devices fall back to Skia.

In practice that means two things. Stay on a recent stable Flutter version. And if you’re still carrying an old SkSL shader warm-up workaround from the Skia days, check whether it does anything at all on your current setup before you spend time maintaining it. If you do see Impeller-specific rendering issues, report them with a minimal reproduction rather than disabling it app-wide.

Make it stay fixed

Performance regresses quietly. Two habits help:

  • Before each release, run a manual “scroll test” in profile mode on a low-end device: every main list, every main animation.
  • For critical flows, write an integration test that records a performance timeline with IntegrationTestWidgetsFlutterBinding and traceAction, so you can compare frame times across builds. I cover where these fit in my Flutter testing strategy.

App size and startup time are related problems with their own tricks, which I cover in reducing Flutter app size.

Takeaways

  • Measure in profile mode on a real, mid-range device. Never trust debug performance.
  • Use the frames chart to decide whether the UI thread or the raster thread is slow, then fix the right one.
  • On the UI side: rebuild less, use const, build lists lazily, move heavy parsing into Isolate.run or compute.
  • On the raster side: avoid unnecessary saveLayer, decode images at display size, use RepaintBoundary where it actually helps.
  • Stay on current Flutter so Impeller handles shader compilation jank for you.

Smooth apps aren’t the result of clever tricks. They come from measuring before you change anything and checking again after you do.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).