Flutter Performance: What Actually Makes a Difference
Google Developer Expert in Flutter & Dart 🚀| Mobile App Development 📱| Tech Advocate 🌐| Women Who Code KL Director
Search for a command to run...
Google Developer Expert in Flutter & Dart 🚀| Mobile App Development 📱| Tech Advocate 🌐| Women Who Code KL Director
I've been using Bloc for years. It was my go-to state management solution, and I was comfortable with it. Events go in, states come out. Simple, predictable, testable. So when people kept telling me to try Riverpod, I resisted. Why fix what isn't bro...
I've been building Flutter apps for a team of around 5 developers for the past few years. When I started, everyone talked about Clean Architecture like it was the only "professional" way to build apps. So naturally, I tried it. After months of writin...
Recently I talked about how to perform automated end-to-end testing on any mobile apps (yes, any, even those you don’t own) with Maestro.Deck: https://excalidraw.com/#json=YBaTDvWm8yTdshL8Z2IOL,7h7736K_Ey7wg08QljkbPg
Recently I gave a talk at Google DevFest Georgetown 2024, about asynchronous Dart features (now with quizzes built in!). Here are the slides I promised:
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.
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
After many instances of dealing with performance issues, here's what actually moves the needle.
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+.
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.
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.
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.
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.
There are specific cases where they are critical.
You need keys when:
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
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')),
],
);
}
}
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.
These are patterns you should always follow. They are not premature optimization, they're just good defaults.
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))
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]),
)
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(),
],
);
}
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.
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();
}
}
Whenever you use images, specify dimensions:
// Always specify size
Image.network(
url,
width: 100,
height: 100,
cacheWidth: 100,
cacheHeight: 100,
)
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.
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:
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,
// ...
)
Open DevTools in your IDE
Go to Performance tab
Record while you interact with your app
Look for frame drops (red bars)
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
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
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.