skip to content

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%

answer

  1. same isolate group: almost anything
  2. native resources cannot cross
  3. ReceivePort itself is unsendable
  4. spawnUri: literals, collections, ports only
  5. closures drag their captured context

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.

solid answer

~40 s

It depends on whether the two isolates share code. For isolates in the same isolate group, which is what `Isolate.spawn` and `Isolate.run` create, the message can contain almost any object, including instances of your own classes, records and closures. The exceptions are objects holding native resources, such as a `Socket` or an open file, plus `ReceivePort`, `DynamicLibrary`, `Finalizable`, `Finalizer`, `NativeFinalizer`, `UserTag` and classes marked `@pragma('vm:isolate-unsendable')`. For an isolate started with `Isolate.spawnUri`, the list is strict: `null`, booleans, numbers, strings, collections made by literals or the `List`/`Map`/`LinkedHashMap`/`Set`/`LinkedHashSet` constructors, `TransferableTypedData`, `Capability` and `SendPort`s. Closures fail because the VM copies their captured context, which can hold more than the closure uses, such as `this` with an open file inside; the send then fails with 'Illegal argument in isolate message'.

code

dart · 20 lines
dart
import 'dart:io';
import 'dart:isolate';

class ThumbnailJob {
  ThumbnailJob(this.source, this.log);
  final List<int> source;
  final RandomAccessFile log; // an open file: a native resource

  // Fails: reading `source` captures `this`, and `this` holds `log`.
  Future<int> badChecksum() => Isolate.run(() => _checksum(source));

  // Works: the helper's closure captures only its own parameter.
  Future<int> goodChecksum() => _checksumInIsolate(source);

  static Future<int> _checksumInIsolate(List<int> bytes) =>
      Isolate.run(() => _checksum(bytes));
}

int _checksum(List<int> bytes) =>
    bytes.fold(0, (sum, b) => (sum + b) & 0xFFFF);

go deeper

for a junior

Remember the short answer: plain data goes through, things tied to native resources do not, and a ReceivePort never travels; you send its sendPort instead.

for a middle

Explain the two rule sets, same group versus spawnUri, and why a closure that reads a field captures this and can fail with an illegal-argument error.

for a senior

Show how you keep messages small and sendable by design: records or sealed command types, static helpers for isolate calls, and no service objects inside messages.

for a principal

Weigh the convenience of sending rich objects inside one group against the coupling it creates; a flat message format keeps a worker replaceable and testable.

## Two rule sets, chosen by shared code `SendPort.send` checks the **transitive object graph** of every message: the object you pass plus everything it references. Which objects are allowed depends on whether the sender and receiver **share the same code**. - Isolates created with `Isolate.spawn`, and therefore `Isolate.run`, join the spawner's **isolate group**. They run the same program, so the receiver knows every class the sender might send. - An isolate created with `Isolate.spawnUri` loads a different program. It may not have your classes at all, so only a small set of universally understood values is allowed. ## Same isolate group: almost everything Inside one group, any object can be sent except these kinds, as listed in the `SendPort.send` documentation: - **Objects with native resources**, meaning subclasses of classes such as `NativeFieldWrapperClass1`. A `Socket` is the documented example, and open files behave the same way. - **`ReceivePort`**. A receive port belongs to the isolate that created it; you send its `sendPort` instead. - **`DynamicLibrary`**, **`Finalizable`**, **`Finalizer`**, **`NativeFinalizer`**, **`UserTag`** and the mirrors `MirrorReference`. - **Any class marked `@pragma('vm:isolate-unsendable')`**, or one that extends or implements such a class. Everything else goes: your own classes, records, enums, typed data, `SendPort`s and closures. Immutable objects such as strings are shared, everything else is copied. ## Different code: the strict list For a `spawnUri` isolate the transitive graph may contain only: | Allowed | Notes | |---|---| | `null`, `true`, `false` | | | `int`, `double`, `String` | | | lists, maps, sets | made by literals or by the `List`, `Map`, `LinkedHashMap`, `Set`, `LinkedHashSet` constructors | | `TransferableTypedData`, `Capability` | | | `SendPort` | from `ReceivePort.sendPort` or `RawReceivePort.sendPort` | | some `Type` objects | for the types above, `Object`, `dynamic`, `void`, `Never` | So a `HashMap`, a record or an instance of your own class cannot cross to a `spawnUri` isolate. Encode such data as maps and lists first. ## Why closures surprise people Since Dart 2.15, closures may be sent and may be used as spawn entry points. A closure is sent together with its **enclosing context**, the variables it captured, and that context is checked like any other message. Two things go wrong in practice: 1. **Capturing `this`.** Reading a field inside a closure written in an instance method captures `this`, so the whole object and everything it references travels too. If that object holds an open file or socket, the send fails. 2. **Over-capture.** The `SendPort.send` and `Isolate.run` documentation warn that the VM's current closure representation can capture more state than the closure needs (dartbug 36983), so a closure can carry variables it never reads. When the graph contains something unsendable, the send fails with an `ArgumentError` whose text reads "Illegal argument in isolate message". When nothing is illegal but the captured graph is large, the symptom is a slow send and extra memory instead. The documented fix is to move the isolate call into a **small helper that takes only the data it needs as parameters**, or to use a top-level or static function, so the captured context holds just those values. ## Diagnosing a failed or slow send 1. Read the error: "Illegal argument in isolate message" means something reachable from the message is on the forbidden list. 2. If a closure is involved, list what it captures, including `this` and variables shared with other closures in the same scope. 3. Move the isolate call into a static or top-level helper that receives only the values it needs. 4. If the send succeeds but is slow, measure the size of what is being copied; a closure can quietly drag a large cache or model along with it. ## Practical rules - Send data, not services: plain values, records and small immutable classes travel well. - Never put a `ReceivePort`, a socket, a file handle or a `Finalizer` in a message; send a `SendPort` or a path instead. - For `spawnUri` isolates, flatten data to literal-style maps and lists. - Keep isolate closures in helpers or static methods so `this` is never captured by accident. - In Flutter, avoid closures written inside a `State` method that read `widget` or other fields; they capture the `State` object and everything it points to.

  • Why can you send a SendPort but not a ReceivePort?
    A `ReceivePort` is the receiving end owned by the isolate that created it; its messages are delivered on that isolate's event loop, so moving it makes no sense and the runtime refuses it. A `SendPort` is just an address. To let another isolate receive, you send it your `sendPort`, or it creates its own `ReceivePort` and sends you its `sendPort`.
  • Can you send a record or a sealed-class instance to a worker started with Isolate.spawn?
    Yes. Isolates created by `Isolate.spawn` share code, so records, enums and your own class instances are allowed and copied, which makes `(id, payload)` records and sealed command types natural message formats. The same objects would be rejected by an isolate created with `Isolate.spawnUri`, which accepts only primitives, plain collections, ports and a few special types.
  • How would you mark one of your own classes so it can never be sent by mistake?
    Annotate it with `@pragma('vm:isolate-unsendable')`. The `SendPort.send` documentation says instances of such classes, and of classes that extend or implement them, cannot be sent, so an accidental capture fails loudly instead of copying a handle-like object.

saying these in an interview costs you the question

  • Closures can never be sent to another isolate
  • An open file or socket can be sent and used on the other side
  • Your own classes must be converted to JSON before any isolate send
  • spawnUri isolates accept the same objects as Isolate.spawn ones
  • A ReceivePort can be sent so the other isolate listens on it