Performance, images and animations
Find rebuilds that matter, keep the frame budget, animate implicitly and with Hero transitions, and serve images at the size the screen actually needs.
Rebuilds and const
// Bad: the whole list rebuilds when the counter changes
class BadPage extends StatefulWidget {
const BadPage({super.key});
@override
State<BadPage> createState() => _BadPageState();
}
class _BadPageState extends State<BadPage> {
int _count = 0;
@override
Widget build(BuildContext context) => Column(
children: [
Text('$_count'),
const HeavyChart(), // const: skipped entirely
_ExpensiveList(items: const ['a', 'b', 'c']),
],
);
}
// Better: push the state down so only the label rebuilds
class CounterLabel extends StatefulWidget {
const CounterLabel({super.key});
@override
State<CounterLabel> createState() => _CounterLabelState();
}
class _CounterLabelState extends State<CounterLabel> {
int _count = 0;
@override
Widget build(BuildContext context) => TextButton(
onPressed: () => setState(() => _count++),
child: Text('$_count'),
);
}- A
constconstructor lets Flutter skip the subtree rebuild entirely — it is the cheapest optimisation available. - Use
RepaintBoundaryaround independently animating widgets so their repaints do not dirty the parent layer. - In the DevTools performance overlay, look for build times above a few milliseconds in the raster or UI thread during scrolling.
- Never call
setStateon a large ancestor to update one small label.
Implicit animations and Hero
class ExpandingCard extends StatefulWidget {
const ExpandingCard({super.key});
@override
State<ExpandingCard> createState() => _ExpandingCardState();
}
class _ExpandingCardState extends State<ExpandingCard> {
bool _open = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _open = !_open),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
height: _open ? 240 : 96,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(_open ? 20 : 12),
),
child: AnimatedOpacity(
opacity: _open ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: const Text('Details'),
),
),
);
}
}
// Hero works across routes with a matching tag
Hero(tag: 'avatar-${user.id}', child: CircleAvatar(backgroundImage: image))| Need | Use | Note |
|---|---|---|
| Value change over time | AnimatedContainer | Implicit, no controller |
| Sequenced timeline | AnimationController | Dispose it explicitly |
| Shared element between screens | Hero | Tags must be unique per route |
| Staggered list entry | A controller with intervals | Keep under 400ms total |
Images and the frame budget
Image.network(
url,
width: 320,
height: 200,
fit: BoxFit.cover,
cacheWidth: (320 * MediaQuery.devicePixelRatioOf(context)).round(),
filterQuality: FilterQuality.medium,
frameBuilder: (context, child, frame, wasSync) =>
wasSync ? child : AnimatedOpacity(opacity: frame == null ? 0 : 1, duration: const Duration(milliseconds: 200), child: child),
errorBuilder: (context, error, stack) => const ColoredBox(color: Color(0x11000000)),
)⚠️
Decoding a full-resolution photo for a 100px thumbnail wastes both memory and GPU time. Set
cacheWidth or cacheHeight on every remote image so the decoded bitmap matches the on-screen size.FAQ
What is the frame budget I should target?
16.6ms per frame for 60Hz and 8.3ms for 120Hz devices. Anything consistently above that shows up as jank when the user scrolls or drags.
Does Impeller change what I should optimise?
Impeller removes most shader-compilation jank, so the remaining cost is usually build time and oversized images. Start by reducing rebuilds and decoding images at the right size rather than tuning shaders.
Related
Material 3 theming and adaptive UI CI/CD, flavors and store deployment
Last refreshed 2026-09-18.