Published on

Flutter Performance Part 3: Freeing the Main Thread

Authors
  • avatar
    Name
    Phat Tran
    Twitter

Your app renders at a steady 60 FPS now. Then a user downloads five years of transaction history, and everything stops. The spinner freezes mid-rotation, buttons go dead, and for two full seconds the app looks crashed.

But you used async/await, so it should not freeze, right?

This is probably the most common misunderstanding in Dart, and it is worth getting precise about.


1. The async illusion

Dart runs your code on a single thread driven by an event loop. async/await gives you non-blocking I/O, not parallelism.

When you fire an HTTP request for account data, Dart hands the request to the operating system and the event loop moves on. While the network does its thing, frames keep drawing and taps keep working. So far the illusion holds.

The moment the response arrives and you call jsonDecode on a 5MB string, the illusion breaks. Decoding is CPU work, and there is only one thread. The event loop cannot draw the next frame or process a tap until the parsing finishes. That is your two-second freeze: not the download, the decode.

2. Isolates: Dart's version of threads

CPU-bound work needs real concurrency, and in Dart that means isolates.

Unlike threads in Java or C++, isolates do not share mutable state. Each has its own heap and its own event loop, and they communicate only by passing messages. You give up shared-memory tricks and in exchange you never think about mutexes or data races. For application code, that is a trade worth taking.

Isolates used to require tedious SendPort/ReceivePort plumbing, which is why so many older tutorials look intimidating. Since Dart 2.19 the API is one call: Isolate.run(). (Flutter's old compute() helper still exists, and nowadays it is just a thin wrapper around the same thing.)

3. The fix, in practice

Here is the code that froze the app, and the version that does not:

// Bad: parsing a large JSON string on the main thread.
// The UI freezes for as long as this takes.
Future<List<Transaction>> parseHistory(String jsonStr) async {
  // The 'await' 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();
}
import 'dart:isolate';

// Good: Isolate.run() moves the heavy lifting to a background isolate.
// The main thread stays free to draw frames.
Future<List<Transaction>> parseHistoryOptimized(String jsonStr) async {

  // Isolate.run spawns an isolate, executes the closure,
  // returns the result, and tears the isolate down.
  return await Isolate.run(() {

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

  });
}

One wrapper, and the payload parses in the background while the UI keeps animating.

A few details that matter once you use this in production:

  • Returning the result is cheap. Isolate.run() delivers its final value via Isolate.exit, which hands the object over to the calling isolate without a deep copy. This is why it pays to do the full transformation inside the closure and return finished Transaction objects, not the raw decoded list.
  • The closure's captured variables are sent to the new isolate, so keep them small and sendable. Capture the JSON string, not your entire repository object.
  • Spawning an isolate costs a couple of milliseconds. For a one-off parse that is irrelevant. If you process frequent small jobs (live market ticks, chat messages), spawning per job adds up, and a long-lived worker isolate with ports is the better architecture.
  • On the web there are no isolates, so this code falls back to running on the main thread. If you ship to web, keep payloads small there or move the work server-side.

Up next: memory and app size

A fast main thread does not help if the app dies quietly after ten minutes. Part 4 is about the silent killer: image caching gone wrong, memory leaks, and putting the final binary on a diet.