Dart Isolates: Minimal Architecture for CPU-Bound Parallel Work
When Dart UI blocks on CPU work, isolates provide memory-isolated parallelism via message passing. This architecture note covers the minimal design, data boundaries, operational checks, failure modes and when to change the approach.
20 Jul 2025, 07:36 UTC

The problem you are solving
A Dart UI or server request handler freezes while doing CPU-bound work such as parsing large payloads, image decoding, or numeric simulation. The main isolate’s event loop cannot process frames or I/O while the work runs. The useful takeaway is to move that work to a separate isolate and accept message-passing costs instead of shared state.
An isolate in Dart is a concurrency unit with its own heap and event loop. Isolates do not share mutable memory. Communication is via ports and serialized messages. This gives race-free parallelism at the cost of copy-on-send.
Requirements for parallel work in Dart
Define what must be parallel and what must stay on the main isolate.
- CPU-bound task that exceeds a few milliseconds and can run independently of UI or request state.
- No need for synchronous access to mutable objects owned by the main isolate.
- Inputs and outputs can be expressed as serializable data, or as a handle to a transferable buffer.
- Failure of the worker must be observable and recoverable by the host.
If the task is I/O-bound, use async Futures and the existing event loop. Isolates do not speed up I/O.
Smallest suitable design
The minimal pattern is one worker isolate per independent unit of work, with a single request-response channel.
// main isolate
ReceivePort mainPort = ReceivePort();
Isolate worker = await Isolate.spawn(workerEntry, mainPort.sendPort);
// send work
mainPort.sendPort.send({'id': 1, 'data': input});
// worker isolate entry
void workerEntry(SendPort mainSendPort) {
ReceivePort port = ReceivePort();
mainSendPort.send(port.sendPort);
port.listen((message) {
try {
var result = heavyCompute(message['data']);
mainSendPort.send({'id': message['id'], 'result': result});
} catch (e, st) {
mainSendPort.send({'id': message['id'], 'error': e.toString()});
}
});
}
Run with dart run file.dart from a terminal where you have read access to the source file. No elevated permissions are required. The host creates a ReceivePort to receive the worker’s SendPort handshake, then sends work.
For error visibility, capture the isolate’s error port at spawn time and log or restart. An uncaught exception terminates the isolate; it does not propagate automatically.
Trust and data boundaries
Treat the serialization boundary as a trust boundary. The main isolate and worker isolate have no shared heap. Messages are copied, not referenced.
- Only send data that can be serialized by Dart’s isolate message codec. Functions, closures, and objects with open handles are not allowed.
- Large structures incur copy overhead. For large binary data, prefer a transferable view or SharedArrayBuffer with explicit synchronization rather than copying the whole payload.
- Do not assume ordering across multiple ports. Use a correlation id in messages if you multiplex requests.
The boundary also isolates failures. A crash in the worker cannot corrupt the main isolate’s heap.
Operational checks
Checks you can run in production without changing the design.
- Isolate liveness: keep a reference to the Isolate object and verify it is not closed after a send. If the worker stops responding, recreate it.
- Error port monitoring: attach a handler to the error port returned by Isolate.spawn to record isolate termination reasons.
- Message latency and size: log time between send and response and the serialized byte size of payloads. Growing latency indicates copy cost or queue buildup.
- CPU usage per isolate: observe that the main isolate remains responsive while the worker runs.
Verification steps for a new service: create two isolates, send a message via SendPort, verify the main isolate receives and processes it and that no shared mutable state changed. Measure a CPU-intensive calculation in a separate isolate versus the main isolate and confirm UI or request handling remains responsive. Set up an isolate with an uncaught exception and an error port, verify the error is reported to the host.
Failure modes
Isolate creation has non-trivial startup cost. Spawning isolates for sub-millisecond tasks is slower than using Future and async.
Message serialization limits types you can send. Complex objects need conversion to JSON or a custom serializable form. Debugging isolates is harder because stack traces are isolated and not aggregated.
Large payloads cause memory spikes due to copying. If the worker crashes, in-flight messages are lost unless you implement retry or idempotency.
Conditions that change the design
Change the design when requirements move away from the minimal pattern.
- Short-lived, high-frequency tasks: use the main isolate with async scheduling or a compute pool with reuse.
- Need for shared memory and low latency: consider SharedArrayBuffer with explicit atomic access, accepting the added complexity and risk of data races.
- Streaming data: replace request-response with a Stream and a dedicated port for backpressure.
- Many parallel workers: introduce a pool and a queue to bound isolate count and startup overhead.
Limitations to keep in mind: isolates cannot share objects, debugging is isolated, and serialization cost dominates for large messages. Check the result by measuring main isolate frame time or request latency under load with and without the worker isolate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.