Isolates in Dart — True Parallelism Without Shared Memory
Heavy computation freezes your Flutter app. Isolates give you parallel execution with zero race conditions — because they share nothing.
Why the event loop isn't enough
We covered the event loop in the Futures episode. Dart is single-threaded — one piece of code runs at a time. The event loop keeps the UI responsive by scheduling work in small chunks.
But what happens when we need to run something that can't be split into small chunks? Parsing a 10 MB JSON file. Encrypting a large blob of data. Processing image pixels. These operations are CPU-bound — they hog the processor for hundreds of milliseconds with no natural pause points.
// This blocks the event loop — no frame renders until it's done
List<int> fibonacci(int n) {
if (n <= 1) return [0, 1].take(n + 1).toList();
var result = [0, 1];
for (var i = 2; i <= n; i++) {
result.add(result[i - 1] + result[i - 2]);
}
return result;
}
void main() {
var huge = fibonacci(50000000); // blocks for seconds
print('Done');
}
While fibonacci runs, no UI events are processed. The screen freezes. Users think the app has crashed. Async/await won't help — that's for I/O-bound work where we're waiting on external systems. For CPU-bound work, we need a second processor.
The mental model: Futures handle waiting. Isolates handle computing. Use the right tool for the shape of the problem.
What is an isolate?
An isolate is Dart's unit of concurrency. Each isolate has its own memory heap, its own event loop, and its own thread of execution. Multiple isolates can run truly in parallel on multi-core CPUs.
The name comes from the key design choice: isolates share no memory. Zero. None. They communicate only by passing messages — copying data or transferring ownership.
Why is this a feature, not a limitation? Because shared memory is the source of every concurrency nightmare: race conditions, deadlocks, torn reads, heisenbugs that only appear under load. By disallowing shared state, Dart eliminates entire categories of bugs at the language level. We can't accidentally mutate the same object from two threads — there are no shared objects.
// In the main isolate
var counter = 0;
// In a background isolate — this is a COPY
// Incrementing it has no effect on the main isolate's counter
What can be sent between isolates?
• Primitive values: int, double, bool, String, null
• Lists and Maps of sendable types
• Typed data (Uint8List, etc.)
• SendPort and ReceivePort (for bidirectional communication)
• Objects marked with special annotations in some cases
Non-sendable things include closures that capture mutable state, most custom objects, and anything with a connection to native resources (files, sockets, database handles).
Spawning isolates — the modern API
Dart 2.19 introduced Isolate.run(), which is the simplest way to offload work. Give it a function and an argument, get back a Future with the result.
import 'dart:isolate';
Future<List<int>> computeFibonacci(int n) async {
// Runs in a separate isolate
return await Isolate.run(() => _fibonacci(n));
}
List<int> _fibonacci(int n) {
if (n <= 1) return [0, 1].take(n + 1).toList();
var result = [0, 1];
for (var i = 2; i <= n; i++) {
result.add(result[i - 1] + result[i - 2]);
}
return result;
}
void main() async {
print('Starting computation...');
var result = await computeFibonacci(40);
print('Done: \${result.length} numbers');
}
Isolate.run() handles all the plumbing: spawning, message passing, and cleanup. The closure runs in a fresh isolate, and the result is sent back to the caller.
The top-level function rule. The function passed to
Isolate.run() must be a top-level function or a static method — not an instance method or a closure that captures local state. This is because the function needs to be serialised and sent to the new isolate.
// Works — top-level function
int _square(int x) => x * x;
var result = await Isolate.run(() => _square(42));
// Works — static method
class Math {
static int cube(int x) => x * x * x;
}
var result = await Isolate.run(() => Math.cube(3));
// FAILS — closure capturing local variable
var multiplier = 10;
var result = await Isolate.run(() => 5 * multiplier); // Error!
For Flutter: compute(). Flutter wraps this further with the compute() helper from foundation.dart. It's the same idea but slightly more ergonomic for Flutter apps.
import 'package:flutter/foundation.dart';
Future<String> parseJson(String rawJson) async {
return await compute(_doParse, rawJson);
}
String _doParse(String json) {
// Heavy parsing work here
return jsonDecode(json).toString();
}
Ports — bidirectional communication
Isolate.run() is fire-and-forget: send work, get result. But sometimes we need ongoing communication — progress updates, streaming results, or a long-lived worker.
For that, we use SendPort and ReceivePort directly.
Here's the full pattern for a long-lived worker isolate:
import 'dart:isolate';
void main() async {
// Create a port to receive messages from the worker
final receivePort = ReceivePort();
// Spawn the worker, passing our SendPort
await Isolate.spawn(
_workerMain,
receivePort.sendPort,
);
// First message from worker is its SendPort
final workerSendPort = await receivePort.first as SendPort;
// Now we can send tasks and receive results
final responsePort = ReceivePort();
workerSendPort.send(['fibonacci', 40, responsePort.sendPort]);
final result = await responsePort.first;
print('Result: \$result');
}
void _workerMain(SendPort mainSendPort) {
final workerReceivePort = ReceivePort();
// Send our port back to main so it can send us tasks
mainSendPort.send(workerReceivePort.sendPort);
// Listen for tasks
workerReceivePort.listen((message) {
final task = message[0] as String;
final arg = message[1] as int;
final replyPort = message[2] as SendPort;
if (task == 'fibonacci') {
final result = _fibonacci(arg);
replyPort.send(result);
}
});
}
The handshake pattern: main creates a ReceivePort, spawns the worker with that port's SendPort, the worker creates its own ReceivePort and sends its SendPort back. Now both sides can talk.
Lifecycle — spawn, communicate, terminate
Isolates have a simple lifecycle: they're spawned, they run, they can be killed or they can exit naturally when their event loop empties.
Spawning. We've seen Isolate.run() and Isolate.spawn(). There's also the older Isolate.spawnUri() which loads code from a separate file — rarely needed in practice.
// Simple: run a function, get result, isolate exits
var result = await Isolate.run(() => heavyWork());
// Long-lived: spawn and keep reference
final isolate = await Isolate.spawn(workerMain, sendPort);
Termination. An isolate exits when its event loop has no more work and no open ports. We can also force termination:
// From outside: kill the isolate
isolate.kill(priority: Isolate.immediate);
// From inside: exit with a value
Isolate.exit(sendPort, result);
Isolate.exit() is more efficient than sending a message and returning — it transfers the data without copying.
Error handling. Errors in an isolate don't crash the main isolate. By default they're silently swallowed. To catch them:
final isolate = await Isolate.spawn(
workerMain,
sendPort,
onError: errorPort.sendPort, // errors sent here
onExit: exitPort.sendPort, // notified when isolate exits
);
errorPort.listen((error) {
print('Worker error: \$error');
});
Debugging tip: Isolate errors can be hard to trace because stack traces don't cross isolate boundaries. Always add logging at the entry point of your worker function.
When to use isolates
Isolates have overhead. Spawning takes a few milliseconds. Message passing involves copying (or transferring) data. Don't use them for trivial work.
Good candidates for isolates:
• Parsing large JSON (>100KB typically)
• Image processing (resizing, filtering, compression)
• Cryptographic operations (hashing, encryption)
• Complex calculations (pathfinding, physics simulations)
• Database serialisation/deserialisation
• Any computation that takes >16ms (one frame at 60fps)
Bad candidates for isolates:
• Small JSON parsing (overhead exceeds savings)
• Simple arithmetic
• I/O-bound work (use Futures instead)
• Anything requiring UI access
The 16ms rule. At 60fps, each frame has 16.67 milliseconds. If synchronous code takes longer than that, the frame is dropped. Users see stutter. Use DevTools' performance view to identify jank, then move the offender to an isolate.
Practical patterns for Flutter
In Flutter apps, the most common isolate pattern is compute() for one-off heavy work.
import 'package:flutter/foundation.dart';
import 'dart:convert';
class UserListScreen extends StatefulWidget {
@override
_UserListScreenState createState() => _UserListScreenState();
}
class _UserListScreenState extends State<UserListScreen> {
List<User>? users;
bool loading = true;
@override
void initState() {
super.initState();
_loadUsers();
}
Future<void> _loadUsers() async {
final response = await http.get(Uri.parse('https://api.example.com/users'));
// Heavy JSON parsing in isolate — UI stays responsive
final parsed = await compute(_parseUsers, response.body);
setState(() {
users = parsed;
loading = false;
});
}
// Must be top-level or static
static List<User> _parseUsers(String jsonString) {
final List<dynamic> data = jsonDecode(jsonString);
return data.map((json) => User.fromJson(json)).toList();
}
}
Isolate pools for repeated work. If we're doing many isolate tasks, spawning a new isolate each time is wasteful. The IsolatePool from package:isolate or similar patterns keep workers alive:
// Conceptual pattern — pool implementation varies
final pool = IsolatePool(concurrency: 4);
Future<List<ProcessedImage>> processImages(List<Uint8List> images) async {
final futures = images.map((img) => pool.run(_processImage, img));
return Future.wait(futures);
}
Typed data transfer. For binary data like images, use TransferableTypedData or pass Uint8List directly. These can be transferred without copying when using Isolate.exit():
// Efficient: data is transferred, not copied
void _processImage(SendPort resultPort) {
final processed = Uint8List(1000000);
// ... fill processed ...
Isolate.exit(resultPort, processed); // zero-copy transfer
}
State management with isolates. Keep isolate results in your state management (Provider, Riverpod, Bloc). Don't let isolate logic leak into widgets. The widget asks for data; the repository decides whether to use an isolate.
class UserRepository {
Future<List<User>> fetchUsers() async {
final response = await _api.getUsers();
// Decision to use isolate is encapsulated here
if (response.body.length > 100000) {
return compute(_parseUsers, response.body);
}
return _parseUsers(response.body);
}
}
The widget doesn't know or care that an isolate was used. That's the right separation of concerns.
Test your understanding
7 questions
Seven questions covering isolates, message passing, and when to use them.