Dart Isolates: Smallest Boundary for CPU Offload via SendPort/ReceivePort
Architecture notes on using Dart isolates with SendPort/ReceivePort as the smallest boundary for offloading CPU-bound work: minimal design, trust boundaries, operational checks, failure modes, and when to choose a different approach.
20 Sept 2025, 04:42 UTC

Problem: CPU-bound tasks block the main Dart event loop, freezing responsiveness.
Takeaway: Use one worker isolate per independent task, communicating only via serialized messages (SendPort/ReceivePort) to maintain a clean, safe boundary.
Dart isolates run on separate threads, each with its own heap. They do not share memory; all communication requires copying data through SendPort and ReceivePort. This pattern is the smallest suitable design for safely offloading heavy computation (e.g., complex parsing, crypto) without blocking the main isolate.
Smallest Design: Worker Entry and Communication
The main isolate creates a ReceivePort to receive replies. The worker's entry function receives a SendPort back to the main isolate. The worker then sends its own SendPort to the main isolate, establishing bidirectional communication.
// Main isolate setup\nfinal receivePort = ReceivePort();\n// Spawn worker, passing main's reply port\nawait Isolate.spawn(workerEntry, receivePort.sendPort);\n// Listen for worker's reply port, then send work\nreceivePort.listen((msg) {\n if (msg is SendPort) {\n // Worker replied with its own port; now we can send work\n workerPort.send(WorkRequest(...));\n }\n});Trust and Data Boundaries
Treat all incoming messages as untrusted input. Never rely on shared mutable state; isolate messages are serialized copies. Always decode incoming data into concrete Dart classes (e.g., WorkRequest) rather than using dynamic or Map.
// Receiver side: defensive decoding\nif (msg is! WorkRequest) { /* Handle protocol error */ }\n// Sender side: mutation after send is invisible to receiver\nfinal list = [1, 2, 3];\nworkerPort.send(list);\nlist.add(4); // Receiver still gets [1, 2, 3]Operational Checks and Failure Modes
The spawner must monitor liveness. A worker crash terminates its isolate, causing the main isolate's SendPort.send() to throw. Use periodic heartbeat messages to distinguish a slow worker from a dead one.
| Failure | Symptom | Design Response |
|---|---|---|
| Worker Uncaught Exception | Isolate terminates | Wrap worker entry in runZonedGuarded; log error and spawn replacement. |
| Slow Worker | No periodic heartbeat | Monitor heartbeat timestamp; if stale, kill and replace isolate. |
| Message Backlog | Memory pressure | Implement backpressure: worker acknowledges receipt before main sends the next request. |
When to Change the Design
Do not use isolates if workers require shared mutable state (use single isolate + async/await or dart:ffi). Also, if round-trip latency is critical for small messages, the copy overhead may favor computation within the main isolate using Future.microtask.
Practical Verification
Confirm the main isolate remains responsive under load while the worker performs CPU work. Verify that after sending an object, mutating the sender's copy does not affect the receiver's copy. Check the worker entry point signature against your deployed Dart SDK version.
Limitations
- This model assumes Dart VM; Flutter web uses Web Workers with different semantics.
- Isolate groups (shared heap) exist but alter the trust boundary.
- Isolate startup latency (1–5 ms) makes them unsuitable for very short, high-frequency requests.