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:
- Easy Level: Using
compute()for background processing. - Medium Level: Using
Isolate.spawn()withStreamControllerfor real-time communication. - 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:
- 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).
- 1000000000: The argument to pass to the
heavyComputationfunction.
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.
- Main Thread:
- Creates a
StreamControllerto 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
0to1000000000). - 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
progressStreamControllerof type StreamController<double> to handle progress updates. - The
startComputationfunction spawns an isolate usingIsolate.spawn(). The spawn function takes two paramheavyComputationandSendPort. 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.
- Main Thread:
- Creates a
ReceivePortto listen for messages from the spawned isolate. - Spawns a new isolate using
Isolate.spawnand passes thesendPortof theReceivePortto it. - Listens for messages from the spawned isolate and processes them.
2. Spawned Isolate:
- Creates its own
ReceivePortto listen for messages from the main isolate. - Sends the
sendPortof itsReceivePortback 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 r
eceivePortof type ReceivePort to receive messages from the spawned isolate. - Spawns a new isolate and runs the
taskComputationfunction in it.await Isolate.spawn(taskComputation, receivePort.sendPort). Passes thesendPortof theReceivePortto the spawned isolate so it can send messages back. - Create
isolateSendPortvariable to store theSendPortof the spawned isolate once it is received. receivePort.listenlistens for messages from the spawned isolate.
How the communication works b/w isolates using ReceivePort & SendPort:
- Main thread spawns a new isolate and passes its
sendPortto it. - Spawned isolate creates its own
ReceivePortand sends itssendPortback to the main isolate. - Main thread receives the
sendPortof the spawned isolate and starts sending messages to it. - Spawned isolate processes the messages and sends responses back to the main isolate.
- 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!