Flutter Performance
Hold a steady 60 fps (<=16 ms/frame; <=8 ms on 120 Hz) on a floor device (low-end Android / older iPhone). Performance is a property you measure in profile mode, not one you assert. It comes from rebuilding less, painting cheaply, and keeping both the UI thread and the raster thread free.
Non-negotiable rules
const everything legal. A const subtree is skipped on rebuild — the framework short-circuits it by identity. Keep prefer_const_constructors on as an error; a non-const literal that could be const is a lint failure, not a preference.
- Shrink the rebuild scope to the smallest changing widget. Never
ref.watch(provider) for a whole state object when one field changed — watch provider.select((s) => s.field). A HUD counter tick must never rebuild a heavy sibling. Rebuild scope is the single biggest lever on build-thread cost.
- Lazy everything long.
ListView.builder / GridView.builder / slivers for variable or unbounded content. ListView(children: items.map(...).toList()) builds every off-screen row up front — refuse it for anything not tiny and fixed.
- No expensive work in
build(). No jsonDecode, sort, regex, DateTime math, file/network, or large allocation — build() may run every frame. Compute in the Notifier/ViewModel once and cache the result in immutable state.
- Heavy CPU off the UI isolate. Parse large payloads, process images, crunch numbers in
compute() or a spawned Isolate. Blocking the UI thread is guaranteed jank; the raster thread cannot save you.
- Size image decode to the display slot.
cacheWidth/cacheHeight or ResizeImage, correct asset resolutions, precacheImage for above-the-fold art. Decoding a 4000 px source into a 100 px box spikes memory and OOMs cheap phones.
- Prefer the cheapest widget that does the job.
ColoredBox/DecoratedBox over Container, SizedBox for spacing, FadeTransition/AnimatedOpacity over the Opacity widget in hot paths. Avoid ClipPath/saveLayer inside scrolling lists — they force an offscreen buffer on the raster thread.
RepaintBoundary around costly, independently-repainting subtrees (an animation, a chart, a live indicator) so its repaint does not re-raster its neighbours. Do not sprinkle boundaries everywhere — each is a compositor layer that costs memory.
- Pass expensive children through
child:. AnimatedBuilder/ValueListenableBuilder/StreamBuilder rebuild only the builder; hand the unchanging subtree in via child: so it is built once, not per frame.
- Dispose to avoid leaks.
AnimationControllers, gesture recognizers, stream subscriptions, timers, TextEditingControllers, caches — released in dispose() (widgets) or ref.onDispose (providers). autoDispose scoped session state so it does not survive the screen.
- Measure in PROFILE mode on a real floor device with DevTools. Debug timings are meaningless (JIT, asserts). Watch the raster thread as well as the UI thread —
saveLayer/blur/clip cost shows there, not in Dart. Never ship a frame-budget number that was asserted, not measured.
Narrow the rebuild scope
Watch the single field that changes, not the whole state object:
// Rebuilds only when the count changes, not on every OrderState mutation.
class OrderBadge extends ConsumerWidget {
const OrderBadge({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(orderProvider.select((s) => s.items.length));
return Text('$count');
}
}
.select runs the selector every publish but rebuilds only when the selected value's == changes, so its input must be an immutable value with real equality. See state-management-riverpod for the watch/read/listen split and family + autoDispose.
Keep expensive children out of the animation loop
// The card is built once; only the transform recomputes each tick.
AnimatedBuilder(
animation: _controller,
child: const ProductCard(),
builder: (_, child) =>
Transform.scale(scale: _controller.value, child: child),
);
Lazy lists and sized images
// Lazy — only visible rows are built.
ListView.builder(
itemCount: items.length,
itemBuilder: (context, i) => ItemTile(item: items[i]),
);
// Decode to the slot, not the source resolution.
Image.asset('assets/hero.png', cacheWidth: 240);
Heavy work off the UI isolate
// parseItems is a top-level or static function (isolate entry-point rules).
final items = await compute(parseItems, jsonString);
Custom painting
For a CustomPainter, the discipline is zero-allocation paint(), a shouldRepaint that is one value comparison over an immutable scene value, and a repaint: Listenable (the AnimationController) instead of setState in a ticker. That treatment lives in custom-canvas-and-gestures; the one performance-critical rule to carry here: never allocate Paint/Path/Gradient inside paint() — hold them as painter fields and mutate cheap properties.
Profiling workflow
flutter run --profile on a physical floor device.
- DevTools -> Performance; record while reproducing the jank.
- For each over-budget frame, check whether the time is in Build, Layout, or Raster.
- Build-heavy -> narrow rebuild scope with
.select, add const, move work out of build().
- Raster-heavy -> reduce
Opacity/clips/saveLayer, add a RepaintBoundary, simplify shaders.
- Use "Track widget rebuilds" to find widgets rebuilding too often.
- Fix one bottleneck, re-measure. Never optimize without a before/after measurement.
Anti-patterns
- Judging performance in debug mode — JIT and asserts make timings fiction. Profile mode, real device, always.
- Premature optimization with no profile data — adds complexity, hides nothing; measure first.
setState() high in the tree rebuilding a whole screen for a tiny change — scope the state down.
ref.watch(provider) for a whole object in a leaf when one field changed — use .select.
ListView(children: [...].toList()) for long/unbounded content — builds every off-screen row.
jsonDecode/sort/DateTime math/regex in build() — it may run every frame; precompute in the ViewModel.
Opacity widget for fades in scrolling lists — forces saveLayer; use FadeTransition or fade via color.
- Decoding full-resolution images into small widgets — memory spikes and OOM on cheap phones.
- Parsing large payloads / heavy loops on the UI isolate — frozen frames; use
compute.
RepaintBoundary on everything — layer-memory bloat; use it surgically around independently-repainting subtrees.
AnimatedBuilder/StreamBuilder rebuilding an expensive child instead of passing it via child:.
- Rebuilding on every keystroke without debounce, or unbounded caches/listeners that leak.
- Allocating
Paint/Path inside paint() — per-frame garbage; hold them as fields.
Definition of done
Related skills
state-management-riverpod — the watch/read/listen split, .select, family + autoDispose, immutable state that makes .select correct.
widget-composition — small const Widget classes over _buildX methods, dispose discipline, cheapest-widget choices.
custom-canvas-and-gestures — zero-allocation paint(), shouldRepaint as one value compare, repaint: Listenable.
testing-strategy — clock-injected pure core so heavy compute is testable off the UI isolate.
References
1---2name: flutter-performance3description: Enforces Flutter runtime performance — const subtrees, minimal rebuild scope via ref.watch(select), lazy ListView/GridView builders and slivers, sized image decode (cacheWidth/ResizeImage), heavy work off the UI isolate via compute/Isolate, surgical RepaintBoundary, dispose everything, and measurement in profile mode on a floor device. Use when optimizing UI, diagnosing jank or dropped frames, tuning long lists or images, reviewing rebuild/repaint scope, or when the task mentions const, select, ListView.builder, cacheWidth, compute, RepaintBoundary, AnimatedBuilder, DevTools, raster thread, or 60/120fps.4---56# Flutter Performance78Hold a steady 60 fps (<=16 ms/frame; <=8 ms on 120 Hz) on a floor device (low-end Android / older iPhone). Performance is a property you **measure in profile mode**, not one you assert. It comes from rebuilding less, painting cheaply, and keeping both the UI thread and the raster thread free.910## Non-negotiable rules11121. **`const` everything legal.** A `const` subtree is skipped on rebuild — the framework short-circuits it by identity. Keep `prefer_const_constructors` on as an error; a non-const literal that could be const is a lint failure, not a preference.132. **Shrink the rebuild scope to the smallest changing widget.** Never `ref.watch(provider)` for a whole state object when one field changed — watch `provider.select((s) => s.field)`. A HUD counter tick must never rebuild a heavy sibling. Rebuild scope is the single biggest lever on build-thread cost.143. **Lazy everything long.** `ListView.builder` / `GridView.builder` / slivers for variable or unbounded content. `ListView(children: items.map(...).toList())` builds every off-screen row up front — refuse it for anything not tiny and fixed.154. **No expensive work in `build()`.** No `jsonDecode`, sort, regex, `DateTime` math, file/network, or large allocation — `build()` may run every frame. Compute in the Notifier/ViewModel once and cache the result in immutable state.165. **Heavy CPU off the UI isolate.** Parse large payloads, process images, crunch numbers in `compute()` or a spawned `Isolate`. Blocking the UI thread is guaranteed jank; the raster thread cannot save you.176. **Size image decode to the display slot.** `cacheWidth`/`cacheHeight` or `ResizeImage`, correct asset resolutions, `precacheImage` for above-the-fold art. Decoding a 4000 px source into a 100 px box spikes memory and OOMs cheap phones.187. **Prefer the cheapest widget that does the job.** `ColoredBox`/`DecoratedBox` over `Container`, `SizedBox` for spacing, `FadeTransition`/`AnimatedOpacity` over the `Opacity` widget in hot paths. Avoid `ClipPath`/`saveLayer` inside scrolling lists — they force an offscreen buffer on the raster thread.198. **`RepaintBoundary` around costly, independently-repainting subtrees** (an animation, a chart, a live indicator) so its repaint does not re-raster its neighbours. Do not sprinkle boundaries everywhere — each is a compositor layer that costs memory.209. **Pass expensive children through `child:`.** `AnimatedBuilder`/`ValueListenableBuilder`/`StreamBuilder` rebuild only the `builder`; hand the unchanging subtree in via `child:` so it is built once, not per frame.2110. **Dispose to avoid leaks.** `AnimationController`s, gesture recognizers, stream subscriptions, timers, `TextEditingController`s, caches — released in `dispose()` (widgets) or `ref.onDispose` (providers). `autoDispose` scoped session state so it does not survive the screen.2211. **Measure in PROFILE mode on a real floor device with DevTools.** Debug timings are meaningless (JIT, asserts). Watch the **raster thread** as well as the UI thread — `saveLayer`/blur/clip cost shows there, not in Dart. Never ship a frame-budget number that was asserted, not measured.2324## Narrow the rebuild scope2526Watch the single field that changes, not the whole state object:2728```dart29// Rebuilds only when the count changes, not on every OrderState mutation.30class OrderBadge extends ConsumerWidget {31 const OrderBadge({super.key});3233 @override34 Widget build(BuildContext context, WidgetRef ref) {35 final count = ref.watch(orderProvider.select((s) => s.items.length));36 return Text('$count');37 }38}39```4041`.select` runs the selector every publish but rebuilds only when the selected value's `==` changes, so its input must be an immutable value with real equality. See `state-management-riverpod` for the watch/read/listen split and `family` + `autoDispose`.4243## Keep expensive children out of the animation loop4445```dart46// The card is built once; only the transform recomputes each tick.47AnimatedBuilder(48 animation: _controller,49 child: const ProductCard(),50 builder: (_, child) =>51 Transform.scale(scale: _controller.value, child: child),52);53```5455## Lazy lists and sized images5657```dart58// Lazy — only visible rows are built.59ListView.builder(60 itemCount: items.length,61 itemBuilder: (context, i) => ItemTile(item: items[i]),62);6364// Decode to the slot, not the source resolution.65Image.asset('assets/hero.png', cacheWidth: 240);66```6768## Heavy work off the UI isolate6970```dart71// parseItems is a top-level or static function (isolate entry-point rules).72final items = await compute(parseItems, jsonString);73```7475## Custom painting7677For a `CustomPainter`, the discipline is zero-allocation `paint()`, a `shouldRepaint` that is one value comparison over an immutable scene value, and a `repaint:` `Listenable` (the `AnimationController`) instead of `setState` in a ticker. That treatment lives in `custom-canvas-and-gestures`; the one performance-critical rule to carry here: **never allocate `Paint`/`Path`/`Gradient` inside `paint()`** — hold them as painter fields and mutate cheap properties.7879## Profiling workflow80811. `flutter run --profile` on a physical floor device.822. DevTools -> Performance; record while reproducing the jank.833. For each over-budget frame, check whether the time is in **Build**, **Layout**, or **Raster**.84 - Build-heavy -> narrow rebuild scope with `.select`, add `const`, move work out of `build()`.85 - Raster-heavy -> reduce `Opacity`/clips/`saveLayer`, add a `RepaintBoundary`, simplify shaders.864. Use "Track widget rebuilds" to find widgets rebuilding too often.875. Fix one bottleneck, re-measure. Never optimize without a before/after measurement.8889## Anti-patterns9091- **Judging performance in debug mode** — JIT and asserts make timings fiction. Profile mode, real device, always.92- **Premature optimization with no profile data** — adds complexity, hides nothing; measure first.93- **`setState()` high in the tree** rebuilding a whole screen for a tiny change — scope the state down.94- **`ref.watch(provider)` for a whole object in a leaf** when one field changed — use `.select`.95- **`ListView(children: [...].toList())` for long/unbounded content** — builds every off-screen row.96- **`jsonDecode`/sort/`DateTime` math/regex in `build()`** — it may run every frame; precompute in the ViewModel.97- **`Opacity` widget for fades in scrolling lists** — forces `saveLayer`; use `FadeTransition` or fade via color.98- **Decoding full-resolution images** into small widgets — memory spikes and OOM on cheap phones.99- **Parsing large payloads / heavy loops on the UI isolate** — frozen frames; use `compute`.100- **`RepaintBoundary` on everything** — layer-memory bloat; use it surgically around independently-repainting subtrees.101- **`AnimatedBuilder`/`StreamBuilder` rebuilding an expensive child** instead of passing it via `child:`.102- **Rebuilding on every keystroke without debounce**, or unbounded caches/listeners that leak.103- **Allocating `Paint`/`Path` inside `paint()`** — per-frame garbage; hold them as fields.104105## Definition of done106107- [ ] `const` applied everywhere legal; `prefer_const_constructors` clean.108- [ ] Rebuilds scoped with `.select`; a tick in one widget never rebuilds a heavy sibling.109- [ ] Long/variable lists are lazy (`.builder`/slivers).110- [ ] No I/O, parsing, or heavy compute in `build()`; big work runs via `compute`/`Isolate`.111- [ ] Images sized to display (`cacheWidth`/`ResizeImage`); above-the-fold precached.112- [ ] Cheapest suitable widgets/effects chosen; `RepaintBoundary` used surgically; expensive children passed via `child:`.113- [ ] Any `CustomPainter` allocates nothing in `paint()`; `shouldRepaint` is one value compare.114- [ ] Controllers, recognizers, subscriptions, timers disposed; scoped session state `autoDispose`d; no leaks.115- [ ] Measured in profile mode on a floor device via DevTools — UI **and** raster threads under budget; no frames over 16 ms; claim backed by a recording.116117## Related skills118119- `state-management-riverpod` — the watch/read/listen split, `.select`, `family` + `autoDispose`, immutable state that makes `.select` correct.120- `widget-composition` — small `const` Widget classes over `_buildX` methods, dispose discipline, cheapest-widget choices.121- `custom-canvas-and-gestures` — zero-allocation `paint()`, `shouldRepaint` as one value compare, `repaint:` `Listenable`.122- `testing-strategy` — clock-injected pure core so heavy compute is testable off the UI isolate.123124## References125126- [Flutter — Performance best practices](https://docs.flutter.dev/perf/best-practices)127- [Flutter — Performance profiling / UI performance](https://docs.flutter.dev/perf/ui-performance)128- [Flutter — Concurrency and isolates / compute](https://docs.flutter.dev/perf/isolates)129- [Flutter — Impeller rendering engine](https://docs.flutter.dev/perf/impeller)130- [Flutter API — RepaintBoundary](https://api.flutter.dev/flutter/widgets/RepaintBoundary-class.html)131- [Flutter API — ResizeImage / cacheWidth](https://api.flutter.dev/flutter/painting/ResizeImage-class.html)132- [Riverpod — using select to filter rebuilds](https://riverpod.dev/docs/concepts/reading#using-select-to-filter-rebuilds)