← Back to posts Cover image for Unfreezing Your Flutter UI: Mastering Async Operations with Isolates and Compute

Unfreezing Your Flutter UI: Mastering Async Operations with Isolates and Compute

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

We’ve all been there. Your Flutter app is running smoothly until you introduce a heavy task—processing a large image, parsing a massive JSON file, or performing complex calculations. Suddenly, your 60 FPS UI stutters, animations freeze, and the app becomes unresponsive. This “jank” frustrates users.

The root cause is Dart’s single-threaded execution model. While Dart’s async/await pattern handles I/O operations (like network calls or file reads) without blocking, it doesn’t create new threads for CPU-intensive work. Heavy computations still run on the main isolate—the same thread that handles drawing your UI, processing gestures, and running animations. When you bog down that thread, the UI has to wait.

Let’s look at a problematic example. Suppose we’re building an app that generates a fractal image.

// WARNING: This will freeze your UI!
Future<Uint8List> generateMandelbrotImage(int size) async {
  final pixels = Uint8List(size * size * 4);
  for (int x = 0; x < size; x++) {
    for (int y = 0; y < size; y++) {
      // Complex, expensive per-pixel calculation
      double cx = (x - size / 2) * 4.0 / size;
      double cy = (y - size / 2) * 4.0 / size;
      // ... intensive computation here ...
      int index = (y * size + x) * 4;
      pixels[index] = colorR;
      pixels[index + 1] = colorG;
      pixels[index + 2] = colorB;
      pixels[index + 3] = 255;
    }
  }
  return pixels;
}

// Calling this in a button press will cause noticeable UI freeze
ElevatedButton(
  onPressed: () async {
    final imageData = await generateMandelbrotImage(500);
    // Update UI with image
  },
  child: Text('Generate'),
)

Even though we use async, the computation itself isn’t waiting on I/O—it’s pure CPU work. The await means “don’t run the next line until this function finishes,” but the function still blocks the main thread.

The Solution: Offload Work with Isolates

Dart provides isolates—separate memory spaces with their own threads. They don’t share memory, which avoids complex locking, but they can communicate by passing messages.

The Easy Button: compute()

For one-off tasks, Flutter’s compute function is a lifesaver. It spawns an isolate, runs your function there, and returns the result.

Here’s how we fix our fractal generator:

// 1. Define a top-level or static function
Uint8List _generateMandelbrotInIsolate(int size) {
  final pixels = Uint8List(size * size * 4);
  for (int x = 0; x < size; x++) {
    for (int y = 0; y < size; y++) {
      // Same heavy computation, but now in an isolate
      double cx = (x - size / 2) * 4.0 / size;
      double cy = (y - size / 2) * 4.0 / size;
      // ... intensive computation ...
      int index = (y * size + x) * 4;
      pixels[index] = colorR;
      pixels[index + 1] = colorG;
      pixels[index + 2] = colorB;
      pixels[index + 3] = 255;
    }
  }
  return pixels;
}

// 2. Call it with compute
ElevatedButton(
  onPressed: () async {
    // This won't freeze the UI!
    final imageData = await compute(_generateMandelbrotInIsolate, 500);
    // Update UI with image
  },
  child: Text('Generate Smoothly'),
)

Key Points about compute:

  • The function must be top-level or static.
  • Arguments and return values must be serializable.
  • It’s perfect for tasks that take a few hundred milliseconds to a few seconds.

Beyond compute: Long-Lived Isolates

For ongoing work or repeated calls, creating a new isolate each time adds overhead. Instead, spawn a dedicated isolate and keep it alive.

import 'dart:isolate';

class DataProcessor {
  late SendPort _workerSendPort;

  Future<void> startIsolate() async {
    final receivePort = ReceivePort();
    await Isolate.spawn(_isolateEntry, receivePort.sendPort);

    _workerSendPort = await receivePort.first as SendPort;
  }

  static void _isolateEntry(SendPort mainSendPort) {
    final workerReceivePort = ReceivePort();
    mainSendPort.send(workerReceivePort.sendPort);

    workerReceivePort.listen((message) {
      final List<int> data = message[0];
      final SendPort replyPort = message[1];
      final processedResult = _heavyProcessing(data);
      replyPort.send(processedResult);
    });
  }

  Future<List<int>> processData(List<int> input) async {
    final responsePort = ReceivePort();
    _workerSendPort.send([input, responsePort.sendPort]);
    return await responsePort.first as List<int>;
  }

  static List<int> _heavyProcessing(List<int> data) {
    return data.map((value) => value * 2).toList();
  }
}

Common Pitfalls and Best Practices

  1. Don’t Share Mutable State: Isolates communicate by message passing. Send only serializable data.

  2. Watch Memory Usage: Each isolate has its own memory heap. Loading a large dataset in both isolates doubles memory consumption.

  3. Error Handling: Isolate errors won’t crash your main app by default, but they terminate the isolate. Use Isolate.addErrorListener or handle errors in the message handler.

  4. When Not to Use Isolates: For very short tasks (under ~50ms), the overhead of message passing might outweigh the benefit. Profile first. Tasks that are I/O-bound are already non-blocking with async/await.

Keep Your UI Responsive

  1. Identify CPU-heavy tasks in your app.
  2. Wrap them in a top-level or static function.
  3. Execute them with compute for one-off jobs, or set up a long-lived isolate for continuous work.
  4. Enjoy a smooth UI while other cores do the heavy lifting.

By offloading work to isolates, you transform your app from a stuttering single-tasker into a responsive experience.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Developers are curious about Flutter's capabilities beyond typical business apps, especially for demanding desktop applications like CAD/CAM or image/video processing. This post will explore Flutter's suitability for high-performance, viewport-based desktop GUIs, discussing Dart's memory model, the 60fps update loop, and real-world examples to gauge its readiness for 'serious' complex software.

Cover image for Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Flutter web applications often suffer from a frustrating 'deep link refresh bug' where refreshing the browser on a nested route (e.g., /home/details) bounces the user back to the root or an incorrect path. This post will diagnose the common causes of this issue, explain how Flutter's router handles web URLs, and provide practical solutions and best practices for building robust, refresh-proof navigation in your Flutter web apps.

Cover image for Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

As Flutter applications grow, managing strings for multiple languages or just keeping text consistent becomes a challenge. This post will guide developers through effective strategies for centralizing strings, implementing robust internationalization (i18n) and localization (l10n), and leveraging tools to streamline the process for small to large-scale projects.