Flutter performance profiling: finding and fixing jank
By Ann Tech · 20 April 2026
Jank is when your app drops below 60fps (or 120fps on high-refresh displays). Users notice it immediately — scrolling feels sticky, taps feel slow. Here is how to find and fix it.
Always profile in profile mode
flutter run --profile
Debug mode runs with assertions, the service isolate, and no AOT compilation — it's artificially slow. Profile mode uses AOT compilation and is close to production performance. Never make performance decisions based on debug build behavior.
Open DevTools
flutter pub global activate devtools
flutter pub global run devtools
Or in VS Code: press F5 in profile mode, then open the DevTools URL printed in the console.
Reading the Performance tab
The Performance tab shows a frame timeline:
- Each bar represents one frame
- UI thread (top): Dart build + layout + paint
- Raster thread (bottom): Skia/Impeller GPU commands
A frame that fits in 16ms (for 60fps) is green. Red bars are janky frames.
Click a red bar to see what ran during that frame in the flame chart.
Common jank causes and fixes
1. Expensive build methods
// PROBLEM: Rebuilds entire list on every setState
@override
Widget build(BuildContext context) {
return Column(
children: expensiveItems.map((item) => _buildItem(item)).toList(),
);
}
// FIX: Extract to const widgets or use ListView.builder
ListView.builder(
itemCount: items.length,
itemBuilder: (_, i) => ItemWidget(item: items[i]),
)
2. Opacity on large subtrees
// PROBLEM: Creates a new compositing layer, repaints entire subtree every frame
Opacity(opacity: 0.5, child: LargeComplexWidget())
// FIX: Use color opacity for static content
ColorFiltered(
colorFilter: ColorFilter.mode(Colors.black.withOpacity(0.5), BlendMode.dstATop),
child: LargeComplexWidget(),
)
// Or for animated opacity, use FadeTransition which doesn't repaint:
FadeTransition(opacity: _opacityAnimation, child: LargeComplexWidget())
3. Heavy work on the UI thread
// PROBLEM: JSON parsing on main thread blocks UI
final products = jsonDecode(response.body) as List; // Blocks for large payloads
// FIX: Use compute() to move to background isolate
final products = await compute(_parseProducts, response.body);
List<Product> _parseProducts(String json) =>
(jsonDecode(json) as List)
.map((e) => Product.fromJson(e as Map<String, dynamic>))
.toList();
4. Images not cached or sized correctly
// PROBLEM: Full-resolution image decoded on every rebuild
Image.network(imageUrl)
// FIX: Cache + resize to display size
CachedNetworkImage(
imageUrl: imageUrl,
memCacheWidth: 300, // Only decode at display size
memCacheHeight: 300,
)
5. Rebuilding too much with setState
// PROBLEM: setState on a high-level widget rebuilds everything
setState(() => _selectedTab = index); // Rebuilds entire Scaffold
// FIX: Use ValueNotifier + ValueListenableBuilder for local state
final _selectedTab = ValueNotifier(0);
ValueListenableBuilder<int>(
valueListenable: _selectedTab,
builder: (_, tab, __) => BottomNavigationBar(currentIndex: tab, ...),
)
RepaintBoundary
Isolate frequently-painting subtrees:
// The counter animates 60fps but shouldn't force the card to repaint
Card(
child: Column(children: [
StaticContent(),
RepaintBoundary(child: AnimatedCounter(value: count)),
]),
)
The rebuild profiler
In DevTools → Widget Rebuild Stats, enable counting rebuilds. Widgets rebuilding more than expected during animations are a strong signal of unnecessary work.
Common pitfalls
Profiling on a simulator. iOS Simulator and Android Emulator don't reflect real GPU performance. Always profile on a physical device — preferably a mid-range one, not the latest flagship.
Fixing things without measuring first. Don't add RepaintBoundary everywhere "just in case" — it adds layers and memory. Measure first with DevTools, then fix what the data shows.
Sign in to like, dislike, or report.