---
title: "Performance"
description: "UI optimization from widgets to the GPU, profiling with DevTools, and how tree shaking cuts the bundle."
tags: [flutter, dart, performance, optimization]
---

## 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

```dart
// 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:

```dart
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

```dart
// Instead of putting state at the top level
class BadExample extends StatefulWidget {
  @override
  _BadExampleState createState() => _BadExampleState();
}

class _BadExampleState extends State<BadExample> {
  bool isSelected = false;

  @override
  Widget build(BuildContext context) {
    // Entire widget tree rebuilds when isSelected changes
    return Column(
      children: [
        LargeWidget(),
        AnotherLargeWidget(),
        Switch(
          value: isSelected,
          onChanged: (value) => setState(() => isSelected = value),
        ),
      ],
    );
  }
}

// Better approach: Localize the state in a smaller widget
class GoodExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        LargeWidget(), // Won't rebuild when switch changes
        AnotherLargeWidget(), // Won't rebuild when switch changes
        SelectionSwitch(), // Only this rebuilds
      ],
    );
  }
}

class SelectionSwitch extends StatefulWidget {
  @override
  _SelectionSwitchState createState() => _SelectionSwitchState();
}

class _SelectionSwitchState extends State<SelectionSwitch> {
  bool isSelected = false;

  @override
  Widget build(BuildContext context) {
    return Switch(
      value: isSelected,
      onChanged: (value) => setState(() => isSelected = value),
    );
  }
}
```

### 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

```dart
// Efficient approach for long lists
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) => ItemTile(item: items[index]),
)
```

```dart
ListView.builder(
  itemCount: items.length,
  itemExtent: 80.0, // Fixed height per item for better performance
  itemBuilder: (context, index) => ItemTile(item: items[index]),
)
```

```dart
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

```dart
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`:

```dart
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:

```dart
// 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

```dart
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
    );
  },
)
```

```dart
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

```dart
@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:

```dart
// 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](https://brain.narayann.dev/notes/engineering/flutter/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](https://brain.narayann.dev/notes/engineering/flutter/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:

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

```dart
// Add this import
import 'package:flutter/rendering.dart';

void main() {
  // Enable performance overlay in debug mode
  debugPaintSizeEnabled = false;
  debugPrintMarkNeedsLayoutStacks = false;
  debugPrintMarkNeedsPaintStacks = false;

  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // Enable performance overlay
      showPerformanceOverlay: true,
      home: const OptimizedScreen(),
    );
  }
}
```

***

## 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.
