Dart Isolates — Parallel Execution and Concurrency
In this tutorial, you will learn about Dart Isolates. We cover key concepts, practical examples, and best practices to help you master this topic.
Dart isolates are independent workers that run code in parallel with their own memory heap and event loop, communicating with the main isolate through message passing for CPU-intensive operations.
What You Will Learn
- Understanding isolates vs threads
- Creating isolates with
Isolate.spawn - Passing messages between isolates
- Using
ReceivePortandSendPort - Computing heavy operations with
Isolate.run - Flutter's
computefunction - Isolate lifecycle and cleanup
Why It Matters
Dart's event loop handles I/O operations efficiently, but CPU-intensive work (image processing, JSON Parsing of large files, data compression, cryptographic operations) blocks the event loop and makes the application unresponsive. Isolates solve this by running code on separate CPU cores. Unlike threads in other languages, isolates do not share memory, which eliminates race conditions and the need for locks. Each isolate has its own heap and communicates only through messages.
Real-World Use
The DodaTech Flutter app uses isolates for image processing. When a user selects a photo for their profile, the image is resized, compressed, and converted to WebP format in a separate isolate. This prevents UI jank during the transformation. The main app remains responsive, showing a progress indicator while the isolate works.
Learning Path
flowchart LR A[Dart Streams] --> B[Dart Isolates\nYou are here] B --> C[Dart Extensions] style B fill:#f90,color:#fff
Isolates vs Threads
Traditional threading shares memory between threads, requiring locks and synchronization. Isolates do not share memory:
import 'dart:isolate';
void main() async {
print('Main isolate started');
// Spawn a new isolate
var receivePort = ReceivePort();
await Isolate.spawn(workerFunction, receivePort.sendPort);
// Receive messages from the worker
receivePort.listen((message) {
print('Main received: $message');
});
print('Main isolate continues...');
}
void workerFunction(SendPort sendPort) {
print('Worker isolate started');
sendPort.send('Hello from worker!');
sendPort.send('Isolates do not share memory');
}
Output:
Main isolate started
Main isolate continues...
Worker isolate started
Main received: Hello from worker!
Main received: Isolates do not share memory
The main isolate spawns a worker and continues executing. The worker runs in parallel on a separate CPU core. Communication happens through SendPort and ReceivePort.
Sending and Receiving Messages
Messages are copied between isolates. Complex objects must be sendable (primitives, lists, maps, and port references):
import 'dart:isolate';
// A simple sendable data class
class WorkRequest {
final int id;
final String data;
WorkRequest(this.id, this.data);
}
class WorkResult {
final int id;
final int length;
WorkResult(this.id, this.length);
}
void main() async {
var receivePort = ReceivePort();
var isolate = await Isolate.spawn(processData, receivePort.sendPort);
// Send work
receivePort.listen((message) {
if (message is WorkResult) {
print('Result for request ${message.id}: length = ${message.length}');
} else if (message is SendPort) {
// Send work to the worker
message.send(WorkRequest(1, 'Hello, Dart Isolate!'));
message.send(WorkRequest(2, 'Parallel processing is powerful'));
}
});
}
void processData(SendPort sendPort) {
var receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
receivePort.listen((message) {
if (message is WorkRequest) {
print('Worker processing request ${message.id}');
var result = WorkResult(message.id, message.data.length);
sendPort.send(result);
}
});
}
Output:
Worker processing request 1
Result for request 1: length = 21
Worker processing request 2
Result for request 2: length = 32
The two-way communication pattern uses ports to establish a bidirectional channel. The main isolate sends WorkRequest objects, and the worker sends WorkResult objects back.
Using Isolate.run
Dart 2.19 introduced Isolate.run, which simplifies the common pattern of running a function in an isolate:
import 'dart:isolate';
// CPU-intensive function
String processLargeString(String input) {
// Simulate heavy processing
var result = input
.split('')
.map((c) => c.toUpperCase())
.join('');
return result;
}
Future<void> main() async {
print('Processing on main isolate...');
var data = 'hello world from dart isolates';
// Run in a separate isolate
var result = await Isolate.run(() => processLargeString(data));
print('Result: $result');
print('Main isolate was not blocked!');
}
Output:
Processing on main isolate...
Result: HELLO WORLD FROM DART ISOLATES
Main isolate was not blocked!
Isolate.run spawns an isolate, runs the function, captures the return value, and closes the isolate automatically. This is the simplest way to offload heavy work.
Flutter's compute Function
Flutter provides a compute function that works like Isolate.run but is integrated with Flutter's lifecycle:
import 'package:flutter/foundation.dart';
// Top-level function required by compute
int calculateSum(List<int> numbers) {
// CPU-intensive calculation
return numbers.fold(0, (a, b) => a + b);
}
class DataProcessor {
Future<int> processData(List<int> data) async {
// Offload to background isolate
return await compute(calculateSum, data);
}
}
// In a widget:
// var processor = DataProcessor();
// var sum = await processor.processData([1, 2, 3, 4, 5]);
// print('Sum: $sum');
Output: Sum: 15
The function passed to compute must be a top-level function or a static method. It takes one argument and returns a value. The result is delivered as a Future.
Sending Multiple Arguments
To pass multiple values, use a record or a list:
import 'dart:isolate';
// Using records for multiple arguments
String multiplyAndFormat((int, int) args) {
var (a, b) = args;
var result = a * b;
return '$a x $b = $result';
}
Future<void> main() async {
var result = await Isolate.run(() => multiplyAndFormat((7, 8)));
print(result);
// Using a list
var listResult = await Isolate.run(() {
var numbers = [10, 20, 30];
return numbers.map((n) => n * 2).toList();
});
print('Doubled: $listResult');
}
Output:
7 x 8 = 56
Doubled: [20, 40, 60]
Records or simple collections work well for multiple arguments. Avoid passing large objects to isolates because they must be copied.
Handling Errors in Isolates
Errors in isolates are delivered as error events on the receive port:
import 'dart:isolate';
void failingWorker(SendPort sendPort) {
throw Exception('Something went wrong in the worker');
}
Future<void> main() async {
var receivePort = ReceivePort();
var isolate = await Isolate.spawn(failingWorker, receivePort.sendPort);
receivePort.listen(
(message) => print('Received: $message'),
onError: (error) => print('Error from isolate: $error'),
onDone: () => print('Isolate closed'),
);
await Future.delayed(Duration(seconds: 1));
}
Output:
Error from isolate: Exception: Something went wrong in the worker
Isolate closed
Uncaught exceptions in isolates terminate the isolate and send an error event. Always handle errors in isolate listeners.
Isolate Lifecycle
Isolates should be properly terminated to free resources:
import 'dart:isolate';
void main() async {
var receivePort = ReceivePort();
var isolate = await Isolate.spawn(
(SendPort sendPort) {
var counter = 0;
Timer.periodic(Duration(seconds: 1), (timer) {
counter++;
sendPort.send('Tick $counter');
if (counter >= 5) {
timer.cancel();
sendPort.send('Done');
}
});
},
receivePort.sendPort,
);
await for (var message in receivePort) {
print('Main: $message');
if (message == 'Done') {
isolate.kill(priority: Isolate.immediate);
print('Isolate killed');
}
}
}
Output:
Main: Tick 1
Main: Tick 2
Main: Tick 3
Main: Tick 4
Main: Tick 5
Main: Done
Isolate killed
Use isolate.kill() to terminate an isolate early. The priority Isolate.immediate kills it immediately. Isolate.beforeNextEvent lets pending events complete first.
Performance Considerations
Isolates are not free. Spawning an isolate has overhead:
import 'dart:isolate';
void main() async {
// Measure isolate spawn overhead
var start = DateTime.now();
for (var i = 0; i < 10; i++) {
await Isolate.run(() => i * i);
}
print('10 isolates: ${DateTime.now().difference(start).inMilliseconds}ms');
// Compare to synchronous execution
start = DateTime.now();
for (var i = 0; i < 10; i++) {
i * i;
}
print('10 synchronous: ${DateTime.now().difference(start).inMicroseconds}us');
}
Output:
10 isolates: 45ms
10 synchronous: 2us
Use isolates only for operations that take more than ~20ms. For short operations, the cost of spawning an isolate outweighs the benefit.
Comparing Approaches
import 'dart:isolate';
import 'dart:math';
void main() async {
// Generate large data
var largeList = List.generate(10_000_000, (i) => i);
// Synchronous: blocks
var start = DateTime.now();
var maxValue = largeList.reduce(max);
print('Sync: max = $maxValue, took ${DateTime.now().difference(start).inMilliseconds}ms');
// With isolate: non-blocking
start = DateTime.now();
var maxAsync = await Isolate.run(() => largeList.reduce(max));
print('Async: max = $maxAsync, took ${DateTime.now().difference(start).inMilliseconds}ms');
}
Output:
Sync: max = 9999999, took 45ms
Async: max = 9999999, took 50ms
The synchronous version blocks the main isolate for 45ms. The async version also takes 50ms but runs in a background isolate, leaving the main isolate free to Process UI events and other tasks.
Common Mistakes
Passing non-sendable objects between isolates: Objects must be primitive types, lists, maps, or port references. Custom objects require manual Serialization (e.g., to JSON).
Accessing shared state across isolates: Isolates do not share memory. Every message is copied. Trying to access a global variable across isolates will not work as expected.
Spawning an isolate for trivial work: The overhead of creating an isolate (1-5ms) may exceed the computation time. Use isolates only for operations over ~20ms.
Not terminating isolates: Unreferenced isolates continue running until their main function completes. Kill unused isolates or let them terminate naturally.
Using
Isolate.spawnwhenIsolate.runsuffices:Isolate.runis simpler and automatically handles cleanup. UseIsolate.spawnonly for advanced patterns like persistent workers.
Practice Questions
- How do isolates differ from threads in terms of memory sharing?
- What types of objects can be passed between isolates?
- When would you use
Isolate.spawninstead ofIsolate.run? - How does Flutter's
computefunction relate to Dart isolates? - Challenge: Implement a parallel prime number sieve. Create a worker isolate that finds all primes up to N. Use
Isolate.runto perform the calculation and return the result as a list of integers.
Mini Project
Build a parallel image processing system:
- Generate a list of 100 random integers representing image processing tasks
- Divide the list into chunks based on available CPU cores
- Spawn an isolate for each chunk
- Each isolate processes its chunk (simulate with
Future.delayed) - Collect and merge results from all isolates
- Compare total time with single-isolate processing
FAQ
What is Next
Now that you understand isolates, learn about extensions for adding functionality. Proceed to Dart Extensions for extending existing types with new methods and properties. Then explore Records and Patterns in Dart for destructuring and pattern matching.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro