# Flutter Performance: What Actually Makes a Difference

I've dealt with my share of performance issues in production Flutter apps. Janky scrolling that makes users think the app is broken. Memory leaks that crash the app after 10 minutes of use. The kind of problems that make you question your life choices.

This article serves as a personal reminder of what to do in my new and existing Flutter projects, so that I don’t make the same mistakes again.

## Common Performance Issues

This is how performance issues look like in real apps:

**Janky scrolling:**

* User scrolls through a list
    
* App stutters, drops frames
    
* Looks broken, unprofessional
    

**Memory leaks:**

* App works fine initially
    
* Gets slower over time
    
* Eventually crashes
    
* Hard to reproduce, harder to debug
    

## What Actually Makes a Difference

After many instances of dealing with performance issues, here's what actually moves the needle.

### 1\. Const Constructors: The Easiest Win

This is the lowest-hanging fruit in Flutter performance. Use `const` constructors wherever you can.

**Bad:**

```dart
class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Hello'),
        SizedBox(height: 16),
        Text('World'),
      ],
    );
  }
}
```

**Good:**

```dart
class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Text('Hello'),
        const SizedBox(height: 16),
        const Text('World'),
      ],
    );
  }
}
```

**Why it matters:**

When you use `const`, Flutter doesn't create new widget instances on every rebuild. It reuses the same instance. This means:

* Less memory allocation
    
* Less garbage collection
    
* Faster rebuilds
    
* Better performance
    

**Enable the lint rules:**

Add these to your `analysis_options.yaml`:

```yaml
include: package:flutter_lints/flutter.yaml # actually this line would most likely suffice

linter:
  rules:
    - prefer_const_constructors
    - prefer_const_constructors_in_immutables
    - prefer_const_declarations
    - prefer_const_literals_to_create_immutables
```

These lint rules will warn you when you forget `const`. Follow the warnings. The `flutter_lints` package is included by default in Flutter projects created with Flutter 2.3+.

### 2\. ListView Performance: This Actually Matters

If your app has lists (and it probably does), this is important.

**Bad: Building all items upfront**

```dart
// DON'T DO THIS
Widget build(BuildContext context) {
  return ListView(
    children: items.map((item) => ItemWidget(item)).toList(),
  );
}
```

**Why it's bad:**

* Creates ALL widgets immediately, even items not visible on screen
    
* With 1000 items = 1000 widgets in memory
    
* Causes jank and memory issues
    

**Good: Use ListView.builder**

```dart
// DO THIS
Widget build(BuildContext context) {
  return ListView.builder(
    itemCount: items.length,
    itemBuilder: (context, index) {
      return ItemWidget(items[index]);
    },
  );
}
```

**Why it's good:**

* Creates widgets on demand, so only visible items are built
    
* Automatically recycles widgets
    
* Handles 10,000 items without issues
    

**For GridView, same principle:**

```dart
// Good
GridView.builder(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
  ),
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ItemWidget(items[index]);
  },
)
```

**Separated lists:**

If you need to add dividers, use `ListView.separated`:

```dart
ListView.separated(
  itemCount: items.length,
  itemBuilder: (context, index) => ItemWidget(items[index]),
  separatorBuilder: (context, index) => const Divider(),
)
```

**Custom ScrollView for complex layouts:**

For mixed content (lists, grids, single items), use `CustomScrollView` with slivers:

```dart
CustomScrollView(
  slivers: [
    SliverAppBar(
      title: const Text('Title'),
    ),
    SliverToBoxAdapter(
      child: HeaderWidget(),
    ),
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => ItemWidget(items[index]),
        childCount: items.length,
      ),
    ),
    SliverGrid(
      delegate: SliverChildBuilderDelegate(
        (context, index) => GridItem(gridItems[index]),
        childCount: gridItems.length,
      ),
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
      ),
    ),
  ],
)
```

This keeps everything lazy and efficient.

### 3\. Images: Don't Load Everything at Once

Images are memory hogs. We need to be careful with large-sized or huge number of images.

**Bad: Loading large images**

```dart
// DON'T DO THIS with large images
Image.asset('assets/large_image.png')
```

If the image is 4000x3000 and you display it at 400x300, you're wasting memory.

**Good: Use appropriate sizes**

```dart
// For network images, use cacheWidth/cacheHeight
Image.network(
  imageUrl,
  cacheWidth: 400,
  cacheHeight: 300,
)

// For asset images, provide multiple resolutions
// assets/
//   image.png      (1x)
//   2.0x/image.png (2x)
//   3.0x/image.png (3x)
Image.asset('assets/image.png')
```

**In lists, always specify dimensions:**

```dart
ListView.builder(
  itemBuilder: (context, index) {
    return Image.network(
      items[index].imageUrl,
      width: 100,
      height: 100,
      cacheWidth: 100,
      cacheHeight: 100,
      fit: BoxFit.cover,
    );
  },
)
```

**Use a proper image caching library:**

For production apps, use `cached_network_image`:

```dart
CachedNetworkImage(
  imageUrl: imageUrl,
  width: 100,
  height: 100,
  memCacheWidth: 100,
  memCacheHeight: 100,
  placeholder: (context, url) => const CircularProgressIndicator(),
  errorWidget: (context, url, error) => const Icon(Icons.error),
)
```

This handles caching, memory management, and loading states for you.

### 4\. Memory Leaks

**Problem 1: Not disposing controllers**

**Bad:**

```dart
class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  final _controller = TextEditingController();
  final _scrollController = ScrollController();
  final _animationController = AnimationController(
    vsync: this,
    duration: Duration(seconds: 1),
  );

  @override
  Widget build(BuildContext context) {
    return TextField(controller: _controller);
  }
  
  // Forgot to dispose!
}
```

**Good:**

```dart
class _MyWidgetState extends State<MyWidget> 
    with SingleTickerProviderStateMixin {
  late final TextEditingController _controller;
  late final ScrollController _scrollController;
  late final AnimationController _animationController;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController();
    _scrollController = ScrollController();
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 1),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    _scrollController.dispose();
    _animationController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(controller: _controller);
  }
}
```

**Controllers that need disposal:**

* `TextEditingController`
    
* `ScrollController`
    
* `TabController`
    
* `AnimationController`
    
* `PageController`
    
* `VideoPlayerController`
    
* Any controller with a `dispose()` method
    

**Enable the lint rule:**

Add this to catch missing disposal:

```yaml
linter:
  rules:
    - close_sinks
```

Note: The built-in `close_sinks` rule catches `Sink` instances but doesn't catch all controllers. For more comprehensive coverage, consider using third-party linter packages like `dart_code_linter` (DCM) which has rules like `dispose-fields` that specifically check for undisposed controllers in StatefulWidgets.

**Problem 2: Not canceling streams**

**Bad:**

```dart
class _MyWidgetState extends State<MyWidget> {
  @override
  void initState() {
    super.initState();
    
    // This stream keeps running even after widget is disposed
    Stream.periodic(Duration(seconds: 1)).listen((event) {
      setState(() {
        // Update state
      });
    });
  }
}
```

**Good:**

```dart
class _MyWidgetState extends State<MyWidget> {
  StreamSubscription? _subscription;

  @override
  void initState() {
    super.initState();
    
    _subscription = Stream.periodic(Duration(seconds: 1)).listen((event) {
      if (mounted) {
        setState(() {
          // Update state
        });
      }
    });
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }
}
```

**Enable the lint rule:**

```yaml
linter:
  rules:
    - cancel_subscriptions
```

This rule warns you when you create a `StreamSubscription` but don't cancel it. It's part of the recommended Flutter lints.

**Problem 3: Not removing listeners**

**Bad:**

```dart
class _MyWidgetState extends State<MyWidget> {
  final scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    
    scrollController.addListener(() {
      // Do something
    });
    // Listener never removed!
  }
}
```

**Good:**

```dart
class _MyWidgetState extends State<MyWidget> {
  late final ScrollController scrollController;

  @override
  void initState() {
    super.initState();
    scrollController = ScrollController();
    scrollController.addListener(_onScroll);
  }

  void _onScroll() {
    // Do something
  }

  @override
  void dispose() {
    scrollController.removeListener(_onScroll);
    scrollController.dispose();
    super.dispose();
  }
}
```

For listener removal, DCM provides the `always-remove-listener` rule that catches this pattern.

**Problem 4: Timers and Future callbacks**

**Bad:**

```dart
class _MyWidgetState extends State<MyWidget> {
  @override
  void initState() {
    super.initState();
    
    // Timer keeps running after widget is disposed
    Timer.periodic(Duration(seconds: 1), (timer) {
      setState(() {}); // Crash! Widget is disposed
    });
  }
}
```

**Good:**

```dart
class _MyWidgetState extends State<MyWidget> {
  Timer? _timer;

  @override
  void initState() {
    super.initState();
    
    _timer = Timer.periodic(Duration(seconds: 1), (timer) {
      if (mounted) {
        setState(() {});
      }
    });
  }

  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }
}
```

**Use the** `mounted` **check:**

Always check `mounted` before calling `setState` in async callbacks:

```dart
Future<void> fetchData() async {
  final data = await api.getData();
  
  if (mounted) {
    setState(() {
      _data = data;
    });
  }
}
```

In fact, Flutter will warn you if you do not include a `mounted` check after an async callback before calling `setState`.

### 5\. Widget Rebuilds: Break It Down

Flutter rebuilds the entire widget subtree when you call `setState()`. Minimize what rebuilds.

**Bad: Rebuilding everything**

```dart
class HomePage extends StatefulWidget {
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Home'),
      ),
      body: Column(
        children: [
          ExpensiveWidget(), // Rebuilds every time setState is called, unless this is marked as const
          Text('Counter: $_counter'),
          AnotherExpensiveWidget(), // Rebuilds every time setState is called, unless this is const
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => setState(() => _counter++),
      ),
    );
  }
}
```

**Good: Extract stateful widget**

```dart
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home'),
      ),
      body: Column(
        children: [
          const ExpensiveWidget(), // Does not rebuild
          const CounterWidget(), // Only this rebuilds
          const AnotherExpensiveWidget(), // Does not rebuild
        ],
      ),
    );
  }
}

class CounterWidget extends StatefulWidget {
  const CounterWidget({Key? key}) : super(key: key);

  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Counter: $_counter'),
        FloatingActionButton(
          onPressed: () => setState(() => _counter++),
          child: const Icon(Icons.add),
        ),
      ],
    );
  }
}
```

**Or use a builder:**

```dart
class HomePage extends StatefulWidget {
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home'),
      ),
      body: Column(
        children: [
          const ExpensiveWidget(),
          StatefulBuilder(
            builder: (context, setState) {
              return Column(
                children: [
                  Text('Counter: $_counter'),
                  FloatingActionButton(
                    onPressed: () => setState(() => _counter++),
                    child: const Icon(Icons.add),
                  ),
                ],
              );
            },
          ),
          const AnotherExpensiveWidget(),
        ],
      ),
    );
  }
}
```

**Key principle:** Keep stateful widgets small. Only the parts that change should be stateful.

### 6\. Keys

There are specific cases where they are critical.

**You need keys when:**

1. **Reordering lists:**
    

```dart
List<Widget> items = [
  ItemWidget(key: ValueKey('item1'), data: data1),
  ItemWidget(key: ValueKey('item2'), data: data2),
  ItemWidget(key: ValueKey('item3'), data: data3),
];

// Without keys, Flutter might reuse the wrong widget state
// when items are reordered
```

2. **Preserving state when parent rebuilds:**
    

```dart
class ParentWidget extends StatefulWidget {
  @override
  State<ParentWidget> createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  bool _showFirst = true;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        if (_showFirst)
          StatefulChild(key: ValueKey('first'))
        else
          StatefulChild(key: ValueKey('second')),
      ],
    );
  }
}
```

3. **In PageView or TabView with stateful children:**
    

```dart
PageView(
  children: [
    Page1(key: PageStorageKey('page1')),
    Page2(key: PageStorageKey('page2')),
    Page3(key: PageStorageKey('page3')),
  ],
)
```

**Types of keys:**

* `ValueKey`: When you have a unique value (ID, string)
    
* `ObjectKey`: When you have a unique object
    
* `UniqueKey`: When you need a unique key every time
    
* `GlobalKey`: When you need to access widget state from outside (rare)
    
* `PageStorageKey`: For preserving scroll position
    

**Enable the lint rule:**

```yaml
linter:
  rules:
    - use_key_in_widget_constructors
```

This reminds you to add keys to your custom widgets when they might need them.

## Performance Patterns to Follow From Day 1

These are patterns you should always follow. They are not premature optimization, they're just good defaults.

### 1\. Always Use Const

If a widget can be `const`, make it `const`.

```dart
// Good habit
const Text('Hello')
const SizedBox(height: 16)
const Icon(Icons.add)
const Padding(padding: EdgeInsets.all(8))
```

### 2\. Always Use Builder for Lists

Never use `.toList()` on a map in a ListView.

```dart
// Bad
ListView(children: items.map((item) => Widget(item)).toList())

// Good
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) => Widget(items[index]),
)
```

### 3\. Extract Widgets Early

Don't wait for performance issues to extract widgets. Do it as you write.

```dart
// Instead of this
Widget build(BuildContext context) {
  return Column(
    children: [
      Container(
        // 50 lines of widget code
      ),
      Container(
        // Another 50 lines
      ),
    ],
  );
}

// Also don't do this
Widget build(BuildContext context) {
  return Column(
    children: [
      _buildFirstSection(),
      _buildSecondSection(),
    ],
  );
}

Widget _buildFirstSection() {
  return Container(
    // Widget code
  );
}

Widget _buildSecondSection() {
  return Container(
    // Widget code
  );
}
```

Instead, create separate widget classes:

```dart
Widget build(BuildContext context) {
  return Column(
    children: [
      const FirstSection(),
      const SecondSection(),
    ],
  );
}
```

### 4\. Avoid Unnecessary Containers

Don't wrap widgets in `Container` when you don't need to.

```dart
// Bad
Container(
  child: Text('Hello'),
)

// Good
Text('Hello')

// If you need padding
Padding(
  padding: EdgeInsets.all(8),
  child: Text('Hello'),
)

// If you need size
SizedBox(
  width: 100,
  height: 100,
  child: Text('Hello'),
)
```

**Enable the lint rule:**

```yaml
linter:
  rules:
    - avoid_unnecessary_containers
```

This warns you when a `Container` isn't doing anything useful.

### 5\. Always Dispose

Create a checklist for every StatefulWidget:

* Created a controller? Add a dispose.
    
* Added a listener? Remove it in dispose.
    
* Started a stream? Cancel it in dispose.
    
* Started a timer? Cancel it in dispose.
    

```dart
class _MyWidgetState extends State<MyWidget> {
  // Controllers
  late final _controller = TextEditingController();
  
  // Subscriptions
  StreamSubscription? _subscription;
  
  // Timers
  Timer? _timer;
  
  @override
  void initState() {
    super.initState();
    // Setup
  }

  @override
  void dispose() {
    // Clean up EVERYTHING
    _controller.dispose();
    _subscription?.cancel();
    _timer?.cancel();
    super.dispose();
  }
}
```

### 6\. Specify Image Sizes

Whenever you use images, specify dimensions:

```dart
// Always specify size
Image.network(
  url,
  width: 100,
  height: 100,
  cacheWidth: 100,
  cacheHeight: 100,
)
```

### 7\. Use Lazy Loading

Use builder pattern for any list or grid:

* `ListView.builder`
    
* `GridView.builder`
    
* `ListView.separated`
    
* `CustomScrollView` with slivers
    

Never use regular `ListView()` or `GridView()` with a list of children.

## Other Good Optimizations

**Optimize later when you measure:**

* Using `RepaintBoundary` for expensive widgets
    
* Using `ListView.builder` with `cacheExtent`
    
* Implementing `shouldRebuild` in custom widgets
    
* Using `compute()` for heavy calculations
    

**How to measure:**

Flutter has built-in tools:

1. **Performance overlay:**
    
    You can toggle display of the performance overlay on your app using the Performance Overlay button in the Flutter inspector. If you prefer to do it in code, add the following:
    

```dart
MaterialApp(
  showPerformanceOverlay: true,
  // ...
)
```

2. **DevTools:**
    

* Open DevTools in your IDE
    
* Go to Performance tab
    
* Record while you interact with your app
    
* Look for frame drops (red bars)
    

3. **Timeline:**
    

```dart
import 'dart:developer';

Timeline.startSync('expensive_operation');
// Your code
Timeline.finishSync();
```

Then view in DevTools.

**What to look for:**

* Frame render time &gt; 16ms (for a 60Hz display) → causes jank
    
* Excessive rebuilds
    
* Memory growing over time (leak)
    
* Long build times for specific widgets
    

## Common Mistakes

**Mistake 1: Premature compute()**

Don't throw everything in `compute()` for isolate processing. Isolates have overhead. Most operations are fast enough on the main thread.

Use `compute()` when:

* Parsing large JSON (1000+ items)
    
* Image processing
    
* Complex calculations (&gt;100ms)
    

Don't use `compute()` for:

* Simple calculations
    
* Small JSON parsing
    
* Database queries (already async)
    

**Mistake 2: Overusing setState()**

Every `setState()` rebuilds the widget. If you're calling it in a loop, you're killing performance.

```dart
// Bad
for (var item in items) {
  setState(() {
    // Update for each item
  });
}

// Good
setState(() {
  for (var item in items) {
    // Update all items
  }
});
```

**Mistake 3: Not testing on real devices, with profile mode**

Your M1 Mac can handle anything. Your user's budget phone might not.

Always test on:

* A mid-range Android/iOS device (3-4 years old)
    
* A low-end device if your users are price-sensitive
    
* Real network conditions
    

## Final Thoughts

Performance in Flutter is not about knowing obscure tricks. It's about following good patterns consistently.

The biggest performance issues I've dealt with came from:

* Not using builders for lists (trying to render 1000 widgets)
    
* Not disposing controllers (memory leaks)
    
* Not using const (unnecessary rebuilds)
    
* Loading huge images (memory issues)
    

All of these are preventable with good defaults and the right linter rules.

And when you do have performance issues, don't guess. Measure (with profile mode), find the bottleneck, fix it, measure again.

That's it. No magic, no tricks. Just consistent good practices.
