Isolates and concurrency in Flutter
By John · 1 January 2026
Dart isolates are the mechanism for true parallelism. By default, all Dart code runs on a single thread — the UI thread. Isolates run Dart code in separate threads with separate memory, enabling CPU-bound work without blocking the UI.
When to use isolates
Isolates add complexity and communication overhead. Only use them when:
- You have CPU-bound work that takes > 16ms (causes frame drops)
- Parsing large JSON responses (1MB+)
- Image processing or compression
- Cryptography
- Complex calculations (pathfinding, simulations)
For I/O-bound work (network requests, file reads), async/await is sufficient — the event loop handles concurrency without isolates.
compute() — the easy way
compute() runs a top-level function in a new isolate and returns the result:
// Must be a top-level function (not a method or closure)
List<Order> _parseOrders(String jsonString) {
final list = jsonDecode(jsonString) as List;
return list.map((e) => Order.fromJson(e as Map<String, dynamic>)).toList();
}
// In your repository:
Future<List<Order>> getOrders() async {
final response = await dio.get('/orders');
// Parse JSON off the main thread
return compute(_parseOrders, response.data as String);
}
Limitations:
- The function must be top-level or static
- Arguments and return values must be sendable (primitives, lists, maps, typed data)
- Each
compute()spawns a new isolate — expensive for many small tasks
Isolate.run() — Dart 2.19+
A cleaner alternative that accepts closures:
Future<ProcessedImage> processImage(Uint8List bytes) async {
return Isolate.run(() {
// This closure runs in a new isolate
return expensiveImageProcessing(bytes);
});
}
Long-lived isolates
For repeated work, spawning a new isolate each time is expensive. Use a persistent isolate with ReceivePort/SendPort:
class DatabaseIsolate {
late SendPort _sendPort;
final _completerMap = <int, Completer>{};
int _nextId = 0;
Future<void> init() async {
final receivePort = ReceivePort();
await Isolate.spawn(_isolateEntry, receivePort.sendPort);
// Get the isolate's send port
_sendPort = await receivePort.first as SendPort;
// Listen for responses
receivePort.listen((message) {
final response = message as Map;
final id = response['id'] as int;
final result = response['result'];
_completerMap.remove(id)?.complete(result);
});
}
Future<T> send<T>(String operation, dynamic data) async {
final id = _nextId++;
final completer = Completer<T>();
_completerMap[id] = completer;
_sendPort.send({'id': id, 'operation': operation, 'data': data});
return completer.future;
}
}
void _isolateEntry(SendPort mainPort) {
final receivePort = ReceivePort();
mainPort.send(receivePort.sendPort);
receivePort.listen((message) {
final msg = message as Map;
final id = msg['id'] as int;
final operation = msg['operation'] as String;
final data = msg['data'];
Object? result;
if (operation == 'parse') {
result = _parseData(data as String);
}
mainPort.send({'id': id, 'result': result});
});
}
Sendable types
Not all objects can be sent between isolates. Sendable types:
- Primitives:
int,double,bool,String,null - Collections of sendable types:
List,Map,Set Uint8Listand other typed dataSendPort
Not sendable:
- Closures
- Custom class instances (unless they only contain sendable fields)
- Futures
- Streams
For complex objects, serialize to JSON first:
// Main isolate: serialize
final json = jsonEncode(order.toJson());
final result = await compute(_processOrder, json);
final processedOrder = Order.fromJson(jsonDecode(result) as Map<String, dynamic>);
// Worker:
String _processOrder(String orderJson) {
final order = Order.fromJson(jsonDecode(orderJson) as Map<String, dynamic>);
// process...
return jsonEncode(order.toJson());
}
Drift and isolates
Drift has first-class isolate support:
// Opens the database in a background isolate
final database = AppDatabase(
NativeDatabase.createInBackground(File(path)),
);
// All queries automatically run on the background isolate
final orders = await database.getAllOrders();
Common pitfalls
Using closures with compute(). compute() requires top-level or static functions. A closure that captures variables from the enclosing scope won't work and throws at runtime.
Spawning isolates for small tasks. Isolate creation takes ~1-2ms. If your task takes 1ms, you've doubled its time. Use isolates for tasks > 10-20ms.
Sending large objects repeatedly. Sending a 10MB list between isolates copies all the data. For very large data, use TransferableTypedData which transfers ownership without copying.
Sign in to like, dislike, or report.