ZetCode

Dart Isolates

last modified May 29, 2026

Introduction

A Dart isolate is an independent unit of execution that has its own memory heap. Unlike threads in most other languages, isolates never share memory. Instead they communicate exclusively by passing copies of objects through ports. This design eliminates data races without the need for locks or mutexes, making concurrent Dart code both safe and predictable.

Every Dart program already runs inside an isolate — the main isolate. Spawning additional isolates lets CPU-bound work run truly in parallel on multi-core hardware without blocking the main event loop.

main.dart
import 'dart:isolate';

// A top-level function is required as the isolate entry point.
void greet(String name) {
  print('Hello from an isolate, $name!');
}

void main() async {
  await Isolate.spawn(greet, 'World');

  // Give the isolate time to print before main exits.
  await Future.delayed(Duration(seconds: 1));
  print('Main isolate finished.');
}

Running this program prints a greeting from the spawned isolate and then a completion message from the main isolate. Both lines execute concurrently.

Isolates vs async/await and the Event Loop

async/await and Future schedule work on the event loop of the current isolate. They are great for I/O-bound tasks (network, file) because the isolate stays idle while waiting for the OS. However, heavy computation still blocks the event loop because it runs on the same thread. Spawning a separate isolate moves that work to a different thread entirely.

Featureasync/awaitIsolate
Execution threadSame thread (event loop)New OS thread
MemoryShared heapSeparate heap
Good forI/O-bound tasksCPU-bound tasks
CommunicationDirect (variables)Message passing (ports)
OverheadVery lowHigher (spawn cost)

The table above summarizes the key differences between the two approaches.

event_loop.dart
import 'dart:isolate';

// Heavy computation - blocks the event loop when run with async/await.
int heavySum(int n) {
  int total = 0;
  for (int i = 0; i < n; i++) total += i;
  return total;
}

void isolateEntry(SendPort port) {
  port.send(heavySum(100000000)); // runs on a separate thread
}

void main() async {
  // --- async/await approach (blocks event loop) ---
  final result1 = heavySum(100000000);
  print('async result: $result1');

  // --- isolate approach (non-blocking) ---
  final receivePort = ReceivePort();
  await Isolate.spawn(isolateEntry, receivePort.sendPort);
  final result2 = await receivePort.first;
  print('isolate result: $result2');
}

Both approaches ultimately return the same value, but they serve different purposes depending on the nature of the task. The isolate version leaves the main event loop entirely free to handle UI updates, timers, or network callbacks while a heavy computation runs in the background. Understanding when and where to use each is crucial for optimizing your app's performance. You should rely on async/await for I/O-bound tasks—such as fetching network data, reading files, or interacting with a database.

In UI development, you must use async/await for these operations in order not to block the UI thread while waiting for an external response. However, because async/await still executes on the main thread, running heavy data processing or complex mathematics there will cause the interface to freeze. Therefore, whenever you need to perform intensive, CPU-bound work (like parsing huge JSON payloads or filtering massive lists), you should offload it to an isolate, ensuring your application remains perfectly smooth and responsive.

Isolate.spawn

Isolate.spawn is the primary way to create an isolate. It takes a top-level (or static) function as the entry point and one argument to pass to it. The call returns a Future<Isolate> that completes once the isolate has been created.

spawn.dart
import 'dart:isolate';

// Entry point - must be a top-level or static function.
void worker(SendPort replyPort) {
  // This code executes in the new isolate.
  final result = List.generate(5, (i) => i * i); // [0,1,4,9,16]
  replyPort.send(result); // send result back to main
}

void main() async {
  // ReceivePort lets the main isolate listen for messages.
  final receivePort = ReceivePort();

  // Spawn the isolate, handing it our SendPort so it can reply.
  await Isolate.spawn(worker, receivePort.sendPort);

  // Await the single reply, then close the port.
  final data = await receivePort.first;
  print('Squares: $data'); // Squares: [0, 1, 4, 9, 16]
}

receivePort.first is a convenient shorthand that closes the port automatically after the first message arrives, avoiding resource leaks.

ReceivePort and SendPort

A ReceivePort is the listening end of a one-way channel and can only be owned by one isolate. Its paired SendPort can be copied and shared freely — even sent to other isolates. To set up bidirectional communication, each side creates its own ReceivePort and exchanges SendPorts during the handshake.

ports.dart
import 'dart:isolate';

void worker(SendPort mainPort) {
  // Create our own receive port and send its SendPort to main.
  final workerPort = ReceivePort();
  mainPort.send(workerPort.sendPort); // handshake

  workerPort.listen((msg) {
    print('Worker got: $msg');
    mainPort.send('Echo: $msg'); // reply
    workerPort.close();          // done - clean up
  });
}

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

  final stream = mainPort.asBroadcastStream();

  // Receive the worker's SendPort from the handshake.
  final SendPort workerPort = await stream.first;

  // Now send a message to the worker.
  workerPort.send('ping');

  // Receive the echo.
  final reply = await stream.first;
  print('Main got: $reply'); // Main got: Echo: ping
  mainPort.close();
}

The pattern — spawn, exchange ports, then communicate — is the standard Dart idiom for bidirectional isolate interaction.

Practical Example - Factorial in an Isolate

Factorial of a large number is a typical CPU-bound task. Moving it to a separate isolate keeps the main event loop responsive while the calculation runs.

factorial.dart
import 'dart:isolate';

// Worker receives [SendPort, n] and sends back n!
void factorialWorker(List<dynamic> args) {
  final SendPort port = args[0];
  final int n = args[1];

  BigInt result = BigInt.one;
  for (int i = 2; i <= n; i++) {
    result *= BigInt.from(i);
  }
  port.send(result);
}

void main() async {
  const int number = 20;
  final receivePort = ReceivePort();

  await Isolate.spawn(factorialWorker, [receivePort.sendPort, number]);

  final BigInt result = await receivePort.first;
  print('$number! = $result');
  // 20! = 2432902008176640000
  receivePort.close();
}

Using BigInt avoids integer overflow for larger inputs. The list [receivePort.sendPort, number] bundles multiple values into the single argument that Isolate.spawn allows.

Limitations

Isolates intentionally impose constraints to guarantee safety:

copy_semantics.dart
import 'dart:isolate';

void worker(SendPort port) {
  // The list sent here is a COPY - mutating it does not affect main.
  port.send([1, 2, 3]);
}

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

  final List<int> list = await receivePort.first;
  list.add(4); // only the local copy is modified
  print(list);  // [1, 2, 3, 4]
  receivePort.close();
}

Because every message is copied, isolates provide message-passing concurrency: safe by construction, with no shared mutable state to reason about.

Source

Dart Isolate class, Dart concurrency documentation

In this tutorial we covered the Dart Isolate API: how isolates differ from async/await, how to spawn them, exchange ports, pass data, and what constraints they impose.

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.