Isolates & Concurrency
Understanding Dart’s Single-Threaded Model
Unlike languages such as Java or C++ that rely on shared-memory threads, Dart is fundamentally single-threaded. Your entire application runs on a single thread called the main isolate. This design eliminates an entire category of bugs -- race conditions, deadlocks, and data corruption from concurrent memory access -- but it also means that CPU-intensive work can block the event loop and freeze your UI.
The Dart event loop processes events one at a time: user taps, network responses, timer callbacks, and Future completions all queue up and execute sequentially. async/await lets you write non-blocking I/O code, but it does not run code in parallel. An await merely yields control back to the event loop while waiting for an external result (like a network response). If you have a function that spends 500ms crunching numbers, async/await will not help -- the main thread is still blocked for 500ms.
The Event Loop in Action
void main() async {
print('1. Start');
// This Future is scheduled on the event loop, not run in parallel
Future.delayed(Duration(seconds: 1), () {
print('3. Timer fired (after 1 second)');
});
// This blocks the main thread for ~2 seconds
// async/await does NOT help here -- it is pure CPU work
final result = heavyComputation();
print('2. Heavy computation done: $result');
}
int heavyComputation() {
int sum = 0;
for (int i = 0; i < 1000000000; i++) {
sum += i;
}
return sum;
}
What Are Isolates?
An isolate is Dart’s unit of concurrency. Each isolate has its own memory heap, its own event loop, and runs independently of all other isolates. The name "isolate" comes from the fact that memory is isolated -- no isolate can directly access another isolate’s variables, objects, or state. Communication happens exclusively through message passing.
Think of isolates like separate workers in different rooms, each with their own desk and supplies. They cannot reach over to each other’s desks, but they can pass notes (messages) through a mail slot (ports). This architecture guarantees memory safety without locks or mutexes.
main(), you are running inside the main isolate. You can spawn additional isolates to perform work in true parallel, on separate CPU cores.Isolate.run() -- The Simple API (Dart 2.19+)
Dart 2.19 introduced Isolate.run(), a high-level convenience method that handles all the boilerplate of spawning an isolate, sending data, receiving results, and cleaning up. This is the recommended way to offload CPU-intensive work.
Basic Isolate.run() Usage
import 'dart:isolate';
Future<void> main() async {
print('Main isolate: starting heavy work on background isolate');
// Isolate.run takes a top-level or static function
// and returns a Future with the result
final result = await Isolate.run(() {
// This runs in a separate isolate!
int sum = 0;
for (int i = 0; i < 1000000000; i++) {
sum += i;
}
return sum;
});
print('Main isolate: result = $result');
// The background isolate is automatically terminated
}
You can also pass data to the isolate function. The function must be a top-level function or a static method -- it cannot be an instance method or a closure that captures mutable state.
Passing Data to Isolate.run()
import 'dart:isolate';
import 'dart:convert';
// Top-level function -- required for isolates
List<Map<String, dynamic>> parseJsonList(String jsonString) {
final List<dynamic> decoded = jsonDecode(jsonString);
return decoded
.map((item) => Map<String, dynamic>.from(item as Map))
.toList();
}
Future<void> main() async {
// Simulate a large JSON string
final largeJson = List.generate(
100000,
(i) => '{"id": $i, "name": "Item $i", "value": ${i * 1.5}}',
).join(',');
final jsonString = '[$largeJson]';
print('Parsing ${jsonString.length} characters of JSON...');
// Parse in a background isolate to keep UI responsive
final parsed = await Isolate.run(() => parseJsonList(jsonString));
print('Parsed ${parsed.length} items');
print('First item: ${parsed.first}');
}
RawReceivePort) are not sendable. Since Dart 2.15, classes can implement the SendPort message protocol, and since Dart 3.x, you can use TransferableTypedData for efficient large data transfers.Isolate.spawn() -- The Low-Level API
Isolate.spawn() gives you more control over the isolate lifecycle. You manage ports, communication, and cleanup yourself. This is useful when you need ongoing communication with the isolate (like a worker pool) rather than a single request-response.
Using Isolate.spawn() with Ports
import 'dart:isolate';
// The entry point for the spawned isolate
// Must be a top-level function that takes a single argument
void workerEntryPoint(SendPort mainSendPort) {
// Create a port to receive messages from main
final workerReceivePort = ReceivePort();
// Send our receive port back to main so it can talk to us
mainSendPort.send(workerReceivePort.sendPort);
// Listen for messages from main
workerReceivePort.listen((message) {
if (message is int) {
// Perform expensive computation
int result = 0;
for (int i = 1; i <= message; i++) {
result += i * i;
}
// Send result back to main
mainSendPort.send(result);
} else if (message == 'close') {
workerReceivePort.close();
}
});
}
Future<void> main() async {
// Create a port to receive messages from the worker
final mainReceivePort = ReceivePort();
// Spawn the isolate
final isolate = await Isolate.spawn(
workerEntryPoint,
mainReceivePort.sendPort,
);
// Wait for the worker to send us its SendPort
final workerSendPort = await mainReceivePort.first as SendPort;
// Now set up a new receive port for ongoing communication
final responsePort = ReceivePort();
// We need to send our new port to the worker...
// Actually, let’s simplify with a complete bidirectional example:
print('Sending work to isolate...');
// For ongoing communication, use a stream
final stream = mainReceivePort.asBroadcastStream();
// Get the worker’s send port (first message)
final SendPort sendPort = await stream.first as SendPort;
// Send a number to compute
sendPort.send(1000000);
// Wait for the result
final result = await stream.first;
print('Sum of squares up to 1000000: $result');
// Clean up
sendPort.send('close');
mainReceivePort.close();
isolate.kill(priority: Isolate.immediate);
}
SendPort & ReceivePort Explained
Ports are the communication mechanism between isolates. They work like one-way message channels:
ReceivePort -- A port that listens for incoming messages. It implements Stream, so you can use listen(), first, forEach(), and other stream methods. Each ReceivePort has a corresponding SendPort.
SendPort -- A lightweight reference that can send messages to its corresponding ReceivePort. Unlike ReceivePort, a SendPort can be sent to other isolates (it is sendable). This is how isolates share communication channels.
Port Communication Pattern
import 'dart:isolate';
Future<void> main() async {
// Step 1: Main creates a ReceivePort
final mainReceivePort = ReceivePort();
// Step 2: Main spawns isolate, passing its SendPort
await Isolate.spawn(
workerFunction,
mainReceivePort.sendPort,
);
// Step 4: Main receives the worker’s SendPort
final workerSendPort = await mainReceivePort.first as SendPort;
// Now main can send messages to the worker
// And the worker can send messages to main
}
void workerFunction(SendPort mainSendPort) {
// Step 3: Worker creates its own ReceivePort
final workerReceivePort = ReceivePort();
// Worker sends its SendPort to main
mainSendPort.send(workerReceivePort.sendPort);
// Worker listens for messages from main
workerReceivePort.listen((message) {
print('Worker received: $message');
// Process and send response
mainSendPort.send('Processed: $message');
});
}
The compute() Function (Flutter)
If you are working in Flutter, the compute() function (from package:flutter/foundation.dart) provides an even simpler API than Isolate.run(). It was available before Isolate.run() existed and remains widely used in Flutter apps.
Flutter’s compute() Function
import 'package:flutter/foundation.dart';
// Must be a top-level function
int fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// Must be a top-level function with a single argument
Map<String, dynamic> processData(Map<String, dynamic> input) {
final n = input['n'] as int;
final result = fibonacci(n);
return {'input': n, 'result': result};
}
// In a Flutter widget:
Future<void> calculateFibonacci() async {
// compute() runs processData in a separate isolate
final result = await compute(
processData,
{'n': 40},
);
print('Fibonacci(${result['input']}) = ${result['result']}');
}
Isolate.run() is essentially the same as Flutter’s compute() but available in pure Dart (without the Flutter SDK). If you are writing a Flutter app, both work fine. For pure Dart projects, use Isolate.run().When to Use Isolates vs async/await
Choosing between async/await and isolates is a critical architectural decision. Here is a clear guide:
Use async/await when:
- Waiting for network requests (HTTP, WebSocket)
- Reading/writing files (I/O-bound operations)
- Waiting for user input or timer events
- Any operation where the work is done by the OS or an external service
Use isolates when:
- Parsing large JSON files (> 1MB)
- Image processing (resize, filter, compress)
- Complex mathematical computations
- Data encryption/decryption
- Search/sort on large datasets (> 10,000 items)
- Any computation that takes more than ~16ms (one frame at 60 FPS)
Comparison: async/await vs Isolate
import 'dart:isolate';
import 'dart:io';
// CORRECT: Use async/await for I/O
Future<String> fetchData() async {
// The OS handles the network request; Dart thread is free
final client = HttpClient();
final request = await client.getUrl(Uri.parse('https://api.example.com/data'));
final response = await request.close();
return await response.transform(utf8.decoder).join();
}
// CORRECT: Use isolate for CPU-heavy parsing
Future<List<Map<String, dynamic>>> fetchAndParse() async {
// Step 1: Fetch data (I/O -- use async/await)
final jsonString = await fetchData();
// Step 2: Parse data (CPU -- use isolate)
final parsed = await Isolate.run(
() => parseJsonList(jsonString),
);
return parsed;
}
// WRONG: Using isolate for I/O (unnecessary overhead)
// WRONG: Using async/await for heavy computation (blocks event loop)
Stopwatch to profile your code if you are unsure.Practical Example: Image Processing Pipeline
Here is a realistic example of using isolates for CPU-intensive image processing. This pattern is common in Flutter apps that handle photos.
Image Processing with Isolates
import 'dart:isolate';
import 'dart:typed_data';
// Represents pixel data for an image
class ImageData {
final int width;
final int height;
final Uint8List pixels; // RGBA bytes
ImageData(this.width, this.height, this.pixels);
}
// Top-level function: apply grayscale filter
Uint8List applyGrayscale(Uint8List pixels) {
final result = Uint8List(pixels.length);
for (int i = 0; i < pixels.length; i += 4) {
final r = pixels[i];
final g = pixels[i + 1];
final b = pixels[i + 2];
final a = pixels[i + 3];
// Luminance formula (perceptual weighting)
final gray = (0.299 * r + 0.587 * g + 0.114 * b).round();
result[i] = gray; // R
result[i + 1] = gray; // G
result[i + 2] = gray; // B
result[i + 3] = a; // A (unchanged)
}
return result;
}
// Top-level function: apply brightness adjustment
Uint8List adjustBrightness(List<dynamic> args) {
final pixels = args[0] as Uint8List;
final factor = args[1] as double; // -1.0 to 1.0
final result = Uint8List(pixels.length);
final adjustment = (factor * 255).round();
for (int i = 0; i < pixels.length; i += 4) {
result[i] = (pixels[i] + adjustment).clamp(0, 255);
result[i + 1] = (pixels[i + 1] + adjustment).clamp(0, 255);
result[i + 2] = (pixels[i + 2] + adjustment).clamp(0, 255);
result[i + 3] = pixels[i + 3]; // Alpha unchanged
}
return result;
}
Future<void> main() async {
// Simulate a 1920x1080 image (about 8MB of pixel data)
final width = 1920;
final height = 1080;
final pixels = Uint8List(width * height * 4);
// Fill with sample data
for (int i = 0; i < pixels.length; i += 4) {
pixels[i] = 128; // R
pixels[i + 1] = 64; // G
pixels[i + 2] = 192; // B
pixels[i + 3] = 255; // A
}
print('Processing ${width}x${height} image...');
// Run grayscale filter on a background isolate
final grayscaled = await Isolate.run(
() => applyGrayscale(pixels),
);
// Run brightness adjustment on a background isolate
final brightened = await Isolate.run(
() => adjustBrightness([grayscaled, 0.2]),
);
print('Done! Processed ${brightened.length} bytes');
print('Sample pixel: R=${brightened[0]} G=${brightened[1]} B=${brightened[2]}');
}
Practical Example: Parallel Computation
You can spawn multiple isolates to run computations in parallel, taking advantage of multiple CPU cores.
Running Multiple Isolates in Parallel
import 'dart:isolate';
// Top-level function: check if a number is prime
bool isPrime(int n) {
if (n < 2) return false;
if (n < 4) return true;
if (n % 2 == 0 || n % 3 == 0) return false;
for (int i = 5; i * i <= n; i += 6) {
if (n % i == 0 || n % (i + 2) == 0) return false;
}
return true;
}
// Top-level function: count primes in a range
int countPrimesInRange(List<int> range) {
final start = range[0];
final end = range[1];
int count = 0;
for (int i = start; i <= end; i++) {
if (isPrime(i)) count++;
}
return count;
}
Future<void> main() async {
const maxNumber = 10000000;
const numIsolates = 4;
const rangeSize = maxNumber ~/ numIsolates;
print('Counting primes up to $maxNumber using $numIsolates isolates...');
final stopwatch = Stopwatch()..start();
// Create ranges for each isolate
final futures = <Future<int>>[];
for (int i = 0; i < numIsolates; i++) {
final start = i * rangeSize + 1;
final end = (i == numIsolates - 1) ? maxNumber : (i + 1) * rangeSize;
futures.add(Isolate.run(() => countPrimesInRange([start, end])));
}
// Wait for all isolates to complete
final results = await Future.wait(futures);
final totalPrimes = results.reduce((a, b) => a + b);
stopwatch.stop();
print('Found $totalPrimes primes in ${stopwatch.elapsedMilliseconds}ms');
}
Platform.numberOfProcessors isolates for CPU-bound work. Spawning 100 isolates on a 4-core device will not make things 100x faster -- it will add overhead from context switching.Error Handling in Isolates
Errors thrown inside an isolate do not automatically propagate to the main isolate. With Isolate.run(), errors are caught and re-thrown as RemoteError in the calling isolate. With Isolate.spawn(), you need to handle errors explicitly.
Error Handling with Isolate.run()
import 'dart:isolate';
int riskyComputation(int input) {
if (input < 0) {
throw ArgumentError('Input must be non-negative: $input');
}
return input * input;
}
Future<void> main() async {
try {
// This will throw a RemoteError
final result = await Isolate.run(() => riskyComputation(-5));
print('Result: $result');
} on RemoteError catch (e) {
print('Error from isolate: $e');
}
// Successful case
try {
final result = await Isolate.run(() => riskyComputation(7));
print('Result: $result'); // Result: 49
} on RemoteError catch (e) {
print('Error from isolate: $e');
}
}
Summary
Dart’s isolate model provides true parallelism while guaranteeing memory safety. Use Isolate.run() (Dart 2.19+) for simple offload-and-return tasks, Isolate.spawn() with SendPort/ReceivePort for ongoing worker communication, and Flutter’s compute() as a convenient wrapper. Reserve isolates for CPU-intensive work that would block the event loop for more than 16ms, and use async/await for I/O-bound operations.
Stopwatch to measure your computation’s duration. If it exceeds 16ms and runs during user interaction, move it to an isolate. Premature optimization with isolates adds complexity -- only use them when you have a real performance problem.