5 Tips to Optimize Your Flutter App 🔥🚀


Flutter is a fantastic tool for building cross-platform applications quickly and effectively, as it allows you to launch your app for Android, iOS, Windows, macOS, and Linux — all from a single codebase. However, one of the most common criticisms of Flutter throughout its history has been its performance, especially on certain platforms like Apple’s devices. That’s why it’s crucial for you, as a developer, to have the right skills to optimize your app as much as possible, ensuring it runs fast and smoothly on the user’s device. Ideally, the user shouldn’t even be able to tell the difference between a native app and one built with Flutter.
I’ve been working with Flutter since the framework was still in its beta phase, and over the years, I’ve picked up quite a few tricks that help me achieve this goal. In this article, I want to share with you the five most important aspects I believe you should keep in mind when it comes to optimizing your Flutter application.
So without further ado, let’s dive in!
Avoid creating methods that return Widgets
One of the most common mistakes I see among Flutter developers —especially those who come from other frameworks or are just getting started— is creating methods that return widgets. While this might seem like a harmless shortcut to keep your code organized, it actually has a direct negative impact on your app’s performance.
Text createTextWidget() {
return Text('Widget created inside a method.');
}
The reason is simple: every time Flutter’s build method runs and hits that method, it will create a brand-new widget instance. Even if nothing has changed, Flutter has no way of knowing that the widget is the same, because you’re giving it a freshly created object every single time. As a result, you lose Flutter’s ability to optimize the widget tree and avoid unnecessary rebuilds.
Let’s take a look at an example of this problem:
class ExampleWidget extends StatefulWidget {
@override
State<ExampleWidget> createState() => _ExampleWidgetState();
}
class _ExampleWidgetState extends State<ExampleWidget> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView(
children: [
for(int i = 0; i < 100; i++)
_buildListItem('Item $i'),
],
),
);
}
Widget _buildListItem(String title) {
return ListTile(
title: Text(title),
);
}
}
At first glance, this code looks clean and functional. However, every time Flutter rebuilds ExampleWidget, it will call _buildListItem() for every single item in the list, generating new widget instances even if the list hasn’t changed at all.
If your app refreshes this layout 60 times per second, you might think that Flutter would need to build 100 widgets for each refresh — that is, a total of 6,000 builds. However, Flutter applies plenty of optimizations under the hood. In this example, each list item ends up being built around 300 times.
The proper solution: extract the widget into a dedicated class.
Instead of building the list items through a method, it’s better to define a separate widget class for each item. This allows Flutter to compare widget instances properly and optimize rebuilds.
Let’s refactor the previous example:
class ExampleWidget extends StatefulWidget {
@override
State<ExampleWidget> createState() => _ExampleWidgetState();
}
class _ExampleWidgetState extends State<ExampleWidget> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView(
children: [
for (int i = 0; i < 100; i++)
_ListItem(title: 'Item $i'),
],
),
);
}
}
class _ListItem extends StatelessWidget {
final String title;
const _ListItem({required this.title});
@override
Widget build(BuildContext context) {
return ListTile(title: Text(title));
}
}
Now, Flutter can treat each _ListItem as an independent widget. If the parent rebuilds, Flutter won’t recreate every list item unnecessarily — it will only update what’s needed.
If we go back to how many times Flutter builds each widget in the list, previously, Flutter was building each one around 300 times. Now, thanks to this small change, Flutter only needs to build the items in the list about 57 times. It’s also worth noting that other optimizations are happening under the hood here, especially with the ListView widget.
👉 As a rule of thumb, you should almost never create widgets inside methods. Instead, extract them into their own classes, even private ones within the same file; to help Flutter do its job efficiently. Your widget tree will be cleaner, your app will perform better, and you’ll make life easier for both yourself and the framework.
Prefer using ListView.builder() over passing a list of widgets
When rendering lists in Flutter, a common approach is to pass an array of widgets directly to the children parameter of ListView. While this works fine for small lists, it quickly becomes a problem as your list grows.
return Scaffold(
body: ListView(
children: [
// Long list of widgets...
],
),
);
That’s because Flutter will instantiate all those widgets in memory immediately, even if most of them aren’t visible. This increases memory usage and slows down your initial build.
It’s true that Flutter won’t call the build() method for every widget right away — it only builds what’s visible plus a small buffer for smooth scrolling. However, since all widget instances already exist in memory, you’re still paying the cost upfront.
Let’s take a look at an example of this problem:
class WorkingWithListsWrong extends StatelessWidget {
final List<String> items = List.generate(10000, (index) => 'Item $index');
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView(
children: [
for (final item in items)
ListTile(title: Text(item)),
],
),
);
}
}
In this example, we’re generating 10,000 items and passing them directly to ListView. Flutter will try to instance all 10,000 widgets immediately, even though the user will only see a small handful of them at any given time. This is a recipe for performance issues, especially on lower-end devices.
The proper solution: use ListView.builder()
To avoid this problem, simply use the builder constructor. Flutter will only create the widgets as they’re needed, keeping memory usage low and scroll performance smooth.
Here’s how you can refactor the previous example:
class ExampleWidget extends StatelessWidget {
final List<String> items = List.generate(10000, (index) => 'Item $index');
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(title: Text(items[index]));
},
),
);
}
}
Now, instead of instantiating 10,000 widgets up front, Flutter will only instantiate the ones that are currently visible. This simple change massively improves performance, especially with large lists.
👉 As a rule of thumb, whenever you work with lists of dynamic or large data sets, you should always prefer ListView.builder() over passing an explicit list of widgets. It’s a small change that has a big impact, keeping your app fast and responsive, even as your data grows.
Avoid heavy logic and repeated calculations inside build()
One of the best ways to keep your Flutter app fast and responsive is to keep the build() method as light as possible. Remember: build() can be called many times during the lifecycle of your widget — much more often than you might expect. If you place heavy operations inside build(), you’re forcing Flutter to repeat that work over and over again.
But it doesn’t stop there. Even lightweight operations can become problematic if you repeat them unnecessarily inside the same build() method. Flutter executes everything inside build() line by line, so if you call the same calculation multiple times, it will recompute it every single time.
Let’s look at an example of this problem:
class ExampleWidget extends StatefulWidget {
@override
State<ExampleWidget> createState() => _ExampleWidgetState();
}
class _ExampleWidgetState extends State<ExampleWidget> {
final List<int> numbers = List.generate(10000, (index) => index);
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Max number: ${_findMax()}'),
Text('Is 5000 in the list? ${numbers.contains(5000)}'),
Text('Max number again: ${_findMax()}'),
],
);
}
int _findMax() {
return numbers.reduce((a, b) => a > b ? a : b);
}
}
In this example:
_findMax() is called twice in the same build(), causing the calculation to run two times unnecessarily.
numbers.contains(5000) is an expensive operation for large lists.
Every time the widget rebuilds, these calculations will happen again.
The proper solution: compute once and reuse
The solution is simple: compute your values once, store them in local variables, and reuse them throughout build().
Here’s the improved version:
class ExampleWidget extends StatefulWidget {
@override
State<ExampleWidget> createState() => _ExampleWidgetState();
}
class _ExampleWidgetState extends State<ExampleWidget> {
final List<int> numbers = List.generate(10000, (index) => index);
@override
Widget build(BuildContext context) {
final maxNumber = _findMax();
final contains5000 = numbers.contains(5000);
return Column(
children: [
Text('Max number: $maxNumber'),
Text('Is 5000 in the list? $contains5000'),
Text('Max number again: $maxNumber'),
],
);
}
int _findMax() {
return numbers.reduce((a, b) => a > b ? a : b);
}
}
Now, even if build() is called multiple times, each expensive operation only runs once per build. This keeps your UI fast and avoids redundant work.
But, we can do it even better: move calculations to initState():
class ExampleWidget extends StatefulWidget {
@override
State<ExampleWidget> createState() => _ExampleWidgetState();
}
class _ExampleWidgetState extends State<ExampleWidget> {
final List<int> numbers = List.generate(10000, (index) => index);
late int maxNumber;
late bool contains5000;
@override
void initState() {
super.initState();
maxNumber = _findMax();
contains5000 = numbers.contains(5000);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Max number: $maxNumber'),
Text('Is 5000 in the list? $contains5000'),
Text('Max number again: $maxNumber'),
],
);
}
int _findMax() {
return numbers.reduce((a, b) => a > b ? a : b);
}
}
With this approach, the heavy calculations happen only once during the widget’s lifecycle. Even if the widget rebuilds multiple times (for example, because of unrelated UI updates), the values are already stored and reused, keeping your build() method clean and extremely fast.
👉 As a rule of thumb, avoid placing heavy logic inside build(), and always compute your values once per build, or even better, once per widget lifecycle (if possible).
Use Isolates for heavy computations
Flutter runs your app’s code in a single thread called the main isolate. This thread handles both your app’s logic and its UI rendering. If you perform heavy computations on the main isolate, such as image processing, large data parsing, or complex mathematical calculations, you risk blocking the UI thread, leading to janky animations, delayed interactions, or even complete freezes.
To avoid this, you can offload heavy tasks to a separate Isolate. Flutter provides this mechanism specifically to help you run CPU-intensive work in parallel without impacting the smoothness of your app.
Imagine you’re applying a filter to an image inside your build() or right after loading it on the main thread:
class ExampleWidget extends StatefulWidget {
@override
State<ExampleWidget> createState() => _ExampleWidgetState();
}
class _ExampleWidgetState extends State<ExampleWidget> {
late Image filteredImage;
@override
void initState() {
super.initState();
_applyFilter();
}
Future<void> _applyFilter() async {
final image = await loadImage(); // Simulated image loading
final result = applyHeavyFilter(image); // Heavy operation on main thread
setState(() {
filteredImage = result;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: filteredImage != null
? filteredImage
: const Center(child: CircularProgressIndicator()),
);
}
// Simulated functions
Future<Image> loadImage() async {
await Future.delayed(const Duration(milliseconds: 100));
return Image.asset('assets/image.png');
}
Image applyHeavyFilter(Image image) {
final now = DateTime.now();
while (DateTime.now().difference(now).inMilliseconds < 2000) {
// Simulate heavy work
}
return image;
}
}
In this example, applyHeavyFilter() simulates a heavy synchronous operation running on the main isolate. During this time, the app’s UI is blocked, and the user cannot interact with it.
The proper solution: use an Isolate
Let’s improve this by moving the heavy operation to a separate isolate. This way, the UI remains smooth while the processing happens in the background:
class ExampleWidget extends StatefulWidget {
@override
State<ExampleWidget> createState() => _ExampleWidgetState();
}
class _ExampleWidgetState extends State<ExampleWidget> {
Image? filteredImage;
@override
void initState() {
super.initState();
_applyFilter();
}
Future<void> _applyFilter() async {
final result = await compute(applyHeavyFilter, 'assets/image.png');
setState(() {
filteredImage = result;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: filteredImage != null
? filteredImage!
: const Center(child: CircularProgressIndicator()),
);
}
}
// Top-level function for compute
Image applyHeavyFilter(String assetPath) {
final now = DateTime.now();
while (DateTime.now().difference(now).inMilliseconds < 2000) {
// Simulate heavy work
}
return Image.asset(assetPath);
}
By using the compute() function, you offload the heavy task to a background isolate. Your main UI thread stays free to handle animations and user interactions.
👉 When your app needs to handle heavy operations like image processing, data parsing, or complex calculations, offload that work to an isolate. It keeps your app responsive and smooth, providing a much better user experience.
Optimize your images for performance
Images are often one of the biggest factors affecting your app’s performance. Poorly optimized images can lead to long loading times, high memory usage, and even frame drops if they’re not handled carefully.
There are three main things to consider when using images in your Flutter app:
Use multiple resolutions of your images
Flutter supports 1x, 2x, and 3x image variants. This allows you to provide lower-resolution images for low-density screens and higher-resolution images for high-density screens. Flutter automatically selects the correct image based on the device’s pixel density.
If you don’t provide these variants, Flutter will scale your image, but it might load unnecessarily large images on devices that don’t need them, wasting memory and processing power.
For example, if you only include a large image in your assets like this:
Image.asset('assets/images/large_image.png')
And your image is 3000x3000 pixels, even devices that display it at much smaller sizes will load the full-resolution asset. This increases memory usage and slows down your app.
The correct approach is to provide proper resolution variants following Flutter’s conventions:
assets/images/large_image.png // 1x — 300x300 px
assets/images/2.0x/large_image.png // 2x — 600x600 px
assets/images/3.0x/large_image.png // 3x — 900x900 px
Flutter will automatically pick the right image based on the device’s screen density, giving you better performance without sacrificing image quality.
Use the right image format
Different formats serve different needs:
PNG for images requiring transparency or sharp edges (like icons).
JPEG for photos and complex images with lots of colors.
WebP for modern, highly compressed images that support both transparency and photos.
Choosing the right format ensures a good balance between quality and file size.
Compress and size your images properly
No matter the format, always compress your images and use the correct resolution. Avoid using images straight from design tools or cameras at full resolution. For example, if your design only displays an image at 300x300 pixels, there’s no need to load a 3000x3000 pixel asset. Oversized images consume unnecessary memory and slow down your app.
👉 Images are a critical part of your app’s performance. By providing proper resolution variants, choosing the right formats, and compressing your assets, you’ll reduce your app’s memory usage and improve loading times, all while keeping your UI crisp and fast on every device.
Final thoughts
And that’s my top 5 tips to help you improve your Flutter app’s performance. If you found this article helpful, feel free to check out my YouTube channel as well, where I talk about Flutter development, new versions of the framework, and plenty of other topics that you’ll definitely find interesting!
Thank you for reading all the way to the end, I hope you have a beautiful rest of your day!
Subscribe to my newsletter
Read articles from David Serrano directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

David Serrano
David Serrano
I am a software developer specialized in mobile apps. About a decade ago I started my career as a web developer, but I soon moved into Android native development; however for the last few years I've been building hybrid apps with Flutter. I consider myself a passionate programmer, I enjoy writing clean and scalable code. In addition to developing apps, I also have knowledge of backend services development, web development, installation and maintenance of servers, marketing applied to the growth of web applications... among other things. I also like to create video games in my free time and write about topics that interest me in the technology world: the last tech trends, experiments that I do, and topics regarding user privacy. You can find more about me, my articles and my projects on my website: davidserrano.io