Dart Isolate Patterns: From Isolate.run to Robust Long-Lived Workers
A deep dive into Dart's concurrency model — when to use isolates, the simple Isolate.run pattern, and a production-ready long-lived worker with ports, message IDs, and clean shutdown.
Published on • October 28, 2026
AI Assistant

Dart’s answer to concurrency is the isolate: a unit of execution with its own memory and event loop, communicating with other isolates purely by message passing. For Flutter and Dart apps, isolates are the tool for computations large enough to block other work — parsing huge JSON blobs, compressing photos, running searches over large datasets, or handling heavy network traffic.
There are no hard rules about when you must use isolates, but the Dart documentation points to a clear threshold: if a computation is large enough to temporarily make your UI unresponsive, move it off the main isolate.
Why Isolates, Not Threads
Dart deliberately avoids shared-memory threads. Each isolate has its own heap; objects are never shared, only sent. This eliminates an entire category of bugs — data races, lock contention, torn reads — at the cost of requiring explicit message passing. On Flutter web, multiple isolates aren’t supported (single-threaded JavaScript), but on the VM (mobile, desktop, server) they run in parallel across cores.
Three memory operations matter when sending data between isolates:
- Copy — most objects are serialized and copied.
- Transfer — some immutable objects (like large typed data buffers) can have their memory transferred without copying.
- Reference within same isolate — irrelevant across isolates; everything crosses the boundary.
Pattern 1: One-Shot Work with Isolate.run
For the common case — run this function elsewhere, give me the result — Isolate.run() is the modern API. It spawns an isolate, runs your function, returns the result, and cleans up:
void main() async {
final jsonData = await Isolate.run(_readAndParseJson);
print('Number of JSON keys: ${jsonData.length}');
}
Future<Map<String, dynamic>> _readAndParseJson() async {
final fileData = await File('big.json').readAsString();
return jsonDecode(fileData) as Map<String, dynamic>;
}
Isolate.run() handles the full lifecycle: spawn, execute, capture the result, return it to the main isolate, terminate, and propagate exceptions/errors back. The result is transferred (not copied) when possible, with a verification pass to ensure only transferable objects cross.
Because you can pass a closure, it reads like a control-flow operator for “run in parallel”:
final summary = await Isolate.run(() {
return rows.map(computeStats).toList();
});
In Flutter, compute() is the equivalent helper built on the same machinery.
Caveat: each Isolate.run pays spawn overhead. For one-off heavy tasks it’s negligible; for a rapid sequence of small tasks, prefer a long-lived worker.
Pattern 2: Long-Lived Worker with Ports
When you repeatedly run the same computation, spawn a persistent worker and communicate via ReceivePort/SendPort. Ports are the only way isolates talk to each other:
- A
ReceivePorthandles incoming messages; it implementsStream. - Each
ReceivePorthas exactly oneSendPort, but manySendPorts can target oneReceivePort. SendPort.send()delivers a message to the paired receive port.
The setup handshake:
- Main isolate creates a
ReceivePortand passes itsSendPortintoIsolate.spawn. - The worker creates its own
ReceivePortand sends itsSendPortback through the port it was given. - Both sides now have an open channel.
The Basic Worker
class Worker {
Future<void> spawn() async {
final receivePort = ReceivePort();
receivePort.listen(_handleResponsesFromIsolate);
await Isolate.spawn(_startRemoteIsolate, receivePort.sendPort);
}
void _handleResponsesFromIsolate(dynamic message) {
if (message is SendPort) {
_sendPort = message;
_isolateReady.complete();
} else if (message is Map<String, dynamic>) {
print(message);
}
}
static void _startRemoteIsolate(SendPort port) {
final receivePort = ReceivePort();
port.send(receivePort.sendPort);
receivePort.listen((message) {
if (message is String) {
port.send(jsonDecode(message));
}
});
}
Future<void> parseJson(String message) async {
await _isolateReady.future;
_sendPort!.send(message);
}
SendPort? _sendPort;
final _isolateReady = Completer<void>();
}
This teaches the minimum, but it’s not production-ready: responses arrive in completion order (not send order), there’s no error handling, and ports are never closed.
Pattern 3: The Robust Worker
The production pattern adds four things: a RawReceivePort for clean startup separation, request IDs to correlate responses, error propagation via RemoteError, and an explicit shutdown protocol.
Startup with RawReceivePort
RawReceivePort lets you separate the one-time “handshake” handler from the ongoing message handler. Create it first, complete a Completer when the worker’s SendPort arrives, then wrap it in a regular ReceivePort for the steady-state listener:
static Future<Worker> spawn() async {
final initPort = RawReceivePort();
final connection = Completer<(ReceivePort, SendPort)>.sync();
initPort.handler = (initialMessage) {
final commandPort = initialMessage as SendPort;
connection.complete((
ReceivePort.fromRawReceivePort(initPort),
commandPort,
));
};
try {
await Isolate.spawn(_startRemoteIsolate, initPort.sendPort);
} on Object {
initPort.close();
rethrow;
}
final (receivePort, sendPort) = await connection.future;
return Worker._(receivePort, sendPort);
}
If spawn fails, the init port is closed and the exception rethrown — no leaked resources.
Correlating Requests with IDs
Rapid sends mean responses return out of order. Give each request an ID and track it in a map of Completers:
final Map<int, Completer<Object?>> _activeRequests = {};
int _idCounter = 0;
Future<Object?> parseJson(String message) {
if (_closed) throw StateError('Closed');
final completer = Completer<Object?>.sync();
final id = _idCounter++;
_activeRequests[id] = completer;
_commands.send((id, message)); // record carries the id
return completer.future;
}
The worker destructures the record, does the work, and echoes the ID back:
static void _handleCommandsToIsolate(ReceivePort rp, SendPort sp) {
rp.listen((message) {
if (message == 'shutdown') {
rp.close();
return;
}
final (int id, String jsonText) = message as (int, String);
try {
sp.send((id, jsonDecode(jsonText)));
} catch (e) {
sp.send((id, RemoteError(e.toString(), '')));
}
});
}
On the main isolate, look up the completer by ID, complete or complete-error, and remove it from the map. Errors arrive as RemoteError and surface as normal Dart exceptions to the awaiting caller.
Clean Shutdown
void close() {
if (_closed) return;
_closed = true;
_commands.send('shutdown');
if (_activeRequests.isEmpty) _responses.close();
}
The worker closes its own receive port on the shutdown message — which terminates the isolate — and the main isolate closes its port when no requests remain in flight. Any parseJson call after close throws StateError instead of silently misbehaving.
Which Pattern Should You Use?
| Situation | Pattern |
|---|---|
| Parse a big JSON once at startup | Isolate.run |
| Photo compression on demand | Isolate.run or Flutter compute |
| Continuous processing (e.g., audio frames, streaming search) | Long-lived worker with ports |
| Worker shared across the app | Singleton Worker with async factory (Worker.spawn()) |
| Flutter web target | Don’t use isolates — not supported |
Performance Notes
- Spawn cost is real — creating an isolate takes on the order of hundreds of microseconds. Batch work accordingly.
- Sending is copying — don’t send giant object graphs repeatedly; send compact payloads or transfer typed-data buffers.
- Close your ports — an open
ReceivePortkeeps its isolate (and your app) from shutting down cleanly. - Errors cross boundaries — exceptions thrown in the worker come back as
RemoteError; handle them like any async error.
Conclusion
Dart’s isolate model trades shared-memory threads for message-passing safety. Start with Isolate.run() — it covers the majority of “move this heavy computation off the UI thread” cases in a single line. When you need sustained, bidirectional communication, graduate to the long-lived worker pattern: RawReceivePort for clean startup, request IDs for out-of-order responses, RemoteError for failure propagation, and an explicit shutdown protocol. Together, these patterns give you multi-core Dart that stays responsive and leaks nothing.
Further Reading
- Isolates — the official Dart language documentation, source of the worker examples in this post.
- Concurrency in Dart — the conceptual overview: main isolate, background workers, and web limitations.
- Concurrency and isolates (Flutter) — Flutter-specific guidance, including
compute.