Dart 55 🎯 Bidirectional Isolate Communication via ReceivePort and SendPort
An isolate is a sealed box. It has its own heap, its own event loop, and no direct access to anything in another isolate. The only way in or out is a port. A SendPort is a one-way channel into a ReceivePort, and a ReceivePort is the mailbox on the other end. To establish two-way communication, the two isolates exchange ports: each sends the other its SendPort, and from that point, messages can flow in both directions. This chapter examines the mechanics of that exchange, the patterns it enables, and the failure modes that arise when ports are mishandled.
Key point: A SendPort is sendable, a ReceivePort is not. Bidirectional communication requires each isolate to send its SendPort to the other. The first message is always one-directional; after the handshake, the channel is bidirectional.
Why bidirectional communication is needed
The request-response problem. A worker isolate that processes tasks must receive tasks and return results. A one-directional channel from main to worker is insufficient because the main isolate has no way to receive the results. The worker needs a port back to the main isolate.
The long-lived worker problem. A short-lived worker can package its result into the initial message and exit. A long-lived worker handles many requests over time and must be able to receive new input while it is running. That requires a persistent channel in both directions.
The streaming problem. A worker that produces a stream of results, such as incremental progress updates, must send each result as it becomes available. One-way communication from the worker back to the main isolate supports this, but the main isolate must also be able to send control messages, such as cancellation or configuration changes. That requires bidirectionality.
The coordination problem. Complex work often involves coordination between isolates. A pipeline may need a stage to signal readiness before the next stage sends data. A pool may need workers to announce their availability. This coordination is impossible with a single direction.
The error-reporting problem. A worker may encounter errors that are not fatal but that the main isolate needs to know about. These error reports are a second class of message, distinct from results, and they travel on the same bidirectional channel.
The isolation guarantee. Even bidirectional communication preserves the isolate boundary. Messages are still copied. The sender’s heap and the receiver’s heap remain separate. The bidirectional channel makes the boundary usable without breaking it.
a. The port handshake
Establishing a bidirectional channel requires each isolate to receive the other’s SendPort. The pattern is a handshake: the main isolate sends its SendPort in the initial message, and the worker replies with its own SendPort.
The initial message
The Isolate.spawn call takes an initial message that is delivered to the entry function. This message is the natural place for the main isolate to send its SendPort.
void workerEntry(SendPort mainPort) {
// The main isolate's SendPort arrived here.
// The worker can send messages back through it.
}
void main() async {
final mainPort = ReceivePort();
await Isolate.spawn(workerEntry, mainPort.sendPort);
}
After this call, the worker can send messages to the main isolate, but the main isolate cannot yet send messages to the worker. The channel is one-directional.
The reply with the worker’s port
For the channel to become bidirectional, the worker must send its own SendPort to the main isolate. The main isolate stores it and uses it for subsequent sends.
void workerEntry(SendPort mainPort) {
final workerPort = ReceivePort();
mainPort.send(workerPort.sendPort);
workerPort.listen((message) {
mainPort.send('received: $message');
});
}
The worker creates its own ReceivePort, sends its SendPort to the main isolate as the first message, and then listens for incoming messages. The main isolate receives the worker’s SendPort and can now send to it.
The main isolate receives the port
The main isolate listens on its ReceivePort. The first message from the worker is the worker’s SendPort. The main isolate stores it for later use.
void main() async {
final mainPort = ReceivePort();
await Isolate.spawn(workerEntry, mainPort.sendPort);
late SendPort workerPort;
mainPort.listen((message) {
if (message is SendPort) {
workerPort = message;
workerPort.send('hello');
} else {
print(message); // 'received: hello'
}
});
}
The first message is the handshake. The second message is the reply from the worker. Subsequent messages continue in both directions.
The completer for the handshake
In asynchronous code, waiting for the handshake is cleaner with a Completer. The Completer completes when the worker’s SendPort arrives.
Future<SendPort> spawnWorker() async {
final mainPort = ReceivePort();
final completer = Completer<SendPort>();
await Isolate.spawn(workerEntry, mainPort.sendPort);
mainPort.listen((message) {
if (message is SendPort && !completer.isCompleted) {
completer.complete(message);
}
});
return completer.future;
}
The caller awaits the completer, then holds the worker’s SendPort for the rest of the session.
The handshake protocol
A common protocol is to include a handshake message type in the stream. The main isolate sends a “ready” message, and the worker replies with a “ready” acknowledgment alongside its SendPort. This confirms that both sides are listening before normal communication begins.
// Main sends: {'type': 'ready'}
// Worker replies: {'type': 'ready', 'port': workerSendPort}
// Both sides are now listening. Normal messages begin.
The protocol is not enforced by the runtime. It is a convention that prevents races between the handshake and the first real message.
b. Message types and protocols
A bidirectional channel carries many kinds of messages. Without a convention for distinguishing them, the receiver must inspect each message and infer its purpose. A typed protocol makes the receiver’s logic clear and predictable.
The tagged message pattern
The simplest protocol tags each message with a type. The receiver switches on the type and handles each case.
// Send a request.
workerPort.send({'type': 'request', 'id': 42, 'payload': data});
// Send a cancellation.
workerPort.send({'type': 'cancel', 'id': 42});
// Send a shutdown.
workerPort.send({'type': 'shutdown'});
The receiver inspects the type field and dispatches accordingly. This is the pattern that scales best, because new message types can be added without breaking existing handlers.
The sealed class pattern
Dart’s sealed classes and pattern matching provide a type-safe alternative to string tags. Each message type is a class, and the receiver’s switch is exhaustive.
sealed class WorkerMessage {}
class RequestMessage extends WorkerMessage {
final int id;
final Object payload;
RequestMessage(this.id, this.payload);
}
class CancelMessage extends WorkerMessage {
final int id;
CancelMessage(this.id);
}
class ShutdownMessage extends WorkerMessage {}
The receiver’s switch over the sealed type is checked at compile time. Adding a new message type requires updating every handler, which prevents silent omissions.
void handle(WorkerMessage message) {
switch (message) {
case RequestMessage(:final id, :final payload):
process(id, payload);
case CancelMessage(:final id):
cancel(id);
case ShutdownMessage():
shutdown();
}
}
The sealed class pattern costs a small amount of boilerplate but eliminates an entire class of runtime errors.
The correlation ID
When the main isolate sends multiple requests and expects multiple responses, it must be able to match each response to its request. A correlation ID is included in each request and echoed in the response.
// Main sends request with id 42.
workerPort.send({'type': 'request', 'id': 42, 'payload': data});
// Worker processes and replies with the same id.
mainPort.send({'type': 'result', 'id': 42, 'value': result});
The main isolate maintains a map from ID to Completer. When the response arrives, the completer with the matching ID is completed.
final pending = <int, Completer<Object>>{};
void sendRequest(int id, Object payload) {
final completer = Completer<Object>();
pending[id] = completer;
workerPort.send({'type': 'request', 'id': id, 'payload': payload});
return completer.future;
}
void onMessage(Map message) {
if (message['type'] == 'result') {
pending.remove(message['id'])?.complete(message['value']);
}
}
The correlation ID is the mechanism that turns a single channel into many concurrent request-response pairs.
The protocol version
A long-lived worker may outlive several versions of the application’s code. A protocol version in the handshake allows the worker to refuse to operate with an incompatible main isolate.
// Main sends: {'type': 'handshake', 'version': 2}
// Worker replies: {'type': 'handshake_ack', 'version': 2}
// If versions differ, the worker sends an error and shuts down.
The version is a single integer. The protocol is: if the versions differ, the connection is refused.
The heartbeat pattern
A long-lived bidirectional channel may need to detect when the other end has stopped responding. A heartbeat is a periodic message that the receiver acknowledges.
// Main sends: {'type': 'ping', 'timestamp': now}
// Worker replies: {'type': 'pong', 'timestamp': now}
// If no pong arrives within a timeout, the channel is considered dead.
The heartbeat pattern detects hung workers and dead channels that a simple listen would not notice.
c. Patterns for bidirectional channels
The bidirectional channel enables several recurring patterns. Each one addresses a different kind of interaction.
The request-response pattern
The simplest pattern is a synchronous-looking call: the main isolate sends a request, and the worker returns a response. The correlation ID matches responses to requests, and the Completer provides the future.
Future<Object> call(Object request) async {
final id = _nextId++;
final completer = Completer<Object>();
_pending[id] = completer;
_workerPort.send({'type': 'request', 'id': id, 'payload': request});
return completer.future;
}
Multiple calls can be in flight simultaneously, and each resolves independently when its response arrives.
The pub-sub pattern
A worker can publish events that multiple listeners receive. The main isolate subscribes to a stream exposed by the worker wrapper.
final _controller = StreamController<Event>.broadcast();
void onMessage(Map message) {
if (message['type'] == 'event') {
_controller.add(Event(message['name'], message['data']));
}
}
Stream<Event> get events => _controller.stream;
Listeners subscribe to events and receive every event the worker publishes. The broadcast controller allows multiple listeners.
The streaming result pattern
A worker that processes a large task can stream partial results instead of returning one final value. Each partial result is sent as a message.
void workerEntry(SendPort mainPort) {
final port = ReceivePort();
mainPort.send(port.sendPort);
port.listen((task) {
for (var i = 0; i < task.steps; i++) {
final partial = computeStep(i);
mainPort.send({'type': 'progress', 'value': partial});
}
mainPort.send({'type': 'done'});
});
}
The main isolate updates the UI on each progress message and knows the task is complete when the done message arrives.
The cancellation pattern
A long-running task can be cancelled by sending a cancellation message. The worker checks for cancellation at safe points and stops.
void workerEntry(SendPort mainPort) {
final port = ReceivePort();
mainPort.send(port.sendPort);
var cancelled = false;
port.listen((message) {
if (message['type'] == 'cancel') {
cancelled = true;
}
});
// In the task loop:
for (var i = 0; i < steps; i++) {
if (cancelled) break;
computeStep(i);
}
}
The worker must periodically check the cancelled flag. A truly preemptive cancellation would require killing the isolate, which discards the channel.
The two-way worker pool
A pool of workers communicates through a shared SendPort for results and individual SendPorts for tasks. The pool wrapper manages the correlation.
class Pool {
final List<SendPort> _workers;
final ReceivePort _resultPort;
final _pending = <int, Completer>{};
var _nextId = 0;
var _nextWorker = 0;
Future<Object> run(Object task) {
final id = _nextId++;
final completer = Completer<Object>();
_pending[id] = completer;
final worker = _workers[_nextWorker];
_nextWorker = (_nextWorker + 1) % _workers.length;
worker.send({'id': id, 'task': task});
return completer.future;
}
}
The _nextWorker rotation distributes tasks round-robin. The _pending map matches responses to requests across all workers.
The bidirectional pipeline
Each stage in a pipeline sends its output to the next stage and receives control messages from the main isolate. The channels are bidirectional at every stage.
// Stage 1 → Stage 2 → Stage 3
// Main isolate can send to any stage via its port.
// Each stage can send progress or errors back to the main isolate.
The pipeline’s stages are long-lived isolates that accept new work and report progress. The main isolate retains a SendPort for each stage.
Complete Example Session
// ============================================
// PART 1: THE HANDSHAKE
// ============================================
import 'dart:isolate';
import 'dart:async';
void workerEntry(SendPort mainPort) {
final workerPort = ReceivePort();
mainPort.send(workerPort.sendPort);
workerPort.listen((message) {
mainPort.send('echo: $message');
});
}
void main() async {
final mainPort = ReceivePort();
await Isolate.spawn(workerEntry, mainPort.sendPort);
late SendPort workerPort;
mainPort.listen((message) {
if (message is SendPort) {
workerPort = message;
workerPort.send('hello');
} else {
print(message); // 'echo: hello'
}
});
}
// ============================================
// PART 2: THE COMPLETER PATTERN
// ============================================
Future<SendPort> spawnEchoWorker() async {
final mainPort = ReceivePort();
final completer = Completer<SendPort>();
await Isolate.spawn(workerEntry, mainPort.sendPort);
mainPort.listen((message) {
if (message is SendPort && !completer.isCompleted) {
completer.complete(message);
}
});
return completer.future;
}
// ============================================
// PART 3: THE REQUEST-RESPONSE PATTERN
// ============================================
class Worker {
final SendPort _sendPort;
final ReceivePort _receivePort;
final _pending = <int, Completer<Object>>{};
var _nextId = 0;
Worker._(this._sendPort, this._receivePort);
static Future<Worker> spawn() async {
final receivePort = ReceivePort();
final isolate = await Isolate.spawn(_entry, receivePort.sendPort);
final completer = Completer<SendPort>();
receivePort.listen((message) {
if (message is SendPort && !completer.isCompleted) {
completer.complete(message);
}
});
final sendPort = await completer.future;
final worker = Worker._(sendPort, receivePort);
receivePort.listen(worker._onMessage);
return worker;
}
static void _entry(SendPort mainPort) {
final port = ReceivePort();
mainPort.send(port.sendPort);
port.listen((message) {
if (message is Map && message['type'] == 'request') {
final id = message['id'] as int;
final result = process(message['payload']);
mainPort.send({'type': 'result', 'id': id, 'value': result});
}
});
}
void _onMessage(Object message) {
if (message is Map && message['type'] == 'result') {
final id = message['id'] as int;
_pending.remove(id)?.complete(message['value']);
}
}
Future<Object> call(Object payload) {
final id = _nextId++;
final completer = Completer<Object>();
_pending[id] = completer;
_sendPort.send({'type': 'request', 'id': id, 'payload': payload});
return completer.future;
}
}
// ============================================
// PART 4: THE STREAMING PATTERN
// ============================================
class StreamingWorker {
final SendPort _sendPort;
final ReceivePort _receivePort;
final _progress = StreamController<int>.broadcast();
StreamingWorker._(this._sendPort, this._receivePort);
Stream<int> get progress => _progress.stream;
// The worker sends {'type': 'progress', 'value': i}.
// The main isolate forwards each value to the controller.
}
// ============================================
// PART 5: THE CANCELLATION PATTERN
// ============================================
class CancellableWorker {
final SendPort _sendPort;
CancellableWorker._(this._sendPort);
void start(int id) {
_sendPort.send({'type': 'start', 'id': id});
}
void cancel(int id) {
_sendPort.send({'type': 'cancel', 'id': id});
}
}
// The worker tracks running tasks by id.
// A cancel message sets a flag that the task loop checks.
// ============================================
// PART 6: THE PUB-SUB PATTERN
// ============================================
// The worker publishes events.
// The main isolate subscribes to a broadcast stream.
// Multiple listeners receive every event.
// ============================================
// PART 7: THE POOL
// ============================================
class WorkerPool {
final List<Worker> _workers;
var _next = 0;
WorkerPool._(this._workers);
static Future<WorkerPool> spawn(int count) async {
final workers = <Worker>[];
for (var i = 0; i < count; i++) {
workers.add(await Worker.spawn());
}
return WorkerPool._(workers);
}
Future<Object> run(Object payload) {
final worker = _workers[_next];
_next = (_next + 1) % _workers.length;
return worker.call(payload);
}
}
// ============================================
// PART 8: THE SHUTDOWN PROTOCOL
// ============================================
void shutdownEntry(SendPort mainPort) {
final port = ReceivePort();
mainPort.send(port.sendPort);
port.listen((message) {
if (message is Map && message['type'] == 'shutdown') {
port.close();
}
});
}
// Main isolate sends {'type': 'shutdown'} then kills the isolate.
// ============================================
// PART 9: THE HEARTBEAT
// ============================================
// Main sends {'type': 'ping', 'timestamp': now} periodically.
// Worker replies {'type': 'pong', 'timestamp': now}.
// If no pong arrives within a timeout, the channel is considered dead.
// ============================================
// PART 10: THE SEALED CLASS PROTOCOL
// ============================================
sealed class WorkerMessage {}
class Request extends WorkerMessage {
final int id;
final Object payload;
Request(this.id, this.payload);
}
class Cancel extends WorkerMessage {
final int id;
Cancel(this.id);
}
class Shutdown extends WorkerMessage {}
// The receiver's switch is exhaustive:
void handle(WorkerMessage message) {
switch (message) {
case Request(:final id, :final payload):
process(id, payload);
case Cancel(:final id):
cancel(id);
case Shutdown():
shutdown();
}
}
The ten parts progressed from the raw handshake through the Completer pattern, the request-response wrapper, streaming, cancellation, pub-sub, pools, shutdown, heartbeats, and sealed-class protocols. The same echo and worker scenarios ran through every part, showing how the handshake enables each pattern.
Quick Reference
Port Types
| Type | Sendable | Direction |
|---|---|---|
SendPort | Yes | One-way (sends to a ReceivePort) |
ReceivePort | No | One-way (receives from many SendPorts) |
The Handshake
| Step | Action |
|---|---|
| 1 | Main creates ReceivePort |
| 2 | Main spawns isolate with mainPort.sendPort |
| 3 | Worker creates own ReceivePort |
| 4 | Worker sends workerPort.sendPort to main |
| 5 | Main stores the worker’s SendPort |
| 6 | Both sides can now send |
Message Protocols
| Pattern | Use Case |
|---|---|
| Tagged maps | Simple dynamic protocols |
| Sealed classes | Type-safe exhaustive handling |
| Correlation ID | Multiple concurrent requests |
| Protocol version | Compatibility checking |
| Heartbeat | Liveness detection |
Common Patterns
| Pattern | Direction | Use Case |
|---|---|---|
| Request-response | Bidirectional | RPC-style calls |
| Streaming | Worker to main | Progress updates |
| Cancellation | Main to worker | Stop long tasks |
| Pub-sub | Worker to main | Event broadcasting |
| Pool | Bidirectional | Parallel task distribution |
Best Practices
✅ Do This:
// Send the SendPort in the initial message.
await Isolate.spawn(worker, mainPort.sendPort); // ✅
// Worker replies with its SendPort.
mainPort.send(workerPort.sendPort); // ✅
// Use a Completer to await the handshake.
completer.complete(workerPort); // ✅
// Tag messages with a type field.
port.send({'type': 'request', 'id': 42}); // ✅
// Use correlation IDs for concurrent requests.
_pending[id] = completer; // ✅
// Use sealed classes for type-safe protocols.
sealed class WorkerMessage {} // ✅
// Handle shutdown explicitly.
if (message['type'] == 'shutdown') port.close(); // ✅
// Close ports when done.
port.close(); // ✅
❌ Don’t Do This:
// Don't try to send a ReceivePort.
mainPort.send(receivePort); // ❌ Not sendable
// Don't forget the handshake.
// The worker cannot send if it does not have a SendPort. // ❌
// Don't send raw values without a type tag.
port.send(data); // ❌ Ambiguous receiver logic
// Don't reuse a port after closing it.
port.close();
port.send('x'); // ❌ Throws
// Don't assume the worker stays alive.
// Its entry function must keep listening. // ❌
// Don't ignore handshake ordering.
// Messages sent before the handshake may be lost. // ❌
// Don't forget the shutdown protocol.
// The worker persists until killed. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
UnsupportedError | Sending a ReceivePort | Send only SendPort |
| Worker never responds | Handshake not completed | Send SendPort in initial message |
| Response mixed up | No correlation ID | Include and echo an ID |
| Worker exits early | Entry function returns | Keep the port listening |
| Port leak | ReceivePort not closed | Close on shutdown |
| Lost messages | Messages sent before handshake | Wait for the handshake |
| Hung channel | Worker not responding | Add heartbeats |
| Protocol mismatch | New main, old worker | Include version in handshake |
Real-World Examples
1. Send SendPort in Initial Message
await Isolate.spawn(workerEntry, mainPort.sendPort);
2. Worker Replies with Its Port
final workerPort = ReceivePort();
mainPort.send(workerPort.sendPort);
3. Await the Handshake
final completer = Completer<SendPort>();
mainPort.listen((m) { if (m is SendPort) completer.complete(m); });
4. Tagged Messages
port.send({'type': 'request', 'id': 1, 'payload': data});
5. Correlation ID
_pending[id]?.complete(message['value']);
6. Streaming Progress
mainPort.send({'type': 'progress', 'value': i});
7. Cancellation
workerPort.send({'type': 'cancel', 'id': 1});
8. Shutdown
workerPort.send({'type': 'shutdown'});
port.close();
9. Sealed Class Protocol
sealed class WorkerMessage {}
class Request extends WorkerMessage { ... }
10. Heartbeat
Timer.periodic(Duration(seconds: 30), (_) => port.send({'type': 'ping'}));
Visual
The Handshake Sequence
┌────────────────────────────────────────────────────────────┐
│ BIDIRECTIONAL HANDSHAKE │
│ │
│ MAIN ISOLATE WORKER ISOLATE │
│ ───────────── ────────────── │
│ │
│ 1. Create mainPort │
│ 2. Spawn with mainPort.sendPort ──────────────────────▶ │
│ │
│ 3. Create workerPort│
│ 4. Receive workerPort.sendPort ◀────────────────────── │
│ │
│ 5. Store workerPort 6. Listen on workerPort │
│ │
│ 7. Send request ──────────────────────────────────────▶ │
│ 8. Process request │
│ 9. Receive response ◀───────────────────────────────── │
│ │
│ After step 4, both directions are open. │
│ │
└────────────────────────────────────────────────────────────┘
Request-Response with Correlation
┌────────────────────────────────────────────────────────────┐
│ CORRELATION ID MATCHES RESPONSES TO REQUESTS │
│ │
│ MAIN ISOLATE │
│ ┌──────────────────────────────────────────────────┐ │
│ │ _pending = {} │ │
│ │ │ │
│ │ call('A') → id=1, _pending[1] = completer1 │ │
│ │ call('B') → id=2, _pending[2] = completer2 │ │
│ │ call('C') → id=3, _pending[3] = completer3 │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ v │
│ WORKER ISOLATE │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Processes A, B, C in some order. │ │
│ │ Sends results with matching IDs. │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ v │
│ MAIN ISOLATE │
│ ┌──────────────────────────────────────────────────┐ │
│ │ onMessage({id: 2, value: B}) │ │
│ │ _pending[2].complete(B) │ │
│ │ onMessage({id: 1, value: A}) │ │
│ │ _pending[1].complete(A) │ │
│ │ onMessage({id: 3, value: C}) │ │
│ │ _pending[3].complete(C) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The order of responses is independent of the order of │
│ requests. The ID matches each response to its caller. │
│ │
└────────────────────────────────────────────────────────────┘
Streaming Results
┌────────────────────────────────────────────────────────────┐
│ STREAMING PARTIAL RESULTS │
│ │
│ MAIN ISOLATE WORKER ISOLATE │
│ ───────────── ────────────── │
│ │
│ Send task ────────────────────────────────────────────▶ │
│ │
│ Process step 1 │
│ Receive {type: progress, value: 1} ◀────────────────── │
│ Process step 2 │
│ Receive {type: progress, value: 2} ◀────────────────── │
│ Process step 3 │
│ Receive {type: progress, value: 3} ◀────────────────── │
│ Process complete │
│ Receive {type: done} ◀──────────────────────────────── │
│ │
│ The main isolate updates the UI on each progress message. │
│ The done message signals completion. │
│ │
└────────────────────────────────────────────────────────────┘
Sealed Class Protocol
┌────────────────────────────────────────────────────────────┐
│ TYPE-SAFE PROTOCOL WITH SEALED CLASSES │
│ │
│ sealed class WorkerMessage {} │
│ │ │
│ ├── Request(id, payload) │
│ ├── Cancel(id) │
│ ├── Progress(value) │
│ └── Shutdown() │
│ │
│ The receiver's switch is exhaustive: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ switch (message) { │ │
│ │ case Request(...): process(...) │ │
│ │ case Cancel(...): cancel(...) │ │
│ │ case Progress(...): update(...) │ │
│ │ case Shutdown(): shutdown() │ │
│ │ } │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Adding a new message type requires updating the switch. │
│ The compiler rejects a non-exhaustive switch. │
│ │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Handshake | Exchange of SendPorts between isolates |
SendPort | Sendable; the only sendable port type |
ReceivePort | Not sendable; the receiver side |
| Initial message | Carries the main isolate’s SendPort |
| Reply message | Carries the worker’s SendPort |
| Correlation ID | Matches responses to requests |
| Tagged messages | Distinguish message types |
| Sealed classes | Type-safe protocol with exhaustive matching |
| Streaming | Worker sends partial results over time |
| Cancellation | Main sends a cancel message |
| Heartbeat | Detects dead channels |
| Shutdown | Explicit message before kill() |
Key takeaways:
- A
SendPortis sendable; aReceivePortis not. Bidirectional communication requires each isolate to send the other itsSendPort. The initial message is the natural place for the main isolate’s port; the worker’s first reply carries its port. - The handshake establishes the channel. Until both sides have exchanged ports, communication is one-directional. A
Completermakes awaiting the handshake clean in asynchronous code. - Message types need a convention. Tagged maps are simple and flexible. Sealed classes are type-safe and exhaustive. Both prevent the receiver from guessing what each message means.
- Correlation IDs enable concurrent requests. A single channel can carry many in-flight requests if each carries a unique ID and the response echoes it. The main isolate matches responses to their callers.
- Streaming is a sequence of messages. A long task can report progress by sending partial results. The main isolate updates the UI without waiting for the final result.
- Cancellation is cooperative. The main isolate sends a cancel message, and the worker checks a flag at safe points. The worker must participate; the runtime does not preempt it.
- The shutdown protocol must be explicit. A long-lived worker stays alive as long as its
ReceivePortis listened to. The main isolate sends a shutdown message, the worker closes its ports, and the main isolate callskill(). - Heartbeats detect dead channels. A channel can be silently broken if the worker hangs. Periodic ping/pong messages reveal the failure before the application stalls.
- Ports are resources. They must be closed. An unclosed
ReceivePortkeeps an isolate alive and leaks memory. The shutdown protocol closes them cleanly.
Remember: Bidirectional communication between isolates is built on two ReceivePorts and two SendPorts, one pair for each direction. The main isolate creates its port, spawns the worker with that port’s SendPort, and waits for the worker to send its own SendPort back. After that handshake, both directions are open. Messages are tagged or typed so the receiver knows what each one means. Correlation IDs match responses to requests. Streaming and cancellation extend the protocol beyond simple request-response. The shutdown protocol ensures that ports are closed and isolates are killed cleanly. The isolate boundary is never crossed; every message is copied, and every port is a distinct channel. The handshake is the mechanism that makes the channel usable in both directions without breaking the isolation guarantee.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!