How to measure and improve Flutter app performance

Yechan Choi


What is a well-made app? An app made with Flutter!

What does a well-built, polished app look like? How do you make sure your users think your app is polished? This question is really hard to answer, because different users will have different perspectives, and it’s hard to please everyone. However, it’s not easy to make sure a "stuttering app" is polished. So how do we make sure our apps are no longer stuttering? In this article, you’ll learn how to measure and improve the performance of apps built with Flutter.

Jank Error Phenomenon

Flutter uses Skia engine to create and remove Widgets. Normally, the Skia engine updates the screen with the Ticker running at 60Hz, so we need to finish rendering in 16.7ms. If we don’t finish rendering in time for the Ticker’s cycle, the new UI won’t be drawn, the screen won’t update, and the app will feel stuttery. This is called a jank when the user sees that the app is out of sync with the refresh rate.

Jank error caused by rendering timeout

Render should complete in time for the screen update cycle (dotted line)… but if it doesn’t, Jank!¹

Rendering doesn’t just have to finish in 16.7ms - the less time it takes to render, the less battery and heat it consumes. Also, Flutter will render faster in apps that support 60Hz or higher to match the refresh rate as much as possible, so the less time it takes to render, the better.

Measuring Rendering Performance with Flutter

To improve rendering performance, you need to detect the occurrence of janks in your app and monitor rendering. Flutter supports Performance Profiling tool for this purpose. Run Flutter in profile mode, and turn on Dart DevTools. If it takes more than 17ms to render the screen, it will be represented by a red bar, indicating that improvement is needed.

Flutter DevTools를 활용해 Rendering performance 측정

Flutter DevTools - Performance

Talking about the four graphs represented in the Performance tab,


The thread that runs the Dart code through the Dart VM. It is responsible for converting the Widget Tree into a Layer Tree and sending it to the Raster thread.


Takes in the Layer Tree and communicates with the GPU to update the UI. The Skia engine runs in this thread. Developers do not have access to the Raster thread or the data in the thread.


Threads for each platform. They are not shown in the Performance Overlay.


is responsible for input and output, and is not shown in the Performance Overlay.

The most common cases of Jank are when both the UI and Raster threads are taking a long time, and when the UI thread is fine but the Raster thread is having problems. In the former case, the UI thread is making too many changes, the Widget Tree is changing too often, or heavy work is being done in the UI thread. In the latter case, saveLayer, Opacity, Shadow, and Clip are likely to be the culprits. Caching non-static images is also costly.² For more information, see the official documentation.

Shader Compilation Jank Error Phenomenon.

Shader compilation jank is when an animation stutters the first time you run your app. Shaders are pieces of code that are processed by the GPU, and in order to draw them in the app, they need to be compiled, and the compilation process causes jank. Starting with Flutter 1.20, you can use SkSL to reduce shader compilation jank.

SkSL warmup

  1. Run the app in profile mode with the -cache-sksl option.
flutter run --profile --cache-sksl// First time running with --cache-sksl option
flutter run --profile --cache-sksl --purge-persistent-cache
  1. trigger all possible animations.
  2. press M to save the captured SkSL.
  3. Build using the saved SkSL.
flutter build appbundle --bundle-sksl-path flutter\\\\_01.sksl.json
flutter build ios --bundle-sksl-path flutter\\\\_01.sksl.json

Theoretically, there’s no guarantee that a saved SkSL will help on other devices, but even if it doesn’t, it shouldn’t cause any problems and is said to work in most cases.³ For more information, see the official documentation.

How to improve performance based on Flutter documents.

Here are 5 ways to improve rendering performance.

Make the build method as lightweight as possible, and as few calls as possible.

  • The build method is a function that can be called again anytime there is a UI change, so you don’t want to do anything expensive in build. When using FutureBuilder, if you don’t cache the future, you’ll be waiting for a new future every time.
  • Many small widgets are better than one big widget; breaking up the implementation of one big widget into methods doesn’t help. StatelessWidgets and StatefulWidgets use their own caching system, so the cost of rebuilding without change is not significant.
  • A way to ensure that the build method is called as little as possible is to make the widget const. A const Widget will not be rebuilt if its parent widget is rebuilt, unless it has changed. Try running the following example in DartPad to see the Console window.

Make sure the possible Widget Tree does not change.

Here’s how the widget is actually drawn in Flutter.

Flutter에서 위젯이 그려지는 과정, Widget Tree, Element Tree, Render Tree

Widget Tree/Element Tree/Render Tree ⁴

The Widget Tree configured by the developer is converted to an Element Tree. The Element Tree is a tree of elements that maps the logical structure, the Widget Tree, to the structure that is actually rendered, the Render Tree. A Widget creates an Element via createElement(). The Element that is created is the BuildContext. The Element creates a RenderObject via createRenderObject(), which in turn creates a Layer. The resulting LayerTree is then passed to the Raster thread to draw the widget.

So if there are no changes to the Widget Tree during the rebuild process, there will be minimal changes to the Render Tree. However, if there are changes to the Widget Tree, the entire subtree will be rebuilt, which will put a load on the UI thread.

Lazy load whenever possible.

In most cases, ListView.builder is better than ListView. ListView.builder dynamically builds only the widgets that are visible on the screen, and doesn’t keep them in memory once they’re off the screen (out of cacheExtend scope, to be precise). ListView, on the other hand, builds all widgets the first time it’s built, causing a jank.

Isolate the heavy lifting.

First, let’s talk about Isolate and Future, Async, and Await in Dart. Dart is a single-threaded language by default. Dart starts with only one Isolate. Async and Await are not parallel operations.

An Isolate is an independent execution space with memory, one thread, and an eventLoop. An eventLoop consists of a microTaskQueue and an eventQueue, with the microTaskQueue taking priority by default.

void eventLoop() {
      while(microTaskQueue.isNotEmpty) {
  } if (eventQueue.isNotEmpty) {
} // Execute the task in eventQueue after executing all the tasks in microTaskQueue. [5]

All events, including all I/O, Gestures, Taps, Timers, Futures, and messages from other Isolates, are added to the eventQueue and then processed sequentially by the eventLoop.

EventQueue에 등록된 event가 eventLoop에 의해 순차적으로 실행

Events registered in the eventQueue are executed sequentially by the eventLoop, and each event’s handler/task is processed in a thread.⁶

For more information on microtasks and eventloops, see link.

async, await are executed in the following order.

  1. the Future object is registered in an internal array
  2. code that needs to be executed in relation to the Future is registered in the eventQueue
  3. return an incomplete Future object
  4. code that should be executed synchronously is executed first
  5. processed by eventLoop first, then passing data to the Future object in a container

This means that async and await are also computed in the UI Thread, so if you end up doing the heavy lifting in the UI Thread, you could end up with a jank.

So how can we handle heavy tasks without overloading the UI Thread? To handle tasks in parallel, we need to create an Isolate. The created Isolate can operate without affecting UI Processing because it operates on a separate memory and EventLoop. On the other hand, an Isolate is completely "isolated" from other Isolates, as its name implies. So, the newly created Isolate will behave by sending and receiving messages through ports with the main Isolate.

Main Isolate와 통신을 주고 받는 Timer Isolate, Flutter

Timer Isolate communicating with Main Isolate ⁷.

It’s not a good idea to just create an isolate and handle it, as it has its own memory allocation and has the overhead of sending messages to and from the main isolate. A good rule of thumb is to use 16ms as a benchmark, which is how often the UI is updated, and handle anything that takes longer than that via an isolate. A prime example of a sync operation that can take longer than 16ms is Json serialization.

With the release of Flutter 2.5, there are some pretty significant changes to this, which I’ll discuss in the Flutter 2.5 section below.

Use effects only when you really need them.

Effects will put a load on the Raster thread and GPU.

  • The saveLayer() will cause slowdowns on devices with older GPUs. Even if you don’t explicitly call SaveLayer(), saveLayer() can be triggered by Clip.antiAliasWithSaveLayer, ShaderMask, ColorFilter, Chip, and Text(overflowShader).
  • Rather than using the Opacity widget, it is better to grant transparency via options in child widgets if possible.
  • It is cheaper to give all child widgets the borderRadius property than to give it through the Clip.

Flutter 2.5 Update.

Flutter 2.5 was released this morning as I write this post. There are tons of performance improvements, and the Flutter DevTools have been updated.

First, the Metal Shader on iOS has been improved, reducing rasterization time by 2/3. Also, the scheduling policy has been changed: previously, UI updates were sometimes interrupted by async tasks as described above, but now frame processing is given higher priority than microtasks, and microtasks are paused while UI threads are processing frames. We expect this to significantly reduce the occurrence of janks.

Garbage Collector stops the UI thread when it reclaims memory, which is also a cause of janks. Previously, memory for images was reclaimed slowly, causing janks to be very frequent on low-memory devices, but we’ve now changed Garbage Collector to reclaim memory for unused images very aggressively, significantly reducing the number of GC runs. We’ve seen over 400 GC runs reduced to 4 runs when playing a GIF of about 20 seconds.

Although not shown in the Performance Overlay, communication with the Platfrom Thread was also introducing latency that was causing janks. By removing unnecessary copies of the message codec, they reportedly reduced latency by up to 50% depending on the device.

Many of the Janks that developers were unable to optimize for have been resolved with this release of Flutter 2.5. In addition to performance-related updates, there are a number of minor updates for Apple Silicon M1, Dart 2.14, Android fullscreen, Material You, MateiralState.scrolledUnder, Material Banner, and TextEditingShortcuts.

It’s a big enough update to skip from Flutter 2.2.3 to Flutter 2.5.0, so check out the changes in What’s new in flutter 2.5 and try them out in your apps. Happy Fluttering!

References and sources











*This content is a copyrighted work protected by copyright law and is copyrighted by Ellis.
*The content is prohibited from secondary processing and commercial use without prior consent.

  • #Flutter
  • #performance

Bring innovative DX solutions to your organization

Sign up for a free trial and a business developer will provide you with a personalized DX solution consultation tailored to your business