Performance
Flutter performance optimization — rendering pipeline, rebuild scope, list and image optimization, profiling tools
Performance
Flutter renders at 60/120 fps by default, leaving 16.6 ms / 8.3 ms per frame. Performance problems are, fundamentally, a single frame blowing past that budget and producing jank.
Rendering Pipeline
Vsync signal
↓
Animation tick (drives animations)
↓
Build (Widget → Element diff)
↓
Layout (RenderObject performLayout)
↓
Paint (RenderObject paint → Layer tree)
↓
Compositing (Layers composited → Skia / Impeller)
↓
Rasterize (GPU rasterization)
↓
Submit frame (handed off to the system compositor)| Stage | Optimization Levers |
|---|---|
| Build | Shrink rebuild scope, use const, split into small Widgets |
| Layout | Avoid expensive layout (IntrinsicWidth/Height, nested Flex), fix itemExtent |
| Paint | RepaintBoundary isolation, cache CustomPainter output |
| Compositing | Reduce layer count, avoid saveLayer / clipRect |
| Rasterize | Smaller layer sizes, avoid stacked gradient masks |
Control Rebuild Scope
The single highest-leverage optimization. Every avoided rebuild is also avoided layout and paint.
Split into Widgets, not methods
See the Widget Organization section in Best Practices.
const-ify Everything Static
// Const widgets are reused when the parent rebuilds
const Padding(padding: EdgeInsets.all(8), child: Text('static'))Enable prefer_const_constructors and prefer_const_constructors_in_immutables.
Selector / Consumer for Granular Subscription
Provider's Consumer rebuilds whenever the entire model changes. Selector only rebuilds when the selected field changes:
// Rebuilds on any model change
Consumer<CartModel>(builder: (_, cart, __) => Text('${cart.itemCount}'))
// Rebuilds only when itemCount changes
Selector<CartModel, int>(
selector: (_, cart) => cart.itemCount,
builder: (_, count, __) => Text('$count'),
)Riverpod's select does the same:
final count = ref.watch(cartProvider.select((c) => c.itemCount));ValueListenableBuilder for Lightweight Local Updates
A scoped solution that needs no state library:
final counter = ValueNotifier<int>(0);
ValueListenableBuilder<int>(
valueListenable: counter,
builder: (_, value, __) => Text('$value'), // only this rebuilds
)
// Trigger update: counter.value++;AnimatedBuilder + child Parameter
Keep an expensive subtree out of an animation's rebuild path:
AnimatedBuilder(
animation: controller,
child: const ExpensiveSubtree(), // not rebuilt
builder: (_, child) => Opacity(opacity: controller.value, child: child),
)RepaintBoundary
Move frequently repainted subtrees onto their own layer so they do not contaminate siblings.
Stack(children: [
const StaticBackground(), // does not repaint
RepaintBoundary(child: AnimatedForeground()), // repaints in its own layer
])Use when:
- Continuously animated elements
- Frequently scrolling content
- Complex CustomPaint
Anti-pattern: sprinkling RepaintBoundary everywhere. Each boundary has a compositing cost; overuse makes things slower. Verify in DevTools' Performance Layers view.
List Performance
Always Use builder Form
ListView.builder(
itemCount: items.length,
itemBuilder: (_, i) => Item(items[i]),
)Do not use ListView(children: [...]) to build long lists eagerly.
Set itemExtent for Fixed-Height Items
itemExtent lets Flutter compute total height without measuring each child, dramatically improving scroll performance:
ListView.builder(
itemExtent: 80, // known per-item height
itemCount: items.length,
itemBuilder: ...,
)Going further: SliverFixedExtentList, SliverPrototypeExtentList.
Keep Each Item Cheap
Every item's build must be fast. Common drag factors:
- Nested ListView
- Complex Stack / Overflow
- Synchronous reads from disk for images (use
cached_network_image) - Repeatedly creating animation Controllers
Keep Alive
Preserve a ListView's scroll position when switching tabs:
class _MyTabState extends State<MyTab> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context); // mandatory
return ListView(...);
}
}Image Performance
Cap the Decode Size
Image.network(url, cacheWidth: 800, cacheHeight: 600)
// or
Image(image: ResizeImage(NetworkImage(url), width: 800))Without a cap, images decode at full resolution into memory — a few large photos can OOM.
Use a Real Cache Library
cached_network_image provides:
- Two-tier memory + disk cache
- Placeholder / error fallback
- Progressive loading / fade-in
- Custom cache management
precacheImage
Decode upfront before entering a page:
@override
void didChangeDependencies() {
super.didChangeDependencies();
precacheImage(NetworkImage(coverUrl), context);
}SVG and Vectors
flutter_svg slows down with many complex SVGs. Consider:
- Simple icons via fonts (IconData)
- Pre-rendering complex graphics to PNG
- For peak performance, cache via PictureRecorder
Avoid Expensive Layout
IntrinsicWidth / IntrinsicHeight
These trigger a second layout pass (dry layout then real layout) — O(N²) cost. Use Flex/Stack if you can.
Opacity and ClipPath
Opacity triggers saveLayer (offscreen render) — expensive. For fading, prefer alternatives to plain Opacity:
// Slower
Opacity(opacity: 0.5, child: ExpensiveChild())
// Faster (when working with images)
Image.network(url, color: Colors.white.withOpacity(0.5), colorBlendMode: BlendMode.modulate)
// For text, just adjust TextStyle.color
Text('hi', style: TextStyle(color: Colors.black.withOpacity(0.5)))ClipPath / ClipRRect with complex shapes also trigger saveLayer. For simple rounded corners, use Container(decoration: BoxDecoration(borderRadius: ...)).
Frequently Changing Shaders / Gradients
Each change rebuilds the Shader — caching is expensive. Static gradients are fine; for dynamic gradients, consider CustomPainter with a cached Picture.
Profile-Mode Debugging
flutter run --profileOnly profile-mode numbers are meaningful. Debug mode includes JIT, assertions, and check code (several times slower); release mode strips out timeline data.
DevTools Panels
| Panel | Purpose |
|---|---|
| Performance | Frame timeline; locate jank frames |
| CPU Profiler | Function-level cost analysis |
| Memory | Heap, leak detection |
| Inspector → Rebuild Counts | Which Widgets rebuild repeatedly |
| Performance → Repaint Rainbow | Visualize repaint regions (more color-flashing = worse) |
| Performance → Layers | Layer composition |
Performance Overlay
MaterialApp(showPerformanceOverlay: true, ...)Top bar is GPU/raster thread frame time, bottom is UI thread. Crossing the red line means jank.
Timeline Events
import 'dart:developer';
Timeline.startSync('myWork');
heavyComputation();
Timeline.finishSync();Appears in the DevTools Performance timeline — useful for tagging custom code paths.
Startup Performance
Cold-Start Optimization
| Phase | Main Work | Optimization Levers |
|---|---|---|
| Native init | Load Engine, Dart VM | Small, hard to optimize |
| Dart isolate startup | Load main isolate, prepare to run main | Use deferred components to split bundles |
| First frame | Build first-screen widget tree | Keep first screen minimal; load secondary widgets async |
| Image / font load | Subset fonts; pre-compress first-screen images | Use SVG placeholders, skeleton screens |
Splash Screen
iOS uses LaunchScreen.storyboard; Android uses launch_background.xml or the flutter_native_splash package for unified management.
Deferred Loading
// Defer pages that are not needed up front
import 'package:flutter/material.dart' deferred as material;
Future<void> open() async {
await material.loadLibrary();
// Use material.XXX
}Suited to large feature modules (readers, editors).
Memory Optimization
Leak Monitoring
DevTools → Memory → Leaks (Flutter 3.16+) automatically detects undisposed Controllers and uncancelled Subscriptions.
Configure the Image Cache
// Adjust ImageCache limits
PaintingBinding.instance.imageCache.maximumSize = 100;
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20; // 100 MBUse WeakReferences for Large Objects
Avoid accidentally extending object lifetimes via caches:
final Expando<Decoded> _cache = Expando();
_cache[image] = decoded; // cache cleared automatically when image is collectedSkia vs Impeller
Impeller is now Flutter's default renderer on both iOS (since 3.10) and Android (default since 3.27, January 2025; on Vulkan-capable devices, with a Skia fallback for older hardware):
| Dimension | Skia | Impeller |
|---|---|---|
| Shader compilation | Runtime JIT — first-use jank | Pre-compiled — no jank |
| Metal / Vulkan | Through abstraction | Native |
| Bundle size | Smaller | Slightly larger |
| Maturity | Mature | Production on iOS/Android; still evolving on desktop |
To opt out on Android (e.g. to validate a regression):
<meta-data android:name="io.flutter.embedding.android.EnableImpeller" android:value="false" />Performance Checklist
Run through this before shipping:
- Profile-mode cold start on a real device < 2 s
- List scroll holds 60 / 120 fps (verify with the Performance Overlay)
- DevTools Rebuild Stats shows no abnormally high-frequency rebuilds
- All large images set
cacheWidth/cacheHeight -
flutter build --analyze-sizeshows no surprise growth - Memory Leaks panel shows no undisposed Controllers
- Long lists use
ListView.builder+ fixeditemExtent
Knuth applies to Flutter too: "premature optimization is the root of all evil." Write clear code first, profile to find bottlenecks, then optimize surgically. Sprinkling const, RepaintBoundary, and Selector blindly only makes the code harder to maintain.