ZetCode

Dart Isolates

last modified May 29, 2026

Bidirectional Communication

When a worker isolate needs to reply to the main isolate, both sides must exchange their own ports. The worker creates a ReceivePort, sends its SendPort as the first message, and the main isolate receives it. This establishes a two-way channel. The following example implements the ping-pong pattern: the main isolate sends 'ping' and the worker echoes back 'pong'.

pingpong.dart
import 'dart:isolate';

void worker(SendPort mainPort) {
  final workerPort = ReceivePort();
  mainPort.send(workerPort.sendPort); // handshake: send our port back

  workerPort.listen((msg) {
    print('Worker received: $msg');
    mainPort.send('pong');  // reply to main
    workerPort.close();
  });
}

void main() async {
  final mainPort = ReceivePort();
  await Isolate.spawn(worker, mainPort.sendPort);

  final stream = mainPort.asBroadcastStream();
  final SendPort workerPort = await stream.first; // receive worker's port

  workerPort.send('ping');
  final reply = await stream.first;
  print('Main received: $reply'); // Main received: pong
  mainPort.close();
}

asBroadcastStream is used because two separate await expressions read from the same stream: the first value is the worker's SendPort from the handshake, and the second is the actual reply.

Sending Complex Data

Ports support primitives, List, Map, Record, and a handful of other built-in types. Instances of custom classes cannot be sent directly — the Dart VM cannot serialize them across an isolate boundary. The standard workaround is to convert the object to a map or record before sending and reconstruct it on the receiving side.

complexdata.dart
import 'dart:isolate';

void worker(SendPort port) {
  // Custom objects cannot cross isolate boundaries directly.
  // Encode as a Map instead and reconstruct on the other side.
  final data = {
    'name': 'Alice',
    'scores': [95, 87, 92],
    'meta': {'level': 3, 'active': true},
  };
  port.send(data);
}

void main() async {
  final receivePort = ReceivePort();
  await Isolate.spawn(worker, receivePort.sendPort);

  final Map data = await receivePort.first;
  print('Name: ${data['name']}');     // Name: Alice
  print('Scores: ${data['scores']}'); // Scores: [95, 87, 92]
  receivePort.close();
}

The received map is a deep copy of the original. Mutations on it do not affect the data in the worker isolate — this copy-on-send rule applies to every message passed between isolates.

Isolate Termination

An isolate can be stopped in three ways. Isolate.kill() interrupts it from the outside with no opportunity for cleanup. The onExit port receives a message when the isolate finishes naturally, which is useful for lifecycle tracking. Isolate.exit(port, value) lets the isolate send a final value and terminate immediately; the object is transferred without an extra copy, making it more efficient than a send() followed by a normal return.

termination.dart
import 'dart:isolate';

void worker(SendPort port) {
  final result = List.generate(5, (i) => i * i); // [0,1,4,9,16]
  // Isolate.exit sends the value and terminates without an extra copy.
  Isolate.exit(port, result);
}

void main() async {
  final dataPort = ReceivePort();
  final exitPort = ReceivePort();

  await Isolate.spawn(worker, dataPort.sendPort,
      onExit: exitPort.sendPort);

  final result = await dataPort.first;
  print('Result: $result');       // Result: [0, 1, 4, 9, 16]

  await exitPort.first;           // wait until the isolate is gone
  print('Isolate has exited.');
  dataPort.close();
  exitPort.close();
}

The onExit port delivers null unless a custom exit value is configured. In the example above the data message is already sent via Isolate.exit, so exitPort receives null as the termination signal.

Error Handling

When an unhandled exception occurs inside an isolate, the isolate terminates but the rest of the application keeps running. Without an onError port that failure would go unnoticed. When the port is set, a message arrives as a two-element list: the first element is the error description string and the second is the stack trace as text.

errors.dart
import 'dart:isolate';

void worker(SendPort port) {
  port.send('starting');
  throw StateError('something went wrong'); // unhandled exception
}

void main() async {
  final dataPort = ReceivePort();
  final errPort  = ReceivePort();

  await Isolate.spawn(worker, dataPort.sendPort,
      onError: errPort.sendPort);

  dataPort.listen((msg) => print('Data: $msg'));

  errPort.listen((err) {
    // err arrives as [errorMessage, stackTrace]
    print('Caught: ${(err as List)[0]}');
    dataPort.close();
    errPort.close();
  });

  await Future.delayed(Duration(seconds: 1));
}

Setting onError is especially important in production code, where silent failures can cause hard-to-trace bugs. An error caught through the port cannot bring down the whole application.

Parallel List Processing

The following example demonstrates the basic map-and-aggregate pattern for parallel data processing. A list of one thousand numbers is split into four equal partitions. Each partition is handed to its own isolate, which computes a partial sum and sends it back. The main isolate collects all partial results and adds them together. The sum of the numbers from 1 to 1000 is 500,500.

parallel.dart
import 'dart:isolate';

// Worker receives [SendPort, List<int>] and replies with the partition sum.
void partitionWorker(List<dynamic> args) {
  final SendPort port = args[0];
  final List<int> nums = List<int>.from(args[1]);
  final int sum = nums.fold(0, (acc, x) => acc + x);
  port.send(sum);
}

Future<int> parallelSum(List<int> numbers, int workerCount) async {
  final size = (numbers.length / workerCount).ceil();
  final receivePort = ReceivePort();
  int spawned = 0;

  for (int i = 0; i < numbers.length; i += size) {
    final end = (i + size).clamp(0, numbers.length);
    await Isolate.spawn(
        partitionWorker, [receivePort.sendPort, numbers.sublist(i, end)]);
    spawned++;
  }

  int total = 0;
  int received = 0;
  await for (final msg in receivePort) {
    total += msg as int;
    if (++received == spawned) break; // all partitions collected
  }
  receivePort.close();
  return total;
}

void main() async {
  final numbers = List<int>.generate(1000, (i) => i + 1); // 1..1000
  final result = await parallelSum(numbers, 4);
  print('Sum 1..1000 = $result'); // Sum 1..1000 = 500500
}

In practice the worker count can be set to Platform.numberOfProcessors to maximise parallelism without unnecessary spawning overhead.

Source

Dart Isolate class, Dart concurrency documentation

This tutorial covered bidirectional isolate communication, sending complex data across isolate boundaries, graceful termination with Isolate.exit and the onExit port, error handling via onError, and a practical parallel list-processing pattern.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Dart tutorials.