Flutter Performance: What Actually Makes a Difference
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:
TextEditingControllerScrollControllerTabControllerAnimationControllerPageControllerVideoPlayerControllerAny 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:
- 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
- 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')),
],
);
}
}
- 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 objectUniqueKey: When you need a unique key every timeGlobalKey: 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.builderGridView.builderListView.separatedCustomScrollViewwith slivers
Never use regular ListView() or GridView() with a list of children.
Other Good Optimizations
Optimize later when you measure:
Using
RepaintBoundaryfor expensive widgetsUsing
ListView.builderwithcacheExtentImplementing
shouldRebuildin custom widgetsUsing
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,
// ...
)
- DevTools:
Open DevTools in your IDE
Go to Performance tab
Record while you interact with your app
Look for frame drops (red bars)
- 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.