← 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's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter is gaining traction as a robust framework for Linux desktop development, offering a smoother experience compared to traditional toolkits like GTK and Qt. This post will explore why Flutter excels for Linux apps, highlight its advantages, and provide practical insights for developers looking to leverage Flutter for their desktop projects.

Cover image for Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Deploying Flutter desktop apps on Windows can be challenging, especially regarding code signing and preventing Windows from blocking installations. This post will guide developers through the process of signing their Flutter desktop applications, exploring alternatives to the Microsoft Store like creating executable installers, and covering the necessary steps to ensure a smooth user experience.

Cover image for Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Developers frequently voice frustrations about Flutter SDK version management and installation processes, particularly on Windows. This post will explore best practices for managing multiple Flutter versions using tools like FVM, discuss integrating Flutter with package managers like Winget for a smoother Windows setup, and offer strategies to ensure a consistent development environment across projects and teams.