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.

“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 inOpacity. - To animate opacity, use
FadeTransitionorAnimatedOpacity, which are optimized for this. - Only clip when you need to. A rounded
ContainerwithBoxDecoration(borderRadius: ...)doesn’t need aClipRRectaround 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
IntegrationTestWidgetsFlutterBindingandtraceAction, 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 intoIsolate.runorcompute. - On the raster side: avoid unnecessary
saveLayer, decode images at display size, useRepaintBoundarywhere 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).