In Flutter the main thread is capable of doing everything. Flutter runs all dart code in a single-threaded environment, meaning that all UI updates and logic execution happen in the main thread. But when you perform heavy computation task on the main thread it can block the UI, causing jank or lag. This results in a bad user experience because the app stops responding.

Isolates, on the other hand, are ideal for offloading heavy work to background threads, enabling concurrency and preventing UI freezes. Isolates in Dart runs in a completely separate memory space, allowing you to perform background tasks without affecting the main thread.

In this post, we'll explore three levels of examples:

  1. Easy Level: Using compute() for background processing.
  2. Medium Level: Using Isolate.spawn() with StreamController for real-time communication.
  3. Advanced Level: Handling multiple types of messages dynamically inside an isolate.

1. Using compute():

  • Without Isolate — Freezes UI
void main() {
  heavyComputation();
}


void heavyComputation() {
    int sum = 0;
    for (int i = 0; i < 1000000000; i++) {
      sum += i;
    }
    setState(() {
      result = "Computation Completed: $sum";
    });
}

Here the main thread getting blocked when heavyComputation() gets called and performing the sum of 1 billion numbers. If this runs inside a Flutter app, the UI will freeze completely.

  • Using compute() - No UI Freezing
void main() async {
  int result = await compute(heavyComputation, 1000000000);
  print("Computation Result: $result");
}

int heavyComputation(int value) {
  int sum = 0;
  for (int i = 0; i < value; i++) {
    sum += i;
  }
  return sum;
}

Here we are using compute() function from flutter/foundation.dart it is a top-level function provided by Flutter to run a function in a separate isolate. The compute function is called with two arguments:

  1. heavyComputation: The function to execute in a separate isolate. it should always be a top level function or a static method (not an instance method).
  2. 1000000000: The argument to pass to the heavyComputation function.

2. Using Isolate's spawn():

Why spawn when we have compute() which handels isolate creation to disposing when not in use? Compute is only good for one time background tasks e.g After calling an api use compute function to parse the received json. We can not achieve two-way communication. For example, a background process that keeps running and sends updates to the main thread, there Isolates spawn method comes into the picture.

  1. Main Thread:
  • Creates a StreamController to broadcast progress updates.
  • Spawns a new isolate to perform a heavy computation.
  • Listens for progress updates and completion messages from the spawned isolate.

2. Spawned Isolate:

  • Performs a heavy computation (summing numbers from 0 to 1000000000).
  • Sends progress updates to the main isolate at regular intervals.
  • Sends the final result to the main isolate when the computation is complete.
StreamController<double> progressStreamController = StreamController<double>();
bool isComputing = false;
Isolate? computationIsolate;



void main() async {
  progressStreamController.stream.listen(
    (progress) {
      print("Progress: ${progress.toStringAsFixed(2)}%");
    },
    onDone: () => print("Progress Stream Closed."),
  );
  await startComputation();
}



Future<void> startComputation() async {
  isComputing = true;
  print("Computation Started...");

  if (progressStreamController.isClosed) {
    progressStreamController = StreamController<double>();
  }

  ReceivePort receivePort = ReceivePort();
  computationIsolate = await Isolate.spawn(
    heavyComputation,
    receivePort.sendPort,
  );

  receivePort.listen((message) {
    if (message is double) {
      progressStreamController.sink.add(message);
    } else if (message is int) {
      print("Computation Completed: $message");
      isComputing = false;
      progressStreamController.sink.close();
    }
  });
}



void heavyComputation(SendPort sendPort) {
  int sum = 0;
  int totalIterations = 1000000000;
  int progressStep = totalIterations ~/ 100;

  for (int i = 0; i < totalIterations; i++) {
    sum += i;

    if (i % progressStep == 0) {
      sendPort.send((i / totalIterations) * 100);
    }
  }

  sendPort.send(sum);
}
  • Created progressStreamController of type StreamController<double> to handle progress updates.
  • The startComputation function spawns an isolate using Isolate.spawn(). The spawn function takes two param heavyComputation and SendPort . The send port we are sending into spwan method. Isolate will use this as porter to send the messages back to the main thread.
  • The function getting passed to spawn() method as entry point should always be a top level or a static method.

Key Points About Isolate.spawn:

  • Isolates do not share memory, so all communication must happen through message passing using SendPort and ReceivePort.
  • Use isolates for tasks like image processing, encryption, or large data calculations.
  • Use isolates to leverage multiple CPU cores for parallel execution.

3. Handling multiple message types with Isolate's spawn():

Problem with the spawn with the stream example is that it only sends progress updates one-way. What if we need two-way communication? What if we need to handle multiple types of messages dynamically? Here comes the Isolate's Dynamic response.

  1. Main Thread:
  • Creates a ReceivePort to listen for messages from the spawned isolate.
  • Spawns a new isolate using Isolate.spawn and passes the sendPort of the ReceivePort to it.
  • Listens for messages from the spawned isolate and processes them.

2. Spawned Isolate:

  • Creates its own ReceivePort to listen for messages from the main isolate.
  • Sends the sendPort of its ReceivePort back to the main isolate.
  • Listens for messages from the main isolate, processes them, and sends responses back.
void main() async {
  ReceivePort receivePort = ReceivePort();
  await Isolate.spawn(taskComputation, receivePort.sendPort);

  SendPort? isolateSendPort;

  receivePort.listen((message) {
    if (message is SendPort) {
      isolateSendPort = message;
      isolateSendPort?.send(100);
      isolateSendPort?.send("Hello");
      isolateSendPort?.send({"task": "compute", "value": 50});
    } else {
      print("Isolate Response: $message");
    }
  });
}



void taskComputation(SendPort mainSendPort) {
  ReceivePort isolateReceivePort = ReceivePort();
  mainSendPort.send(isolateReceivePort.sendPort);

  isolateReceivePort.listen((message) {
    if (message is int) {
      mainSendPort.send("Processed Integer: ${message * 2}");
    } else if (message is String) {
      mainSendPort.send("Processed String: $message");
    } else if (message is Map<String, dynamic>) {
      mainSendPort.send({"status": "Processed", "input": message});
    }
  });
}
  • Created a receivePort of type ReceivePort to receive messages from the spawned isolate.
  • Spawns a new isolate and runs the taskComputation function in it. await Isolate.spawn(taskComputation, receivePort.sendPort) . Passes the sendPort of the ReceivePort to the spawned isolate so it can send messages back.
  • Create isolateSendPort variable to store the SendPort of the spawned isolate once it is received.
  • receivePort.listen listens for messages from the spawned isolate.

How the communication works b/w isolates using ReceivePort & SendPort:

  1. Main thread spawns a new isolate and passes its sendPort to it.
  2. Spawned isolate creates its own ReceivePort and sends its sendPort back to the main isolate.
  3. Main thread receives the sendPort of the spawned isolate and starts sending messages to it.
  4. Spawned isolate processes the messages and sends responses back to the main isolate.
  5. Main thread receives the responses and prints them.

When you should use Isolates?

  • Large computations (e.g., JSON parsing, cryptography, image processing).
  • Heavy API responses that need extensive processing.
  • Keeping UI responsive during CPU-intensive tasks.

When you shouldn't use Isolates?

  • Simple tasks that don't block the UI.
  • Tasks that require direct access to UI elements (Isolates cannot modify widgets).

Git Repo: https://github.com/helloDevAman/Isolate-Medium

Conclusion

Dart Isolates provide a powerful way to handle parallel processing in Flutter. By offloading tasks to separate memory spaces, we can ensure a smooth user experience without UI freezes.

Now that you understand isolates, try using them in your next Flutter app for improved performance!