Steven's Knowledge

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)
StageOptimization Levers
BuildShrink rebuild scope, use const, split into small Widgets
LayoutAvoid expensive layout (IntrinsicWidth/Height, nested Flex), fix itemExtent
PaintRepaintBoundary isolation, cache CustomPainter output
CompositingReduce layer count, avoid saveLayer / clipRect
RasterizeSmaller 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 --profile

Only profile-mode numbers are meaningful. Debug mode includes JIT, assertions, and check code (several times slower); release mode strips out timeline data.

DevTools Panels

PanelPurpose
PerformanceFrame timeline; locate jank frames
CPU ProfilerFunction-level cost analysis
MemoryHeap, leak detection
Inspector → Rebuild CountsWhich Widgets rebuild repeatedly
Performance → Repaint RainbowVisualize repaint regions (more color-flashing = worse)
Performance → LayersLayer 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

PhaseMain WorkOptimization Levers
Native initLoad Engine, Dart VMSmall, hard to optimize
Dart isolate startupLoad main isolate, prepare to run mainUse deferred components to split bundles
First frameBuild first-screen widget treeKeep first screen minimal; load secondary widgets async
Image / font loadSubset fonts; pre-compress first-screen imagesUse 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 MB

Use 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 collected

Skia 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):

DimensionSkiaImpeller
Shader compilationRuntime JIT — first-use jankPre-compiled — no jank
Metal / VulkanThrough abstractionNative
Bundle sizeSmallerSlightly larger
MaturityMatureProduction 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-size shows no surprise growth
  • Memory Leaks panel shows no undisposed Controllers
  • Long lists use ListView.builder + fixed itemExtent

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.

On this page