| |

Dart 53 🎯 Multithreading with Isolates: Isolate.spawn and Message Passing

⏱️ Estimated read: 19 min | |

Dart’s single-threaded event loop is efficient for I/O-bound work, but it cannot help with CPU-bound work. A long computation blocks the event loop, freezes the UI, and prevents timers from firing. The solution is not threads, because Dart does not expose shared-memory threads. The solution is isolates: independent units of execution with their own heap, their own event loop, and no shared mutable state. Isolates run concurrently, on separate threads managed by the Dart VM, and communicate only by passing messages. This chapter examines how isolates are spawned, how they exchange data, and what patterns make message-passing concurrency practical in real applications.

Key point: An isolate is not a thread. It is an independent Dart program with its own memory heap. Nothing is shared. Every value that crosses the boundary is copied or transferred, and every interaction is a message. The absence of shared state is what makes isolates safe, and the cost of copying is what makes them expensive to use carelessly.


Why isolates exist

The CPU-bound problem. Network requests, file reads, and user input are I/O-bound: the work happens outside the CPU, and the event loop waits for a completion event. A long computation is CPU-bound: the work happens on the CPU, and the event loop cannot process any event until the computation finishes. A sort of a million records, an image decode, or a cryptographic hash will freeze the UI if run on the main isolate.

The shared-memory problem. Traditional threads share memory, which requires locks, atomics, and careful synchronization. Every shared access is a potential race condition, and race conditions produce bugs that appear only under specific timing. Dart’s designers judged this complexity too high for application code and chose isolation instead.

The isolation model. Each isolate has its own heap, its own garbage collector, and its own event loop. No object is ever accessible from two isolates at once. There is no lock, no mutex, and no atomic operation in application code, because there is nothing to synchronize. The cost is that data must be copied between isolates, and the benefit is that concurrency bugs of the shared-memory kind are structurally impossible.

The parallelism requirement. A multi-core device can run multiple isolates truly in parallel, each on a different core. The main isolate renders frames while a worker isolate processes data. The two do not contend for locks because they do not share memory.

The message-passing requirement. Since isolates cannot share objects, they must communicate by sending messages. A message is a value that is copied from the sender’s heap to the receiver’s heap. The sender and receiver never touch the same memory, so there is no race. The cost is the copy, and the design of the application determines how much copying is required.

The framework integration. Flutter’s compute function, the Isolate.run API, and the Isolate.spawn API all wrap the same primitive: spawn a new isolate, send it a message, wait for a reply, and shut it down. Understanding the primitive makes all three APIs predictable.


a. Spawning isolates with Isolate.spawn

The Isolate.spawn function is the low-level API for creating an isolate. It takes an entry-point function and a message, and returns a future that resolves to an Isolate object.

The entry point

The entry-point function runs in the new isolate. It receives the initial message as its only argument. The function must be a top-level or static function; it cannot be a closure that captures variables from the spawning isolate, because those variables live in the spawning isolate’s heap.

import 'dart:isolate';

void workerEntry(SendPort mainSendPort) {
  // Runs in the new isolate.
  mainSendPort.send('hello from worker');
}

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

  receivePort.listen((message) {
    print(message);  // 'hello from worker'
  });
}

The SendPort is the worker’s channel back to the main isolate. The ReceivePort on the main isolate receives messages sent through that port.

The SendPort and ReceivePort pair

A ReceivePort creates a stream of messages. A SendPort is a handle that can be used to send messages to the corresponding ReceivePort. Ports are the only way isolates communicate.

// Main isolate creates a port for replies.
final replyPort = ReceivePort();

// Main isolate sends its own SendPort to the worker.
await Isolate.spawn(workerEntry, replyPort.sendPort);

The SendPort can be sent to another isolate as part of a message. This is how two isolates establish bidirectional communication: each sends the other its send port.

The bidirectional channel

A common pattern is for the main isolate to send a SendPort in the initial message, so the worker can reply. The main isolate then listens on its ReceivePort.

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'
    }
  });
}

The first message from the worker is its send port, which the main isolate stores for later use.

The isolate lifecycle

An isolate runs until its entry-point function returns. Once the function returns, the isolate exits, and any ports it created become unusable. If the isolate is meant to be long-lived, the entry-point function must not return; it must instead wait for messages indefinitely.

void workerEntry(SendPort mainPort) {
  final port = ReceivePort();
  mainPort.send(port.sendPort);
  // No return: the isolate stays alive as long as the port is listened to.
}

A long-lived isolate’s listen callback keeps the isolate alive. When the main isolate calls isolate.kill(), the worker is terminated.

The isolate object

Isolate.spawn returns a future that resolves to an Isolate object. The object exposes kill(), pause(), and resume() methods, and a controlPort for VM-level messages. Most applications only need kill().

final isolate = await Isolate.spawn(workerEntry, mainPort.sendPort);
// ...
isolate.kill(priority: Isolate.immediate);

Isolate.immediate terminates the isolate without waiting for pending messages. Isolate.beforeNextEvent lets pending microtasks drain first.


b. Message passing and the transferable types

An isolate message is a value sent through a SendPort. The value is copied into the receiver’s heap, with exceptions for certain types that can be transferred instead of copied.

Copyable types

Most simple values are copied: numbers, booleans, strings, lists, maps, sets, and objects of classes that are deeply serializable. The copy is deep: nested collections and objects are copied recursively.

void workerEntry(SendPort mainPort) {
  // The list is copied into the worker's heap.
  final data = [1, 2, 3, 4, 5];
  mainPort.send(data);
}

The Dart VM’s copy-on-send means that the sender’s list and the receiver’s list are independent. Mutating one does not affect the other.

Non-copyable types

Some values cannot be copied. ReceivePort, SendPort, and Isolate objects are transferable but not copyable. Closures that capture variables cannot be sent. Native resources like file handles and socket descriptors cannot be sent.

// Error: closures cannot be sent.
mainPort.send(() => print('hello'));
// UnsupportedError: Cannot copy a closure.

A closure that captures nothing may be sendable in some cases, but the rule is that closures are generally unsendable, and the safe approach is to send data and let the receiver construct the closure.

The TransferableTypedData optimization

Copying a large Uint8List is expensive. The TransferableTypedData class allows a typed data buffer to be transferred instead of copied. After transfer, the sender’s reference is emptied, and the receiver owns the buffer.

final buffer = Uint8List(1024 * 1024);  // 1 MB
final transferable = TransferableTypedData.fromList([buffer]);
mainPort.send(transferable);
// The buffer is now owned by the receiver.
// The sender's reference is null.

The transfer is constant-time. This is the mechanism that makes large payloads practical between isolates.

The portability of ports

A SendPort can be sent to another isolate. A ReceivePort cannot. The reason is that a ReceivePort has a stream and callbacks attached, which cannot be moved across isolates. The SendPort is just an address, so it can be shared.

final port = ReceivePort();
final otherPort = ReceivePort();
await Isolate.spawn(worker, otherPort.sendPort);
// port.sendPort can be sent. port cannot.

This is why the bidirectional pattern works: the main isolate sends its SendPort, not its ReceivePort.

The message size

Every message is copied (or transferred). For small messages, the copy is negligible. For large messages, the copy is the dominant cost. An isolate that sends a large list on every message spends most of its time serializing and deserializing. The fix is to send small messages, or to use TransferableTypedData for large buffers.

// Slow: copying a large list on every message.
mainPort.send(List.generate(1000000, (i) => i));

// Fast: transfer the underlying buffer.
final data = Uint8List.fromList(List.generate(1000000, (i) => i));
mainPort.send(TransferableTypedData.fromList([data]));

c. Patterns for isolate-based concurrency

Isolates are powerful but expensive to create. The patterns in this section are the ones that appear most often in real applications.

The one-shot worker

The simplest pattern is a short-lived isolate that performs one task and exits. Isolate.run wraps this pattern in a single function.

final result = await Isolate.run(() {
  return heavyComputation(data);
});

The function runs in a new isolate, and its return value is sent back. The isolate exits when the function returns. This is the recommended API for one-off CPU-bound tasks.

The long-lived worker pool

For repeated tasks, spawning a new isolate per task is wasteful. A long-lived worker isolate handles many messages and stays alive until the main isolate shuts it down.

class Worker {
  final SendPort _sendPort;
  final ReceivePort _receivePort;

  Worker._(this._sendPort, this._receivePort);

  static Future<Worker> spawn() async {
    final receivePort = ReceivePort();
    final isolate = await Isolate.spawn(_entry, receivePort.sendPort);

    final sendPortCompleter = Completer<SendPort>();
    receivePort.listen((message) {
      if (message is SendPort) {
        sendPortCompleter.complete(message);
      }
    });

    final sendPort = await sendPortCompleter.future;
    return Worker._(sendPort, receivePort);
  }

  static void _entry(SendPort mainPort) {
    final port = ReceivePort();
    mainPort.send(port.sendPort);
    port.listen((message) {
      // Process message.
      mainPort.send(process(message));
    });
  }

  void send(Object message) => _sendPort.send(message);
  void dispose() {
    _receivePort.close();
  }
}

The worker stays alive as long as its ReceivePort is listened to. The main isolate holds the worker’s SendPort and uses it to send tasks. A pool of several workers allows parallelism across cores.

The pipeline pattern

A pipeline is a chain of isolates, each performing one stage of a computation. Stage one sends its output to stage two, which sends its output to stage three, and so on. Each stage runs in its own isolate, and the stages operate concurrently.

// Stage 1: parse.
// Stage 2: transform.
// Stage 3: format.
// Each stage is an isolate with its own receive and send ports.

The pipeline is useful when each stage has a different cost, and the stages can run at different rates. The isolate’s message queue provides natural buffering between stages.

The scatter-gather pattern

Scatter-gather distributes a task across multiple workers and collects their results. The main isolate splits the work into chunks, sends each chunk to a worker, and combines the results as they return.

final chunks = splitWork(data, workerCount);
final results = await Future.wait(chunks.map((chunk) => worker.run(chunk)));
final combined = combine(results);

The workers run in parallel on separate cores. The main isolate is free to render frames while they work. This is the pattern for parallel image processing, large data transforms, and any workload that can be partitioned.

The periodic worker

A long-lived isolate can perform work on a timer without blocking the main isolate. The worker listens on its port and uses Timer.periodic to schedule its work. This is useful for background synchronization, heartbeat checks, and polling.

void pollingEntry(SendPort mainPort) {
  final port = ReceivePort();
  mainPort.send(port.sendPort);
  Timer.periodic(Duration(seconds: 30), (_) {
    mainPort.send('heartbeat');
  });
}

The main isolate processes the heartbeats as events on its event loop.

The cleanup pattern

An isolate must be explicitly terminated with kill(). If it holds resources, the entry-point function should handle cleanup when the isolate is terminated. This is done by listening on the port for a shutdown message and closing resources before returning.

void workerEntry(SendPort mainPort) {
  final port = ReceivePort();
  mainPort.send(port.sendPort);
  port.listen((message) {
    if (message == 'shutdown') {
      // Clean up resources.
      port.close();
    }
  });
}

The kill() call is immediate and does not run cleanup code. The worker must handle a shutdown message before the main isolate calls kill().


Complete Example Session

// ============================================
// PART 1: THE PROBLEM — CPU-BOUND WORK
// ============================================

// A long computation on the main isolate blocks the UI.
int slowComputation(List<int> data) {
  var total = 0;
  for (var i = 0; i < data.length; i++) {
    for (var j = 0; j < 1000; j++) {
      total += data[i] * j;
    }
  }
  return total;
}

void main() {
  final data = List.generate(1000000, (i) => i);
  final result = slowComputation(data);  // Blocks everything.
  print(result);
}

// ============================================
// PART 2: THE SIMPLEST FIX — Isolate.run
// ============================================

import 'dart:isolate';

void main() async {
  final data = List.generate(1000000, (i) => i);
  final result = await Isolate.run(() => slowComputation(data));
  print(result);
}

// The computation runs on a new isolate.
// The main isolate remains responsive.

// ============================================
// PART 3: THE MANUAL SPAWN
// ============================================

void workerEntry(SendPort mainPort) {
  final result = slowComputation(List.generate(1000000, (i) => i));
  mainPort.send(result);
}

void main() async {
  final port = ReceivePort();
  await Isolate.spawn(workerEntry, port.sendPort);
  port.listen((message) {
    print('Result: $message');
    port.close();
  });
}

// ============================================
// PART 4: THE BIDIRECTIONAL CHANNEL
// ============================================

void echoWorker(SendPort mainPort) {
  final port = ReceivePort();
  mainPort.send(port.sendPort);
  port.listen((message) {
    mainPort.send('echo: $message');
  });
}

void main() async {
  final mainPort = ReceivePort();
  await Isolate.spawn(echoWorker, mainPort.sendPort);
  late SendPort workerPort;
  mainPort.listen((message) {
    if (message is SendPort) {
      workerPort = message;
      workerPort.send('hello');
    } else {
      print(message);
    }
  });
}

// ============================================
// PART 5: TRANSFERRING LARGE BUFFERS
// ============================================

void bufferWorker(SendPort mainPort) {
  final port = ReceivePort();
  mainPort.send(port.sendPort);
  port.listen((message) {
    if (message is TransferableTypedData) {
      final data = message.materialize().asUint8List();
      mainPort.send(data.length);
    }
  });
}

void main() async {
  final mainPort = ReceivePort();
  await Isolate.spawn(bufferWorker, mainPort.sendPort);
  late SendPort workerPort;
  mainPort.listen((message) {
    if (message is SendPort) {
      workerPort = message;
      final buffer = Uint8List(1024 * 1024);
      workerPort.send(TransferableTypedData.fromList([buffer]));
    } else {
      print('Received $message bytes');
    }
  });
}

// ============================================
// PART 6: THE WORKER POOL
// ============================================

class WorkerPool {
  final List<SendPort> _workers;
  final ReceivePort _resultPort;
  int _next = 0;

  WorkerPool._(this._workers, this._resultPort);

  static Future<WorkerPool> spawn(int count) async {
    final workers = <SendPort>[];
    final resultPort = ReceivePort();
    for (var i = 0; i < count; i++) {
      final completer = Completer<SendPort>();
      final port = ReceivePort();
      await Isolate.spawn(_poolEntry, [port.sendPort, resultPort.sendPort]);
      port.listen((msg) {
        if (msg is SendPort) completer.complete(msg);
      });
      workers.add(await completer.future);
    }
    return WorkerPool._(workers, resultPort);
  }

  static void _poolEntry(List<SendPort> ports) {
    final [mainPort, resultPort] = ports;
    final port = ReceivePort();
    mainPort.send(port.sendPort);
    port.listen((task) {
      resultPort.send(heavyTask(task));
    });
  }

  void run(Object task, void Function(Object) onResult) {
    final worker = _workers[_next];
    _next = (_next + 1) % _workers.length;
    worker.send(task);
    _resultPort.listen(onResult);
  }
}

// ============================================
// PART 7: THE PIPELINE
// ============================================

// Stage 1: parse raw text into tokens.
// Stage 2: transform tokens.
// Stage 3: format the transformed tokens.
// Each stage is an isolate.
// Output of one stage is input to the next.

// ============================================
// PART 8: SCATTER-GATHER
// ============================================

Future<List<R>> scatterGather<T, R>(
  List<T> items,
  Future<R> Function(T) work,
  int parallel,
) async {
  final chunks = <List<T>>[];
  final chunkSize = (items.length / parallel).ceil();
  for (var i = 0; i < items.length; i += chunkSize) {
    chunks.add(items.sublist(i, i + chunkSize > items.length ? items.length : i + chunkSize));
  }
  final results = await Future.wait(chunks.map((chunk) => Isolate.run(() => chunk.map(work).toList())));
  return results.expand((x) => x).toList();
}

// ============================================
// PART 9: THE SHUTDOWN PATTERN
// ============================================

void shutdownWorker(SendPort mainPort) {
  final port = ReceivePort();
  mainPort.send(port.sendPort);
  port.listen((message) {
    if (message == 'shutdown') {
      // Clean up.
      port.close();
    }
  });
}

void shutdownMain() async {
  final mainPort = ReceivePort();
  final isolate = await Isolate.spawn(shutdownWorker, mainPort.sendPort);
  late SendPort workerPort;
  mainPort.listen((message) {
    if (message is SendPort) workerPort = message;
  });
  // Later:
  workerPort.send('shutdown');
  isolate.kill(priority: Isolate.immediate);
}

// ============================================
// PART 10: THE COMPLETE PICTURE
// ============================================

// 1. Isolate.spawn creates an isolate with a top-level entry point.
// 2. The initial message usually contains a SendPort for replies.
// 3. Messages are copied between heaps, except TransferableTypedData.
// 4. SendPort can be sent; ReceivePort cannot.
// 5. Isolate.run is the simple one-shot API.
// 6. Long-lived workers require a pool and manual lifecycle.
// 7. Cleanup happens through a shutdown message before kill().
// 8. CPU-bound work belongs in an isolate. I/O-bound work does not.

The ten parts progressed from the blocking problem through Isolate.run, manual spawn, bidirectional channels, buffer transfer, worker pools, pipelines, scatter-gather, shutdown, and the complete pattern. The same computation scenarios ran through every part, showing how message passing fits together.


Quick Reference

Isolate APIs

APIUse CaseReturns
Isolate.spawnLow-level spawnFuture<Isolate>
Isolate.runOne-shot CPU taskFuture<R>
computeFlutter convenienceFuture<R>
Isolate.spawnUriSpawn from another fileFuture<Isolate>

Port Types

TypeSendablePurpose
SendPortYesSend messages to a receive port
ReceivePortNoReceives messages from send ports

Message Types

TypeCopy or Transfer
Numbers, strings, booleansCopy
Lists, maps, setsCopy (deep)
User objectsCopy (deep)
SendPortTransfer
TransferableTypedDataTransfer (zero copy)
Closures with capturesNot sendable
Native handlesNot sendable

Isolate vs Thread

PropertyIsolateThread
Shared memoryNoYes
CommunicationMessage passingShared variables
SynchronizationNot neededLocks, atomics
Copy costPer messageNone
ParallelismYes (multi-core)Yes (multi-core)

Best Practices

✅ Do This:

// Use Isolate.run for one-shot CPU-bound work.
final result = await Isolate.run(() => heavyCompute(data));  // ✅

// Send SendPort, not ReceivePort.
mainPort.send(port.sendPort);  // ✅

// Transfer large buffers.
mainPort.send(TransferableTypedData.fromList([data]));  // ✅

// Keep messages small.
mainPort.send({'id': 1, 'result': 42});  // ✅

// Handle shutdown explicitly.
if (message == 'shutdown') { port.close(); }  // ✅

// Use a pool for repeated tasks.
// Spawning per task is wasteful.  // ✅

// Run CPU-bound work off the main isolate.
// The UI stays responsive.  // ✅

❌ Don’t Do This:

// Don't capture variables in the spawn function.
Isolate.spawn(() => print(x), port);  // ❌ Closures not sendable

// Don't send ReceivePort.
mainPort.send(receivePort);  // ❌ Not sendable

// Don't send large lists on every message.
mainPort.send(List.generate(1000000, (i) => i));  // ❌ Copy cost

// Don't forget to close ReceivePorts.
port.listen((_) {});  // ❌ Leaks if not closed

// Don't spawn an isolate per task.
// The overhead dominates the work.  // ❌

// Don't expect shared mutable state.
// Each isolate has its own heap.  // ❌

// Don't use isolates for I/O.
// The event loop handles I/O fine.  // ❌

Common Pitfalls

PitfallWhy It HappensFix
UnsupportedErrorSending a closureMove function to top level
SendPort missingReceivePort not sharedSend the SendPort in the initial message
Slow transfersCopying large listsUse TransferableTypedData
Port leaksReceivePorts not closedClose ports when done
Isolate exits earlyEntry function returnsKeep the port listening
No parallelismTasks serialized on one workerSpawn a pool
Hang on shutdownWorker not handling shutdownSend shutdown message before kill
Memory growsMany long-lived isolatesPool workers, limit count

Real-World Examples

1. One-Shot Compute

final result = await Isolate.run(() => fibonacci(40));

2. Manual Spawn

await Isolate.spawn(worker, receivePort.sendPort);

3. Bidirectional Channel

mainPort.send(port.sendPort);  // Send send port
port.listen((msg) => mainPort.send('reply: $msg'));

4. Transfer Large Buffer

mainPort.send(TransferableTypedData.fromList([buffer]));

5. Worker Pool

class Pool { final workers = <SendPort>[]; }

6. Scatter-Gather

await Future.wait(chunks.map((c) => Isolate.run(() => process(c))));

7. Shutdown

port.listen((msg) { if (msg == 'shutdown') port.close(); });

8. Close Port

port.close();

9. Isolate Kill

isolate.kill(priority: Isolate.immediate);

10. Detect a SendPort Message

if (message is SendPort) { workerPort = message; }

Visual

Isolate Architecture

┌────────────────────────────────────────────────────────────┐
│  ISOLATES: SEPARATE HEAPS, MESSAGE PASSING                 │
│                                                            │
│  ┌────────────────────────────┐  ┌──────────────────────┐ │
│  │  MAIN ISOLATE              │  │  WORKER ISOLATE      │ │
│  │                            │  │                      │ │
│  │  ┌──────────────────────┐  │  │  ┌────────────────┐  │ │
│  │  │ Heap A               │  │  │  │ Heap B         │  │ │
│  │  │  obj1, obj2, obj3    │  │  │  │ (separate)     │  │ │
│  │  └──────────────────────┘  │  │  └────────────────┘  │ │
│  │                            │  │                      │ │
│  │  ┌──────────────────────┐  │  │  ┌────────────────┐  │ │
│  │  │ Event loop A         │  │  │  │ Event loop B   │  │ │
│  │  └──────────────────────┘  │  │  └────────────────┘  │ │
│  │                            │  │                      │ │
│  │  ReceivePort ────┐         │  │         ┌──────────┐ │ │
│  │                  │         │  │         │ReceivePort│ │ │
│  │  SendPort ───────┼─────────┼──┼────────▶│          │ │ │
│  │                  │         │  │         └──────────┘ │ │
│  │                  │         │  │                      │ │
│  │                  ◀─────────┼──┼───────── SendPort ───│ │
│  └────────────────────────────┘  └──────────────────────┘ │
│                                                            │
│  No shared memory. Every exchange is a message copy.       │
│                                                            │
└────────────────────────────────────────────────────────────┘

Message Transfer

┌────────────────────────────────────────────────────────────┐
│  COPY vs TRANSFER                                          │
│                                                            │
│  COPY (default)                                            │
│  ┌──────────────────────┐    ┌──────────────────────┐     │
│  │  Sender Heap         │    │  Receiver Heap       │     │
│  │  [1, 2, 3, 4, 5]     │───▶│  [1, 2, 3, 4, 5]     │     │
│  │  (original intact)   │    │  (copy created)      │     │
│  └──────────────────────┘    └──────────────────────┘     │
│  Cost: proportional to size. Both copies exist.            │
│                                                            │
│  TRANSFER (TransferableTypedData)                          │
│  ┌──────────────────────┐    ┌──────────────────────┐     │
│  │  Sender Heap         │    │  Receiver Heap       │     │
│  │  [buffer]            │───▶│  [same buffer]       │     │
│  │  (reference nulled)  │    │  (owns buffer)       │     │
│  └──────────────────────┘    └──────────────────────┘     │
│  Cost: constant. Only one copy exists.                     │
│                                                            │
└────────────────────────────────────────────────────────────┘

The Worker Pool

┌────────────────────────────────────────────────────────────┐
│  WORKER POOL: PARALLELISM ACROSS CORES                     │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  MAIN ISOLATE                                     │     │
│  │    Splits work, sends chunks, collects results.   │     │
│  └────┬───────────┬───────────┬───────────┬─────────┘     │
│       │           │           │           │                │
│       v           v           v           v                │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐         │
│  │Worker 1 │ │Worker 2 │ │Worker 3 │ │Worker 4 │         │
│  │Core 1   │ │Core 2   │ │Core 3   │ │Core 4   │         │
│  │         │ │         │ │         │ │         │         │
│  │ chunk 1 │ │ chunk 2 │ │ chunk 3 │ │ chunk 4 │         │
│  └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘         │
│       │           │           │           │                │
│       └───────────┴─────┬─────┴───────────┘                │
│                         │                                  │
│                         v                                  │
│  ┌──────────────────────────────────────────────────┐     │
│  │  MAIN ISOLATE                                     │     │
│  │    Collects results, combines them.               │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  True parallelism: one worker per core.                    │
│  Main isolate remains responsive.                          │
│                                                            │
└────────────────────────────────────────────────────────────┘

The Lifecycle

┌────────────────────────────────────────────────────────────┐
│  ISOLATE LIFECYCLE                                         │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  1. SPAWN                                         │     │
│  │     Isolate.spawn(entry, initialMessage)          │     │
│  │     New heap created. Entry function starts.      │     │
│  └──────────────────────────────────────────────────┘     │
│                        │                                   │
│                        v                                   │
│  ┌──────────────────────────────────────────────────┐     │
│  │  2. HANDSHAKE                                     │     │
│  │     Worker sends its SendPort to main.            │     │
│  │     Main stores it.                               │     │
│  └──────────────────────────────────────────────────┘     │
│                        │                                   │
│                        v                                   │
│  ┌──────────────────────────────────────────────────┐     │
│  │  3. COMMUNICATION                                 │     │
│  │     Main sends tasks. Worker sends results.       │     │
│  │     Messages are copied.                          │     │
│  └──────────────────────────────────────────────────┘     │
│                        │                                   │
│                        v                                   │
│  ┌──────────────────────────────────────────────────┐     │
│  │  4. SHUTDOWN                                      │     │
│  │     Main sends 'shutdown'.                        │     │
│  │     Worker closes its ReceivePort.                │     │
│  │     Main calls isolate.kill().                    │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The entry function returning also exits the isolate.      │
│                                                            │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
IsolateIndependent Dart program with own heap and event loop
Isolate.spawnLow-level API to create an isolate
Isolate.runOne-shot API for a single task
SendPortSendable handle for sending messages
ReceivePortReceives messages, not sendable
MessageA value copied or transferred between heaps
TransferableTypedDataZero-copy transfer of large buffers
Entry pointTop-level or static function
ShutdownSend message, then kill()
Worker poolLong-lived isolates for repeated tasks
Scatter-gatherSplit work, dispatch to pool, combine results

Key takeaways:

  • An isolate is not a thread. It is an independent Dart program with its own heap, event loop, and garbage collector. Nothing is shared, so there are no locks and no race conditions of the shared-memory kind.
  • Isolates communicate by message passing. A SendPort can be sent, a ReceivePort cannot. The bidirectional pattern is to send a SendPort in the initial message so the worker can reply.
  • Messages are copied, not shared. Most values are deep-copied. TransferableTypedData is the exception: the buffer is transferred with zero copy, and the sender’s reference becomes empty.
  • Isolate.run is the simple API. For one-shot CPU-bound tasks, Isolate.run handles spawn, message, result, and shutdown in one call. Manual Isolate.spawn is only needed for long-lived workers.
  • The entry point must be a top-level or static function. Closures that capture variables cannot be sent to another isolate. The function must be self-contained.
  • A worker pool provides parallelism for repeated tasks. Spawning an isolate per task is expensive. A pool of long-lived workers distributes tasks across cores and reuses the isolates.
  • CPU-bound work belongs in an isolate. I/O-bound work does not need one; the event loop handles it. The rule of thumb is: if the work keeps the CPU busy for more than a few milliseconds, offload it.
  • Shutdown is explicit. The worker must handle a shutdown message and close its ports. The main isolate then calls kill(). An isolate whose entry function returns also exits.
  • The copy cost is the main trade-off. Small messages are cheap. Large messages are expensive unless they are transferred. Design the message protocol to keep payloads small or to use TransferableTypedData.

Remember: Dart’s concurrency model is isolates, not threads. Each isolate has its own heap and its own event loop, and communication between isolates happens by sending messages through ports. This design eliminates the class of bugs that shared-memory threading produces, at the cost of copying every value that crosses the boundary. For one-off CPU-bound tasks, Isolate.run is the simplest API. For repeated work, a pool of long-lived workers keeps isolates alive and distributes tasks across cores. Messages are copied by default; TransferableTypedData provides zero-copy transfer for large buffers. Entry points must be top-level or static. Shutdown is explicit, and a worker’s entry function must keep listening to stay alive. The result is concurrency that is safe by construction, with a cost that the application design determines.


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!