Brain
EngineeringFlutter

Performance

UI optimization from widgets to the GPU, profiling with DevTools, and how tree shaking cuts the bundle.

UI optimization

Smooth UI comes from rebuilding less, drawing less, and measuring what you changed. The subsections below go from the widget layer down to the GPU, then cover how to prove the work paid off.

Widgets

  • Use const constructors wherever possible to prevent unnecessary rebuilds
  • Break large widgets into smaller, focused components
  • Extract commonly used widgets to avoid duplication
  • Minimize the use of anonymous functions in widget builds
  • Implement shouldRepaint on custom painters and shouldRebuild on delegates so they can skip work when nothing they depend on changed
// Instead of this
Container(
  color: Colors.blue,
  child: Text('Hello'),
)

// Use this
const Container(
  color: Colors.blue,
  child: Text('Hello'),
)

A const widget is created once and reused, so Flutter can skip rebuilding it.

Splitting a screen into smaller widgets keeps each rebuild local:

class OptimizedScreen extends StatelessWidget {
  const OptimizedScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Optimized UI')),
      body: Column(
        children: const [
          // Extracted into separate widgets
          HeaderSection(),
          ContentSection(),
          FooterSection(),
        ],
      ),
    );
  }
}

State management

  • Localize state to the smallest possible widget
  • Pick a state management solution (Provider, Riverpod, Bloc) that matches app complexity
  • Avoid setState() high in the widget tree
  • Rebuild only the subtree that actually changed

Lists

  • Use ListView.builder() instead of ListView() for long lists
  • Set itemExtent for fixed height items
  • Use ListView.separated() for consistent separators
  • Raise cacheExtent to preload more items
  • Paginate very large datasets
  • Give dynamic lists stable keys
// Efficient approach for long lists
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) => ItemTile(item: items[index]),
)
ListView.builder(
  itemCount: items.length,
  itemExtent: 80.0, // Fixed height per item for better performance
  itemBuilder: (context, index) => ItemTile(item: items[index]),
)
ListView.separated(
  itemCount: items.length,
  itemBuilder: (context, index) => ItemTile(item: items[index]),
  separatorBuilder: (context, index) => const Divider(height: 1),
)

Images

  • Cache network images with a package like cached_network_image
  • Resize images to the size they are displayed at
  • Pick the format by content: WebP, PNG, or JPEG
  • Provide placeholders and error handlers
  • Load images lazily when they are outside the viewport
CachedNetworkImage(
  imageUrl: "https://example.com/image.jpg",
  placeholder: (context, url) => CircularProgressIndicator(),
  errorWidget: (context, url, error) => Icon(Icons.error),
)

Resize on the server when you can, otherwise use ResizeImage:

Image(
  image: ResizeImage(
    NetworkImage('https://example.com/large_image.jpg'),
    width: 300,
    height: 300,
  ),
)

Layout

  • Flatten the widget tree to cut layout recalculations
  • Use SizedBox instead of an empty Container for spacing
  • Use LayoutBuilder for responsive designs, it rebuilds on constraint changes rather than on every MediaQuery change
  • Drop unnecessary Padding and Container nesting
  • Reach for Expanded and Flexible instead of hard coded sizes

Container is a convenience widget that expands into several render objects. Reaching for the specific widget does the same job with less to lay out:

// Nested padding, margin, and decoration in one Container
Container(
  margin: EdgeInsets.all(8),
  padding: EdgeInsets.all(8),
  decoration: BoxDecoration(
    color: Colors.blue,
  ),
  child: Text('Hello'),
)

// Same result, fewer render objects
Padding(
  padding: EdgeInsets.all(16),
  child: ColoredBox(
    color: Colors.blue,
    child: Text('Hello'),
  ),
)

DevTools' Layout Explorer shows the constraints flowing down each node, which is usually faster than reading the tree.

Animation

  • Pass a static child to AnimatedBuilder so it is not rebuilt
  • Avoid raw Opacity in animations
  • Prefer AnimatedOpacity and the other prebuilt animation widgets
  • Keep custom painter animations cheap
  • Use Hero for shared element transitions
AnimatedBuilder(
  animation: _controller,
  // Don't rebuild static child widgets
  child: const StaticChildWidget(),
  builder: (context, child) {
    return Transform.rotate(
      angle: _controller.value * 2.0 * math.pi,
      child: child, // Reuse the pre-built child
    );
  },
)
AnimatedOpacity(
  opacity: _visible ? 1.0 : 0.0,
  duration: const Duration(milliseconds: 500),
  child: const Text('Fade-in Text'),
)

Memory

  • Dispose controllers and listeners
  • Load resources lazily
  • Use weak references for caches that can be rebuilt
  • Release resources once they leave the viewport
  • Watch memory in DevTools
@override
void dispose() {
  _controller.dispose();
  _textEditingController.dispose();
  super.dispose();
}

Rendering and GPU

  • Keep shaders simple, complex gradients cost frames
  • Avoid clipping, it is expensive on the GPU
  • Wrap independently repainting subtrees in RepaintBoundary
  • Avoid full screen repaints
  • Minimize Opacity changes, they force GPU composition

Stacked opacity is the common overdraw bug. Each layer is composited separately, and the result is the same as one multiplied value:

// Two layers, composited twice
Opacity(
  opacity: 0.5,
  child: Opacity(
    opacity: 0.5,
    child: MyWidget(),
  ),
)

// One layer, same visual result
Opacity(
  opacity: 0.25,
  child: MyWidget(),
)

See layout-and-rendering for how RepaintBoundary works at the render object level.

Code organization

  • Structure code by feature or responsibility
  • Keep build methods small and focused
  • Extract widgets by responsibility
  • Use the Builder pattern when a widget needs its own BuildContext
  • Keep provider scopes tight so state does not propagate further than needed

Common problems

Four failure modes cover most jank, and each one maps to a phase of the frame described in layout-and-rendering.

Excessive rebuilds. The build phase runs on too much of the tree. Push setState() down to the smallest widget that owns the state, extract stateful pieces, and mark everything that does not change as const.

Layout thrashing. The layout phase re-runs over a deep tree. Flatten the hierarchy, avoid nesting scrollables, and be deliberate about LayoutBuilder and MediaQuery, both of which rebuild on every size change.

Paint overhead. The paint phase is doing expensive work every frame. Wrap the part that changes in RepaintBoundary, cut opacity, blur, and clip effects, and keep custom painters simple when they run on animation.

Layer explosion. Compositing is handed too many layers, usually from stacked opacity, clips, and shadows. Consolidate effects, and turn on debugRepaintRainbowEnabled to see which regions actually repaint.

Measuring performance

Optimization without measurement is guesswork. Track:

  1. Frame rate: aim for 60fps, which is 16.67ms per frame
  2. Memory usage: monitor in DevTools
  3. App startup time: measure cold and warm starts
  4. First meaningful paint: how quickly the user sees content
  5. Time to interactive: when the user can first act on the UI

DevTools gives you four views worth knowing: the Timeline for frame by frame rendering, the Widget Inspector for rebuild counts, the Layout Explorer for constraints, and the CPU Profiler for methods that burn time. Test on a low end device, not just the simulator.

Never profile in debug mode. Debug builds run the JIT with assertions on and are several times slower than release, so any number you take from them is meaningless. Use flutter run --profile. The debug banner in the corner is the reminder.

To collect frame timings from inside the app rather than by eye:

SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {
  for (final timing in timings) {
    // buildDuration, rasterDuration, and totalSpan per frame
  }
});


Tree shaking

Tree shaking drops code your app never calls from the final bundle.

What it is

Tree shaking is a term commonly used in modern JavaScript frameworks that has been adopted in Flutter. It refers to the process of removing dead (unused) code during the compilation phase, resulting in smaller application sizes.

How it works

  1. Dependency Analysis: During compilation, Flutter analyzes your application's code to identify which parts of the imported libraries and packages are actually used.

  2. Dead Code Identification: The compiler identifies code paths that are never executed (dead code) based on your actual usage patterns.

  3. Code Elimination: The identified unused code is then removed from the final build, reducing the application's size.

  4. Optimization Process: This process happens automatically during the release build process without requiring any special configuration.

Benefits

  • Reduced App Size: The final application package (APK/IPA) is smaller since it doesn't include unused code.
  • Improved Performance: Smaller apps typically have faster startup times and better runtime performance.
  • Lower Resource Usage: Less memory is required to load and run the application.
  • Faster Downloads: Smaller app sizes mean faster downloads for users, which can improve adoption rates.

In practice

Flutter's Ahead-of-Time (AOT) compilation process, used when building release versions of apps, automatically performs tree shaking. When you include a large package but only use a small portion of its functionality, tree shaking ensures only the necessary parts are included in your app.

For example, if you import the Material Design library but only use a few specific widgets, the compiler will only include those widgets and their dependencies in the final build, not the entire Material Design library.

Limitations

  • Tree shaking works best with pure Dart code and may have limitations with native code included through plugins.
  • Some code may be retained if the compiler cannot definitively determine it's unused.
  • Reflection-based code and dynamically loaded code may prevent effective tree shaking.

Understanding tree shaking helps developers make informed decisions about package dependencies, knowing that unused portions won't unnecessarily bloat their applications.

On this page