Advanced · Lesson 3 · 14 min read
Profiling and Fixing Performance
Find real jank with DevTools, understand UI versus raster thread costs, and apply the fixes that actually move the numbers.
Updated July 31, 2026
What you will learn
- Profile correctly in profile mode with DevTools
- Tell UI-thread jank apart from raster-thread jank
- Fix the most common causes of dropped frames
- Find and stop memory leaks
A 60Hz display gives you 16.7ms per frame; a 120Hz display gives you 8.3ms. Exceed it and the frame is dropped — that is jank. Fixing it starts with measuring, because the cause is almost never where developers guess.
Profile in profile mode
# Debug builds are 3–10x slower and have assertions everywhere.
# Never draw performance conclusions from them.
flutter run --profile
# On a real device — simulators use your desktop GPU and lie to you
flutter run --profile -d <device-id>
# Open DevTools from the printed URL, or:
dart devtoolsUI thread vs raster thread
The performance overlay and DevTools timeline show two bars per frame. Which one is over budget tells you where to look.
| Slow thread | What it means | Typical causes |
|---|---|---|
| UI (Dart) | Your Dart code is too slow — build/layout is over budget | Rebuilding too much, expensive build methods, JSON parsing, unbounded loops |
| Raster (GPU) | The scene is too expensive to draw | Opacity layers, saveLayer, large blurs, clipping with anti-alias, huge images, shader compilation |
MaterialApp(
showPerformanceOverlay: true, // two graphs: UI on top, raster below
home: const HomeScreen(),
)Fixing UI-thread jank
- Rebuild less. Use the DevTools rebuild counter to find widgets rebuilding per frame that should not be. Move state listeners as low as possible.
- Make subtrees const. A
constwidget is skipped entirely when its parent rebuilds. - Never build off-screen work. Replace
ColumninsideSingleChildScrollViewwithListView.builderwhen the list is long — builder constructs only visible items. - Move heavy computation off the main isolate with
computeor a long-lived isolate: JSON parsing, image decoding, cryptography, big sorts. - Cache derived values. Recomputing a filtered/sorted list inside
buildruns on every frame; compute it when the source changes instead.
// Before: sorts the whole list on every single rebuild
@override
Widget build(BuildContext context) {
final sorted = [...widget.items]..sort((a, b) => a.name.compareTo(b.name));
return ListView(children: sorted.map(ItemTile.new).toList());
}
// After: sort only when the input actually changes
List<Item> _sorted = const [];
@override
void initState() {
super.initState();
_resort();
}
@override
void didUpdateWidget(covariant ItemList old) {
super.didUpdateWidget(old);
if (!listEquals(old.items, widget.items)) _resort();
}
void _resort() {
_sorted = [...widget.items]..sort((a, b) => a.name.compareTo(b.name));
}
@override
Widget build(BuildContext context) => ListView.builder(
itemCount: _sorted.length,
itemBuilder: (context, i) => ItemTile(_sorted[i]),
);Fixing raster-thread jank
- Avoid `saveLayer`.
Opacity,ShaderMask,ColorFilterandBackdropFiltercan each trigger an offscreen buffer. PreferFadeTransition, or apply opacity to a colour rather than wrapping inOpacity. - Prefer decoration radii to clips.
BoxDecoration(borderRadius: ...)is cheaper thanClipRRectwith anti-aliasing. - Right-size images. Decoding a 4000px JPEG to show a 100px avatar burns memory and raster time. Set
cacheWidth/cacheHeightor useResizeImage. - Watch blurs.
BackdropFilterover a large area is one of the most expensive things you can draw. Keep it small and static. - Warm up shaders. First-run animation stutter on older devices is often shader compilation; Impeller compiles shaders ahead of time, which is a strong reason to keep it enabled.
// Decode at display size, not source size
Image.network(
url,
width: 100,
height: 100,
cacheWidth: 200, // 2x for high-DPI screens, not 4000
cacheHeight: 200,
fit: BoxFit.cover,
)
// Cheaper than wrapping the whole subtree in Opacity
DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
),
child: child,
)Long lists
ListView.builder(
itemCount: items.length,
// Give a fixed height when you know it — Flutter skips measuring
itemExtent: 72,
// Keep offscreen build work bounded
cacheExtent: 500,
itemBuilder: (context, index) => ItemTile(
// Stable keys preserve state correctly when items reorder
key: ValueKey(items[index].id),
item: items[index],
),
)Memory leaks
The classic Flutter leak is a subscription or controller that outlives its widget. DevTools' Memory view will show your State objects accumulating as you navigate back and forth.
class _FeedScreenState extends State<FeedScreen> {
StreamSubscription<Event>? _subscription;
Timer? _pollTimer;
late final AnimationController _controller;
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_subscription = eventBus.stream.listen(_onEvent);
_pollTimer = Timer.periodic(const Duration(seconds: 30), (_) => _refresh());
_controller = AnimationController(vsync: this, duration: kThemeAnimationDuration);
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_subscription?.cancel();
_pollTimer?.cancel();
_controller.dispose();
_scrollController
..removeListener(_onScroll)
..dispose();
super.dispose();
}
}- Image cache growth —
PaintingBinding.instance.imageCachehas a size limit; loading many large images can still spike memory. Cap it, or usecached_network_imagewith a disk cache. - Closures capturing State — a long-lived callback holding
thiskeeps the whole element subtree alive. Cancel the registration indispose. - Global caches with no eviction — a
Mapyou only ever add to is a leak with extra steps.
Startup time
- Keep
main()thin — do not await slow initialisation beforerunApp. Render a splash or skeleton and initialise in the background. - Defer plugin setup you do not need on the first screen.
- Use deferred imports on web to split rarely used features out of the initial bundle.
- Measure with
flutter run --profile --trace-startupand readstart_up_info.json.
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Do NOT await everything here — it delays the first frame
runApp(const MyApp());
// Fire-and-forget work that the first screen does not need
unawaited(analytics.initialise());
}Key takeaways
- Always profile in profile mode on a physical device.
- UI-thread jank means your Dart is slow; raster jank means the scene is expensive to draw.
ListView.builderwithitemExtentand stable keys is the baseline for long lists.- Every subscription, timer and controller needs a matching cancel or dispose.
Practice
Diagnose a janky list
Build a list of 1,000 items with full-size network images, a blur behind the app bar and no itemExtent. Profile it, record the frame times, then fix it step by step — measuring after each change — and write down which change produced the largest improvement.
Show hints
- Fix one thing at a time; changing three things at once teaches you nothing.
- The image decode size is usually the biggest single win.