| |

Dart 55 🎯 Bidirectional Isolate Communication via ReceivePort and SendPort

⏱️ Estimated read: 19 min | |

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

TypeSendableDirection
SendPortYesOne-way (sends to a ReceivePort)
ReceivePortNoOne-way (receives from many SendPorts)

The Handshake

StepAction
1Main creates ReceivePort
2Main spawns isolate with mainPort.sendPort
3Worker creates own ReceivePort
4Worker sends workerPort.sendPort to main
5Main stores the worker’s SendPort
6Both sides can now send

Message Protocols

PatternUse Case
Tagged mapsSimple dynamic protocols
Sealed classesType-safe exhaustive handling
Correlation IDMultiple concurrent requests
Protocol versionCompatibility checking
HeartbeatLiveness detection

Common Patterns

PatternDirectionUse Case
Request-responseBidirectionalRPC-style calls
StreamingWorker to mainProgress updates
CancellationMain to workerStop long tasks
Pub-subWorker to mainEvent broadcasting
PoolBidirectionalParallel 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

PitfallWhy It HappensFix
UnsupportedErrorSending a ReceivePortSend only SendPort
Worker never respondsHandshake not completedSend SendPort in initial message
Response mixed upNo correlation IDInclude and echo an ID
Worker exits earlyEntry function returnsKeep the port listening
Port leakReceivePort not closedClose on shutdown
Lost messagesMessages sent before handshakeWait for the handshake
Hung channelWorker not respondingAdd heartbeats
Protocol mismatchNew main, old workerInclude 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

ItemValue
HandshakeExchange of SendPorts between isolates
SendPortSendable; the only sendable port type
ReceivePortNot sendable; the receiver side
Initial messageCarries the main isolate’s SendPort
Reply messageCarries the worker’s SendPort
Correlation IDMatches responses to requests
Tagged messagesDistinguish message types
Sealed classesType-safe protocol with exhaustive matching
StreamingWorker sends partial results over time
CancellationMain sends a cancel message
HeartbeatDetects dead channels
ShutdownExplicit message before kill()

Key takeaways:

  • A SendPort is sendable; a ReceivePort is not. Bidirectional communication requires each isolate to send the other its SendPort. 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 Completer makes 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 ReceivePort is listened to. The main isolate sends a shutdown message, the worker closes its ports, and the main isolate calls kill().
  • 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 ReceivePort keeps 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.


Was this helpful?


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!