Why Your Flutter App Feels Slow and How to Fix It?

Firman Maulana - Sep 9 - - Dev Community

Have you ever used an app and noticed the UI is choppy on some pages? This might not be a big deal for some people as long as the main process is running smoothly. However, if this happens continuously, it can be very annoying, and users might start looking for other similar apps that are more convenient to use, which will cause a decrease in the number of users of your app 😢.

Maybe some of you think that the slowness of an application is very subjective based on user experience, right? It could be that the application is fine but users feel very slow and vice versa. If so, it will be difficult to assess the quality of an application. So we should look at other more measurable ways. One of them is for Android applications, you can use Android Vitals in Google Play Console.

Android Vitals

You can see comparison metrics that show how well or poorly your application performs compared to other similar applications on the market, which is useful for helping developers identify areas that need improvement. As developers, we want our apps to be useful and provide a good experience for users. In app development, an optimal user experience often depends on how good the user interface (UI) looks. A slow or inefficient UI can lead to performance degradation.

You may have heard this before:

Flutter is fast by default, so you don’t need to worry about performance.

While this is generally true, you don’t need to optimize your app prematurely or when it’s not needed. We need to understand what might be causing your app to slow down and focus more on how Flutter’s rendering works underneath the widgets you use. Well, this is something I experienced a while back too. So, I explored the app and there are a few things I want to share with you.

Understanding Rendering Phases
Before doing anything, even fixing our code, the important thing is you must first understand the process so you can figure out what the problem is and how to fix it later. There are several phases for UI rendering. You can see the sequencing diagram below.

Flutter sequencing diagram

The build phase is our responsibility as Flutter developers and the one we have the most control over. Here, our widgets are rebuilt, and the related elements for them are created. From those widgets, we also create the relevant rendering objects. Then, we enter the layout and painting phase, which is managed by the framework or the rendering object, and in general, we don’t need to worry about layout and painting. Of course, you can create custom-rendered objects or manage CustomPainting via CustomPainter. Maybe later, we will try to understand it in more detail.

Now, I want to talk about rendering jank. Flutter targets 60 frames per second, which is good for mobile apps. If it takes too long, it will cause janks. Jank can be triggered for various reasons, one of which is because a lot of work is done on the UI thread or raster thread, which causes delays in completing frames. Work on the UI thread usually involves updating the state, rebuilding widgets, or performing heavy operations such as calculations, parsing, or fetching data. The work on the raster thread can be complex graphic rendering, such as drawing large images, complex animations, or other visual effects.

Isolate

To reduce the risk of jank, we must be careful in writing code to avoid heavy operations on the UI thread or Raster thread. For example, heavy operations can be moved to isolate to run in the background, so that the UI thread remains responsive. We can also optimize the code with good state management, use widgets that are appropriate for our needs, and avoid complex animations or large images that require a lot of GPU resources. An isolate is like a separate worker that can do heavy tasks in the background without blocking the main UI thread. This way, the UI stays smooth and responsive even when your app is doing something complex.

How Does an Isolate Work?
Think of the main UI thread as a waiter in a restaurant. If you give the waiter too many tasks, like cooking food or washing dishes, they won't have time to serve the customers. In Flutter, if the main UI thread is busy doing heavy tasks, the app becomes slow.

Instead, we can hire another worker (an isolate) to handle those heavy tasks, like calculating big numbers or processing large data files. The main UI thread can keep handling user interactions, like scrolling or tapping buttons, while the isolate does the heavy lifting in the background.

Example of Using an Isolate
Let's say we want to calculate a list of prime numbers, which is a heavy task. Instead of doing this on the main thread, we use an isolate to do it in the background:

void calculatePrimes(SendPort sendPort) {
  final List<int> primes = [];
  int num = 2;

  // Calculate prime numbers
  while (primes.length < 10000) {
    if (isPrime(num)) {
      primes.add(num);
    }
    num++;
  }

  // Send the results back to the main thread
  sendPort.send(primes);
}
Enter fullscreen mode Exit fullscreen mode

We create a separate worker (isolate) to calculate prime numbers.
The calculatePrimes function runs in the isolate, so it doesn't slow down the main thread. When the calculation is done, the isolate sends the result back to the main thread using SendPort.

Optimizing Build

Flutter provides devtools that can help monitor your app's performance and identify potential causes of jank. This allows you to determine what steps you can take to improve performance, especially in rendering the UI in your app. There are several things I noticed and found from the case I experienced and of course, based on articles and other developers who encountered similar cases. So I tried to group action items that you can try to improve performance, especially in rendering the app you created. There will be many things based on the project you are working on. Here I will try to focus on one thing that can be done on any project, build optimization. What are those things? Let's find out!

1. Use Constant Widgets
The first thing, you should always do is take advantage of constant widgets whenever possible. We can talk a lot about the benefits of const. Const variables are compile-time constants, meaning one const object will be created and reused no matter how much the const expression is evaluated. For widgets, this means Flutter will not unnecessarily rebuild widgets marked as const. Flutter will be smart in determining that the build of that widget will not change, so it can sometimes skip the rebuild process.

2. Static and Non-const Objects
You can also use static objects for objects that cannot become constants. Use static when the class cannot become constant, but you only need one instance throughout your project.

class NotConstant {
  static final anotherContConstant = NotConstantAnother();
  NotConstant() {
    print(anotherContConstant);
  }
}

Enter fullscreen mode Exit fullscreen mode
class NotConstantAnother {}
Enter fullscreen mode Exit fullscreen mode

For example, both of these classes are not constants, and we create 100 instances of the NotConstant class. Inside that class, we create a static final NotConstantAnother, and inside the constructor, we print the static object.

void createInstance() {
  for (var i = 0; i < 50; i++) {
    final notConstant = NotConstant();
  }
}
Enter fullscreen mode Exit fullscreen mode

When we try to create 50 instances of notConstant as in the code above, we only have an instance of anotherContConstant.

3. Optimizing Execution
We can also sometimes improve readability by using the late keyword. This example is the same as before, but note that operation1 and operation2 will only call the expensive operation when we try to access them because of the late keyword. This is called lazy computation.

void doSomething() {
  const condition = true;
  late final hasPubspecFile = File('pubspec.yaml').existsSync();
  late final hasTestFolder = Directory('test').existsSync();

  // if the condition is false, has TestFolder/has PubspecFile will not be computed
  // Similarly if "hasTestFolder" is false, hasPubspecFile will not be computed
  if (condition && hasTestFolder && hasPubspecFile) {
    print('Hello world');
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Prefer Small Widgets
Back to widgets. The next important thing to reduce the cost of building widgets is to prefer smaller widgets. The main benefit is reducing the number of widgets that need to be rebuilt when calling setState, and it also makes testing much easier.


class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Here we have the old example with the counter. When you press the floating button, it calls setState, which causes all widgets in MyHomePage to be rebuilt, except that marked const. Can you think of a better way to avoid all these widgets being rebuilt unnecessarily? You're right, state management. The state management frameworks we have are great, and they all have their ways of reducing widget rebuilds in your app. You can use flutter_bloc, riverpod, or whatever you like and fits the project you're developing.

The next question is which is better, preferring widgets or making helper methods? Try looking at the code below:


@override
Widget build(BuildContext context) {
  return const MyHomePage();
}
Enter fullscreen mode Exit fullscreen mode
@override
Widget build(BuildContext context) {
  return myHomePage();
}
Enter fullscreen mode Exit fullscreen mode

We have indirectly touched on some points as to why you should prefer custom widgets. The main reason is that when we call setState to rebuild certain widgets, we want to limit that to only rebuilding what is needed. This becomes very difficult to do with helper methods because our build becomes very large, and as a result, just because of the use of helper methods.

Helper methods cause unwieldy big widgets. Calling setState in a helper method will cause the entire widget and all other helper methods in that widget to rebuild. Custom widgets have the option to be marked as const when possible.

5. Using Lazy Loading List
The main concept of a lazy loading list will render items visible on the screen, while other items will only be rendered when needed. These practices can be very beneficial if you are working with large lists. Of course, you can apply the same principle to GridView using GridView.builder.

Project Example

Okay, now let’s get to the fun part and explore the tools and examples. The best way to understand Flutter is to examine the underlying code. But today I’ll try to show you a simple example app that we can use to compare the implementation of the optimizations we discussed earlier.

In our Flutter app, here is the complete project, we have one homepage with two buttons to display the prime numbers page, each with a different approach to handling performance.

Recommended Page
The Recommended Page implements some of the things we discussed above. The app performs the heavy computation required to render the prime number list on a separate thread using Dart’s Isolate. This means that while the computation is running, the main thread, which is responsible for handling the user interface, remains free to update and respond to user interactions. As a result, users experience a smooth and responsive app because the intensive processing does not block or slow down the interface.

Additionally, the Recommended Page uses lazy loading via ListView.builder. This technique ensures that only items currently visible on the screen are displayed. As the user scrolls, new items are loaded dynamically. This approach significantly reduces memory usage and improves performance because it avoids loading and rendering the entire prime list at once. Users enjoy a smoother scrolling experience without any noticeable lag or delay.


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

  @override
  State<RecommendedPage> createState() => _RecommendedPageState();
}

class _RecommendedPageState extends State<RecommendedPage> {
  late Future<List<int>> _primesFuture;
  late ReceivePort _receivePort;
  Isolate? _isolate;

  @override
  void initState() {
    super.initState();
    _receivePort = ReceivePort();
    // Initiates the prime number computation in a separate isolate.
    // This approach prevents UI blocking and ensures a smooth user experience.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _primesFuture = _computePrimes();
    });
  }

  @override
  void dispose() {
    // Properly closes the receive port and terminates the isolate when the widget is disposed of.
    // This avoids memory leaks and ensures the isolate is not running in the background unnecessarily.
    _receivePort.close();
    _isolate?.kill(priority: Isolate.immediate);
    super.dispose();
  }

  Future<List<int>> _computePrimes() async {
    // Spawns a new isolate to perform the prime number computation.
    _isolate =
        await Isolate.spawn(_computePrimesInIsolate, _receivePort.sendPort);

    // Listens for the results sent from the isolate.
    // Using isolates allows for parallel processing without blocking the main thread.
    final List<int> primes = await _receivePort.first;
    return primes;
  }

  static void _computePrimesInIsolate(SendPort sendPort) {
    // Performs the prime number computation in a separate isolate.
    // The computation does not affect the UI performance.
    final List<int> primes = [];
    int num = 2;
    while (primes.length < 10000) {
      if (_isPrime(num)) {
        primes.add(num);
      }
      num++;
    }
    sendPort.send(
        primes); // Sends the computed list of primes back to the main isolate.
  }

  static bool _isPrime(int number) {
    // Determines if a number is prime.
    if (number <= 1) return false;
    for (int i = 2; i <= number ~/ 2; i++) {
      if (number % i == 0) return false;
    }
    return true;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Recommended'),
      ),
      body: FutureBuilder<List<int>>(
        future: _primesFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          } else if (snapshot.hasError) {
            return Center(child: Text('Error: ${snapshot.error}'));
          } else if (snapshot.hasData) {
            final primes = snapshot.data!;
            // Efficiently displays the list of prime numbers using ListView.builder.
            // This approach lazily builds widgets only for the visible items, optimizing memory usage.
            return ListView.builder(
              itemCount: primes.length,
              itemBuilder: (context, index) {
                return ListTile(
                  title: Text('Prime ${primes[index]}'),
                );
              },
            );
          } else {
            return const Center(child: Text('No data available'));
          }
        },
      ),
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

NotRecommended Page
The NotRecommended Page handles prime number computation directly on the main thread. This means the app’s interface can become unresponsive during the computation because the main thread is busy processing tasks. Users may experience freezing or slowdowns because the UI thread cannot update or handle interactions while it is busy generating the main list.

The NotRecommended Page also uses a traditional ListView that simultaneously loads and renders all list items. This method increases memory usage because all items are processed and stored in memory at once. As a result, scrolling becomes less smooth and can cause lag, especially if the list is very large. Users may notice performance issues like stuttering or slowness when interacting with the list.

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

  @override
  State<NotRecommendedPage> createState() => _NotRecommendedPageState();
}

class _NotRecommendedPageState extends State<NotRecommendedPage> {
  late Future<List<int>> _primesFuture;

  @override
  void initState() {
    super.initState();
    // Initialize the future that will compute prime numbers.
    // This computation happens on the main thread, which may cause the UI to freeze.
    _primesFuture = _computePrimes();
  }

  Future<List<int>> _computePrimes() async {
    // Simulates heavy computation on the main thread.
    // This can block the UI, making it unresponsive during the calculation.
    final List<int> primes = _generatePrimes();
    return primes;
  }

  List<int> _generatePrimes() {
    // Generates a list of prime numbers up to a certain count.
    final List<int> primes = [];
    int num = 2;
    while (primes.length < 10000) {
      if (_isPrime(num)) {
        primes.add(num);
      }
      num++;
    }
    return primes;
  }

  bool _isPrime(int number) {
    if (number <= 1) return false;
    for (int i = 2; i <= number ~/ 2; i++) {
      if (number % i == 0) return false;
    }
    return true;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Not Recommended'),
      ),
      body: FutureBuilder<List<int>>(
        future: _primesFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          } else if (snapshot.hasError) {
            return Center(child: Text('Error: ${snapshot.error}'));
          } else if (snapshot.hasData) {
            final primes = snapshot.data!;
            // Displays the list of prime numbers in a scrollable column.
            // This approach may cause performance issues for a large number of items because it renders all items at once.
            return SingleChildScrollView(
              child: Column(
                children: primes.map((prime) {
                  return ListTile(
                    title: Text('Prime $prime'),
                  );
                }).toList(),
              ),
            );
          } else {
            return const Center(child: Text('No data available'));
          }
        },
      ),
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

Run Our Flutter App
Okay, now let’s try running our app in profile mode. Of course, profile mode still has some pretty cool debugging capabilities for profiling your app’s performance. So, here’s what our simple app looks like.

App UI

Profile mode is disabled on the emulator and simulator because its behavior does not represent real-world performance, so you need to make changes on the device.

When running with profile mode you can see in the terminal there is a Flutter DevTools URL that you can use to monitor your application. Now you open it in the browser and will be directed to the devtools page, we are talking about UI rendering so we go to the performance tab.

You can also activate the enhance tracing menu to add detail to the trace timeline. Here I try to check the Track Widget Builds section.

Enhance Tracing

When you enter the recommended page, you can see that there are no distracting UI blocks. While using Isolate helps move heavy computations (such as generating a list of prime numbers) off the main thread, there are still some operations or UI elements that may require uncompiled shaders, and triggering the first shader compilation. Okay, then you scroll the page and look back at the devtools. As you can see the page scrolls smoothly, and there is no jank found.

Recommended Page Devtools

Now let's try to tap the Not Recommended button to enter the page. You will be stuck for a while before being directed to the not recommended page and after that try to scroll and look back at the devtools. You will find so much jank and the user experience becomes uncomfortable because the scrolling does not run smoothly.

Not Recommended Page

Conclusion

Performance optimization, especially related to UI rendering, which is the focus of this discussion, may not be something that must be done from the beginning of the project, but it is important enough to understand to determine our steps and know which parts are the main priority or can be postponed for the next stage.

Keep in mind that many factors cause it. There are also those that we can control as developers, which are difficult or even impossible, such as things that are directly related to the characteristics of the technology we use or even the user's device. So once again, let's focus on the parts that we can control, one of which is improving code quality.

The few ways I mentioned above may be just a part of improving UI rendering based on what I have found and experienced. Maybe next time we can discuss other things that help optimize rendering, such as border repainting, caching, animations, and so on.

I think there might be many other ways to improve your app performance. If you have similar experiences or other methods that you do, feel free to share them in the comments 💬!

Source Codes

References:
https://docs.flutter.dev
https://x.com/remi_rousselet?lang=en
https://www.youtube.com/@flutterdev
https://www.youtube.com/@streamdevelopers

. . . . . . . . . . . . . . . . . . . .
Terabox Video Player