Skip to main content

Command Palette

Search for a command to run...

Flutter Performance: What Actually Makes a Difference

Updated
β€’12 min read
Y

Google Developer Expert in Flutter & Dart πŸš€| Mobile App Development πŸ“±| Tech Advocate 🌐| Women Who Code KL Director

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:

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

Good:

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:

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

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

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

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

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:

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

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

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

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:

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:

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:

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:

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:

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:

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:

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:

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

  @override
  void initState() {
    super.initState();

    scrollController.addListener(() {
      // Do something
    });
    // Listener never removed!
  }
}

Good:

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:

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:

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:

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

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

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:

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:
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
  1. Preserving state when parent rebuilds:
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')),
      ],
    );
  }
}
  1. In PageView or TabView with stateful children:
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:

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.

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

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

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

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.

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

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.

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:

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

MaterialApp(
  showPerformanceOverlay: true,
  // ...
)
  1. DevTools:
  • Open DevTools in your IDE

  • Go to Performance tab

  • Record while you interact with your app

  • Look for frame drops (red bars)

  1. Timeline:
import 'dart:developer';

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

Then view in DevTools.

What to look for:

  • Frame render time > 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 (>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.

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