skip to content

Isolates & Concurrency

Isolates give Dart parallelism without shared memory: each has its own heap and event loop, and work moves between them as copied messages over ports. Interviewers probe concurrency vs parallelism.

part ofDartoverview, primer and where to startread it →
on this pageshow

explore

questions

10

In Dart, how do two isolates exchange data through SendPort and ReceivePort, and what happens to a mutable object you send?

level: juniorimportance: must knowfreq 62%

answer

  1. no shared mutable state between isolates
  2. ReceivePort exposes a sendPort getter
  3. send() returns without waiting
  4. mutable graphs copied, immutable ones shared
  5. receiver edits its own copy

basics

~20 s

Isolates share no mutable state, so they talk by messages: a ReceivePort receives them, and its sendPort getter gives a SendPort that any isolate can call send() on. Mutable objects arrive as copies; immutable ones such as strings are shared.

solid answer

~50 s

Each Dart isolate has its own memory and its own event loop, so the only way to pass data is a message. You create a `ReceivePort` in the isolate that wants to receive, and hand its `sendPort` to the other isolate, usually as the spawn argument. Calling `SendPort.send(message)` returns immediately; the message is delivered to the receiving port's listener as an ordinary event when that isolate's event loop gets to it. The message is not shared by reference: the mutable part of its object graph is copied during `send()`, while objects the VM knows are immutable, such as strings, are shared. So if the worker adds to a list it received, the sender's list is unchanged. One `ReceivePort` can have many `SendPort`s, and a `SendPort` can itself be sent, which is how two-way channels are built.

code

dart · 16 lines
dart
import 'dart:isolate';

Future<void> main() async {
  final replies = ReceivePort();
  final sizes = [640, 1280];
  await Isolate.spawn(_worker, (replies.sendPort, sizes));
  final fromWorker = await replies.first as List<int>;
  print(sizes); // [640, 1280]
  print(fromWorker); // [640, 1280, 1920]
}

void _worker((SendPort, List<int>) message) {
  final (replyTo, sizes) = message;
  sizes.add(1920); // changes the worker's copy only
  replyTo.send(sizes);
}

go deeper

for a junior

Recall the pair: a ReceivePort receives, its sendPort getter gives the SendPort others call send() on. Say clearly that a sent mutable object arrives as a copy.

for a middle

Explain that the copy happens inside send() on the sender, that delivery is an event on the receiver's loop, and that immutable objects like strings are shared rather than copied.

for a senior

Tie the copy cost to real symptoms: a huge message sent from the UI isolate costs frame time even though the work is offloaded, so you shape messages to be small or move bytes differently.

for a principal

Frame ports as the price of having no shared mutable state: no locks or races on Dart objects, but every boundary crossing is a copy you design and measure.

## Why isolates need ports at all A Dart **isolate** is an independent worker with its own memory and its own **event loop**. Dart code in one isolate cannot read or write a variable that lives in another: a top-level variable mutated in a spawned isolate stays untouched in the main isolate, because each isolate has its own copy of it. This is deliberate. With no shared mutable state there are no data races on Dart objects, no locks and no memory-visibility bugs between isolates. The price is that isolates cannot simply call each other or look at each other's objects. They cooperate by sending **messages**, and `dart:isolate` gives exactly two classes for that job, `ReceivePort` and `SendPort`. ## The two ends of a channel | Class | Lives in | What you do with it | |---|---|---| | `ReceivePort` | the isolate that receives | `listen` to it like a `Stream`, then `close()` it when finished | | `SendPort` | anywhere it has been passed | call `send(message)` to deliver into its `ReceivePort` | - A `ReceivePort` is created with its constructor, `ReceivePort()`. It is a **non-broadcast stream**: it buffers messages until a listener subscribes, and only one listener may subscribe. - Every `ReceivePort` has a **`sendPort` getter**. `SendPort` has no public constructor; you always get one from a receive port. - One `ReceivePort` can have many `SendPort`s pointing at it, and `SendPort`s compare equal when they point at the same receive port, even after being sent to another isolate. - A `SendPort` is itself sendable, so an isolate can pass its own `sendPort` to another isolate. That is how a reply channel, or a full two-way channel, is set up. ## What `send()` actually does 1. You call `sendPort.send(message)` in the sending isolate. 2. The runtime checks the message graph is sendable and copies whatever needs copying. This happens **during the call**, on the sending isolate, and can take time linear in the size of the object graph. 3. `send()` returns. It does **not** wait for the receiver to handle the message and it returns `void`, not a `Future`. 4. When the receiving isolate's event loop is free, the message is delivered to the `ReceivePort`'s listener as a normal data event. Because delivery is an event on the receiver's loop, a busy receiver simply handles the message later; the sender is never blocked by it. ## Copy versus share - **Mutable objects are copied.** Lists, maps, sets, typed-data buffers and instances of your own mutable classes arrive as a new object graph in the receiving isolate, and nothing links the copy back to the original. - **Immutable objects may be shared.** The `SendPort.send` documentation says objects the VM identifies as immutable, for example strings, are shared instead of copied. You cannot observe the difference, because nobody can mutate them. - **Mutation never travels back.** If the worker changes its copy, the sender sees nothing until the worker sends a new message with the result. - **Copying costs time and memory.** Sending a very large structure from the UI isolate of a Flutter app does the copy on that isolate, so it can cost frame time on its own. ## One direction per port A port pair is a **one-way** channel: messages flow from the `SendPort` holders into the single `ReceivePort`. That shapes every design built on it: - **Replies need their own port.** A request that expects an answer carries, or was preceded by, a `SendPort` that belongs to the requester. - **Many writers, one reader.** Several isolates can hold copies of one `SendPort`, and all of their messages end up at the same listener, which handles them one at a time on its event loop. - **Addresses travel, receivers do not.** A `SendPort` can be passed along inside any message, but the `ReceivePort` stays in the isolate that created it. ## A round trip in practice The usual first program spawns a worker with `Isolate.spawn`, passing the main isolate's `sendPort` so the worker can reply. The worker edits the list it received and sends it back; printing both lists shows two independent objects. `await replies.first` takes the first message and then cancels the subscription, which also closes the port, so the program can exit. ## Mistakes interviewers listen for - Saying isolates are threads that share objects. Isolates spawned into one isolate group do use one managed heap and share code internally, but no mutable Dart object is ever reachable from two isolates. - Expecting `send()` to be awaitable or to deliver a reply. Replies need a second port. - Believing every object is copied, strings included, or that nothing is copied because Dart passes references. - Forgetting that a `ReceivePort` accepts only one listener, and that an open port keeps its isolate alive until you close it.

  • If send() returns immediately, who pays for copying a large message?
    The sending isolate. The `SendPort.send` documentation says the send happens immediately and may cost time linear in the object graph it copies. So sending a big list from a Flutter UI isolate spends that copy time on the UI isolate, even though the processing then happens elsewhere.
  • How does the worker send an answer back if SendPort.send returns nothing?
    It needs a port that belongs to the caller. The caller creates a `ReceivePort`, sends or spawns with its `sendPort`, and the worker calls `send` on that. For a conversation in both directions the worker also creates its own `ReceivePort` and sends its `sendPort` back as its first message.
  • Can the same SendPort be used by several isolates at once?
    Yes. A `SendPort` can be sent to as many isolates as you like, and all of their messages arrive at the single `ReceivePort` it came from. Several `SendPort`s can point at one receive port, but each `SendPort` belongs to exactly one `ReceivePort`.

Two isolates are two kitchens with a pass-through hatch. A cook can push a plate through, but the other kitchen gets its own plate; whatever it adds never appears on the plate the first cook still holds.

saying these in an interview costs you the question

  • Isolates share objects like threads, so the worker's changes appear in the caller's list
  • SendPort.send returns a Future that completes with the reply
  • You create a SendPort with its own constructor
  • Every sent object is copied, even strings
  • send() blocks until the receiving isolate has handled the message
open as a page

In Dart, why doesn't marking a CPU-heavy function async keep the program responsive, and what does running it in another isolate change?

level: juniorimportance: must knowfreq 70%

basics

~20 s

An async function still runs on the calling isolate's single thread; its synchronous work blocks that isolate's event loop until the next await. Another isolate has its own heap and event loop and can run the work in parallel on another core.

open as a page

In Dart, which objects can you send through SendPort.send, and why does sending a closure sometimes fail with an illegal-argument error?

level: middleimportance: must knowfreq 48%

basics

~20 s

Between isolates sharing code, almost any object can be sent except native-resource holders (sockets, open files), ReceivePort, Finalizer and similar. spawnUri isolates accept only primitives, plain collections and ports. Closures fail when their captured context reaches something unsendable.

open as a page

In Dart, when would you use Isolate.run and when Isolate.spawn, for example to hash thousands of files in a backup CLI?

level: middleimportance: must knowfreq 58%

basics

~20 s

Isolate.run starts an isolate, runs one function, returns its result as a Future, rethrows its error, and exits. Isolate.spawn starts a long-lived worker from an entry point and a message, and you manage ports, results, errors and shutdown yourself.

open as a page

Why does a Dart command-line program or worker isolate keep running after its work is done, and how do you close ports so it exits?

level: middleimportance: should knowfreq 36%

basics

~20 s

An open ReceivePort or RawReceivePort keeps its isolate alive, so a forgotten port stops a worker, or a CLI program, from exiting. Close each port with close() or by cancelling its subscription; tell a worker to close its own port.

open as a page

In Dart, what happens when code that calls Isolate.run or Isolate.spawn is compiled for the web, and what are the alternatives?

level: middleimportance: should knowfreq 28%

basics

~20 s

The Dart web platform does not implement isolates: under the JavaScript compiler, calls such as Isolate.spawn throw UnsupportedError. Web code must run the work on the main thread, split into chunks, or move it into a separately compiled web worker.

open as a page

In Dart, how would you build a long-lived image-resizing worker isolate with two-way ports, and match each resize result to the job that requested it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Spawn the worker with your port's SendPort; the worker creates its own ReceivePort and sends back its SendPort. Tag every job with an id, keep a Completer per id, and have the worker reply with (id, result) or (id, RemoteError).

open as a page

In Dart, why is Isolate.spawn much cheaper than Isolate.spawnUri, and how does Isolate.exit hand a large result back without copying it?

level: seniorimportance: should knowfreq 24%

basics

~20 s

Isolate.spawn puts the new isolate in the spawner's isolate group, sharing code and a managed heap, so it starts fast. spawnUri loads a separate program outside the group. Within a group, Isolate.exit transfers its final message's objects to the receiver instead of copying them.

open as a page

A Dart backup CLI hangs forever after a spawned hashing isolate hits an unreadable file — why, and how do onError, onExit and kill prevent it?

level: seniorimportance: should knowfreq 28%

basics

~20 s

The worker threw, errors are fatal by default, so it died without sending a result, and the spawner's await on its port waits forever. Pass onError and onExit ports to Isolate.spawn so a crash or silent exit is reported, and kill workers you no longer need.

open as a page

In Dart, when does moving image bytes between isolates as TransferableTypedData beat sending a plain Uint8List, and what rules come with it?

level: seniorimportance: nice to knowfreq 16%

basics

~10 s

A Uint8List is mutable, so every send copies it. TransferableTypedData.fromList copies the bytes once, after which sending is constant time and ownership moves: only the receiver can call materialize(), and only once.

open as a page