Published on

Flutter Performance Part 3: Freeing the Main Thread

Authors
  • avatar
    Name
    Phat Tran
    Twitter

Your banking app is looking fantastic. The UI rendering is perfectly optimized, and you are consistently hitting 60 FPS. But then, a user tries to download their transaction history for the past 5 years. Suddenly, the entire app freezes. The loading spinner stops spinning, buttons become unresponsive, and for 2 solid seconds, the app looks dead.

What happened? You used async/await, so it shouldn't freeze the UI, right?

This is one of the most common misunderstandings in Dart and Flutter. Let's fix it.


1. The Async Illusion: Understanding the Event Loop

Dart is single-threaded. It runs on an Event Loop. When you use async/await, you are performing non-blocking I/O.

If you make an HTTP request to fetch bank data, Dart will hand that request over to the OS and say, "Wake me up when the network responds." During this waiting period, the Event Loop is completely free. It can continue drawing frames at 60 FPS, spinning loading indicators, and handling user taps.

However, once the network data arrives and you start parsing a massive 5MB JSON string into Dart objects, you are performing a CPU-bound task.

Parsing JSON requires raw CPU math. Because Dart is single-threaded, the Event Loop is forced to drop everything and focus entirely on parsing the JSON. It cannot draw the next frame. It cannot spin the loading indicator. The UI is officially frozen until the parsing is complete.

2. True Concurrency: Enter Isolates

To solve CPU-bound freezes, you need true multi-threading. In Dart, threads are called Isolates.

Unlike threads in Java or C++, Isolates do not share memory. They have their own memory heap and their own Event Loop. This makes them incredibly safe (no need to worry about mutex locks or race conditions), but it means they must communicate by passing messages back and forth.

In the past, working with Isolates required writing a lot of complex boilerplate code or using the clunky compute() function. Thankfully, modern Dart provides a much cleaner API: Isolate.run().

3. Real-World Fix: Parsing Heavy Data

Let's look at the exact code that froze our banking app, and how to fix it in seconds.

The Bad Way (Freezes the UI)

// ❌ BAD: Parsing a massive JSON string on the Main Thread.
// The UI will completely freeze while this function executes.
Future<List<Transaction>> parseHistory(String jsonStr) async {
  // The 'await' here only applied to fetching the data.
  // jsonDecode runs synchronously on the main thread!
  final List decoded = jsonDecode(jsonStr);

  return decoded.map((e) => Transaction.fromJson(e)).toList();
}

The Good Way (Silky Smooth UI)

import 'dart:isolate';

// ✅ GOOD: Using Isolate.run() pushes the heavy lifting to a background thread.
// The main UI thread remains completely free to animate 60 FPS loading spinners.
Future<List<Transaction>> parseHistoryOptimized(String jsonStr) async {

  // Isolate.run automatically spawns a background thread, executes the closure,
  // returns the result, and immediately destroys the thread to clean up memory.
  return await Isolate.run(() {

    final List decoded = jsonDecode(jsonStr);
    return decoded.map((e) => Transaction.fromJson(e)).toList();

  });
}

It is that simple. With a single wrapper, your heavy payload is safely parsed in the background, ensuring your users never see a frozen screen.


Up Next: Memory Diet and App Size

Now that our main thread is clear and our frames are fast, we have to address the silent killer: RAM usage. In Part 4, we will explore how poor image caching can crash your app, how to hunt down memory leaks, and how to put your final binary size on a diet.