Dart is single-threaded — but that doesn’t mean it can’t run code in parallel. Dart uses Isolates to achieve true parallelism. Understanding how they work is essential for building Flutter apps that stay smooth and responsive even under heavy computation.
Dart’s Concurrency Model
In Dart, every program runs on a single event loop inside a single isolate. Async/await doesn’t run things in parallel — it just schedules work cooperatively so the UI thread isn’t blocked while waiting for I/O.
// This is NOT parallel — it just yields while waiting
Future<void> fetchData() async {
final result = await http.get(Uri.parse('https://api.example.com/data'));
print(result.body); // Runs on the SAME thread
}
For CPU-intensive work like JSON parsing, image manipulation, or cryptography, async/await isn’t enough. Those tasks run on the main thread and block the UI, causing dropped frames and janky animations.
That’s where Isolates come in.
What is an Isolate?
An Isolate is an independent worker with:
- Its own memory heap — no shared memory between isolates
- Its own event loop
- Communication via message passing (using
SendPortandReceivePort)
Because isolates don’t share memory, there’s no risk of race conditions or deadlocks. They’re safe by design.
Main Isolate Worker Isolate
─────────────── ────────────────
UI thread Heavy computation
Event loop Own event loop
Own heap Own heap
│ │
│ ←── SendPort/Port ────► │
│ messages │
Spawning a Basic Isolate
import 'dart:isolate';
void heavyTask(SendPort sendPort) {
// This runs in a completely separate isolate
int result = 0;
for (int i = 0; i < 100000000; i++) {
result += i;
}
sendPort.send(result); // Send the result back
}
Future<void> main() async {
final receivePort = ReceivePort();
// Spawn a new isolate, passing our ReceivePort's SendPort
await Isolate.spawn(heavyTask, receivePort.sendPort);
// Wait for the result
final result = await receivePort.first;
print('Result: $result'); // Result: 4999999950000000
}
Two-Way Communication
For more complex scenarios, you need bidirectional communication:
import 'dart:isolate';
// The worker function — runs in the spawned isolate
void workerIsolate(SendPort mainSendPort) {
final workerReceivePort = ReceivePort();
// Send our own SendPort to the main isolate so it can send us messages
mainSendPort.send(workerReceivePort.sendPort);
workerReceivePort.listen((message) {
if (message is int) {
// Process the number and send the result back
final result = _expensiveCompute(message);
mainSendPort.send(result);
} else if (message == 'close') {
workerReceivePort.close();
}
});
}
int _expensiveCompute(int n) {
// Simulate heavy work
return n * n;
}
Future<void> main() async {
final mainReceivePort = ReceivePort();
await Isolate.spawn(workerIsolate, mainReceivePort.sendPort);
// First message is the worker's SendPort
final workerSendPort = await mainReceivePort.first as SendPort;
final results = ReceivePort();
// Send a task to the worker
workerSendPort.send(42);
final result = await results.first;
print('42 squared = $result'); // 42 squared = 1764
// Clean up
workerSendPort.send('close');
mainReceivePort.close();
}
Isolate.run() — The Simple Way (Dart 2.19+)
For one-off computations, the Isolate.run() API is the simplest approach:
import 'dart:isolate';
String parseHeavyJson(String jsonString) {
// Simulate heavy JSON parsing
final parsed = json.decode(jsonString);
return parsed['result'] as String;
}
Future<void> main() async {
final jsonString = '{"result": "parsed value", "data": [...]}';
// Runs on a new isolate, returns the value directly
final result = await Isolate.run(() => parseHeavyJson(jsonString));
print(result); // "parsed value"
}
Isolate.run() automatically:
- Spawns an isolate
- Sends the closure and arguments
- Returns the result
- Kills the isolate when done
Flutter’s compute() Function
Flutter provides a convenience wrapper called compute() that’s designed for use in Flutter apps:
import 'package:flutter/foundation.dart';
// Must be a top-level function (not a closure or method)
List<Photo> parsePhotos(String responseBody) {
final parsed = jsonDecode(responseBody) as List;
return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}
Future<List<Photo>> fetchPhotos(http.Client client) async {
final response = await client.get(Uri.parse('https://jsonplaceholder.typicode.com/photos'));
// Run parsePhotos on a separate isolate to avoid janking the UI
return compute(parsePhotos, response.body);
}
compute() is essentially a simplified Isolate.run() — it’s great for Flutter but Isolate.run() is preferred in pure Dart code.
Isolate Pools for Repeated Work
Spawning an isolate has overhead. For repeated heavy tasks, maintain a pool:
import 'dart:isolate';
import 'dart:async';
class IsolatePool {
final int size;
final List<_WorkerState> _workers = [];
int _nextWorker = 0;
IsolatePool(this.size);
Future<void> initialize(void Function(SendPort) workerEntry) async {
for (int i = 0; i < size; i++) {
final receivePort = ReceivePort();
final isolate = await Isolate.spawn(workerEntry, receivePort.sendPort);
final sendPort = await receivePort.first as SendPort;
_workers.add(_WorkerState(isolate, sendPort, receivePort));
}
}
Future<T> run<T>(dynamic task) {
// Round-robin load distribution
final worker = _workers[_nextWorker % size];
_nextWorker++;
final completer = Completer<T>();
final replyPort = ReceivePort();
worker.sendPort.send([task, replyPort.sendPort]);
replyPort.first.then((result) {
replyPort.close();
completer.complete(result as T);
});
return completer.future;
}
void dispose() {
for (final worker in _workers) {
worker.isolate.kill();
worker.receivePort.close();
}
}
}
class _WorkerState {
final Isolate isolate;
final SendPort sendPort;
final ReceivePort receivePort;
_WorkerState(this.isolate, this.sendPort, this.receivePort);
}
What Can Be Passed Between Isolates?
Isolates don’t share memory, so only transferable objects can be sent via ports:
| ✅ Can Send | ❌ Cannot Send |
|---|---|
Primitives (int, double, bool, String) | Closures / anonymous functions |
List, Map, Set of transferable objects | Socket, File, Stream handles |
Uint8List, ByteBuffer | Native objects with finalizers |
| Plain Dart classes (deeply copied) | BuildContext (Flutter) |
Note: Objects are deep-copied when sent between isolates. For very large data, use TransferableTypedData to avoid copying:
import 'dart:isolate';
import 'dart:typed_data';
// Transfer without copying (zero-copy)
final data = Uint8List.fromList([1, 2, 3, 4, 5]);
final transferable = TransferableTypedData.fromList([data]);
// Send the transferable — no copy made
sendPort.send(transferable);
// On the receiving end
receivePort.listen((msg) {
final received = (msg as TransferableTypedData).materialize().asUint8List();
});
When to Use What
| Scenario | Solution |
|---|---|
| I/O operations (network, file) | async/await — no isolate needed |
| Simple one-off heavy computation | Isolate.run() or compute() |
| Repeated heavy work | Isolate pool |
| Background service / long-running | Isolate.spawn() with message loop |
| Flutter list parsing from API | compute() |
Practical Example: Image Processing in Flutter
import 'dart:isolate';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
// Top-level function required for compute()
Uint8List applyGrayscaleFilter(Uint8List imageBytes) {
// Convert RGBA pixels to grayscale
final result = Uint8List(imageBytes.length);
for (int i = 0; i < imageBytes.length; i += 4) {
final r = imageBytes[i];
final g = imageBytes[i + 1];
final b = imageBytes[i + 2];
final a = imageBytes[i + 3];
// Luminosity formula
final gray = (0.299 * r + 0.587 * g + 0.114 * b).round();
result[i] = gray;
result[i + 1] = gray;
result[i + 2] = gray;
result[i + 3] = a;
}
return result;
}
class ImageProcessor {
Future<Uint8List> processImage(Uint8List bytes) async {
// This runs on a separate isolate — UI stays smooth
return await compute(applyGrayscaleFilter, bytes);
}
}
Common Pitfalls
1. Passing closures to isolates
// ❌ This will throw — closures can't be sent to isolates
final multiplier = 3;
await Isolate.spawn((SendPort port) {
port.send(value * multiplier); // Error!
}, receivePort.sendPort);
// ✅ Use Isolate.run() which handles closures correctly
final result = await Isolate.run(() => value * multiplier);
2. Forgetting to close ports
// ❌ Memory leak — ReceivePort never closed
final port = ReceivePort();
await Isolate.spawn(worker, port.sendPort);
final result = await port.first;
// Port still open!
// ✅ Use .first which auto-closes, or close manually
port.close();
3. Not handling isolate errors
// ✅ Handle errors from spawned isolate
final errorPort = ReceivePort();
await Isolate.spawn(
heavyWork,
receivePort.sendPort,
onError: errorPort.sendPort,
);
errorPort.listen((error) {
print('Isolate error: $error');
});
Conclusion
Dart Isolates are the key to true parallelism. For most Flutter use cases, reach for compute() or Isolate.run() when you need to offload heavy work. Use the lower-level Isolate.spawn() when you need persistent workers or bidirectional communication.
The rule of thumb: if a piece of code takes more than a few milliseconds, it probably belongs in an isolate. Your users will thank you for it in the form of a buttery-smooth 60fps UI.
💬 Want to learn, build, and grow with a community of developers? Join the King Technologies Discord — where code meets community!