skip to content

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%

answer

  1. worker sends its own SendPort first
  2. RawReceivePort for the handshake
  3. ReceivePort.fromRawReceivePort afterwards
  4. job id plus a Completer map
  5. RemoteError arrives as data

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).

solid answer

~40 s

I wrap the worker in a class with an async `spawn()` factory. It creates a `RawReceivePort` whose handler waits for the worker's first message, which is the worker's own `SendPort`, then wraps the same port with `ReceivePort.fromRawReceivePort` so one listener can handle all later responses. The worker's entry point creates a `ReceivePort`, sends its `sendPort` back and listens for jobs. Each `resize()` call allocates an id, stores a `Completer` in a map and sends `(id, bytes, width)`. The worker replies with `(id, result)`, or with `(id, RemoteError(...))` when resizing throws, and the main side removes the completer and completes it or fails it. Ids matter because a worker that awaits inside its handler answers in completion order, not in the order jobs were sent. A `close()` method finishes the design.

code

dart · 69 lines
dart
import 'dart:async';
import 'dart:isolate';
import 'dart:typed_data';

class ResizeWorker {
  ResizeWorker._(this._responses, this._commands) {
    _responses.listen(_onResponse);
  }

  final ReceivePort _responses;
  final SendPort _commands;
  final _pending = <int, Completer<Uint8List>>{};
  int _nextId = 0;

  static Future<ResizeWorker> spawn() async {
    final initPort = RawReceivePort();
    final ready = Completer<(ReceivePort, SendPort)>.sync();
    initPort.handler = (Object? first) {
      ready.complete((
        ReceivePort.fromRawReceivePort(initPort),
        first as SendPort,
      ));
    };
    try {
      await Isolate.spawn(_workerMain, initPort.sendPort);
    } on Object {
      initPort.close();
      rethrow;
    }
    final (responses, commands) = await ready.future;
    return ResizeWorker._(responses, commands);
  }

  Future<Uint8List> resize(Uint8List jpeg, int width) {
    final id = _nextId++;
    final done = Completer<Uint8List>.sync();
    _pending[id] = done;
    _commands.send((id, jpeg, width));
    return done.future;
  }

  void _onResponse(Object? message) {
    final (int id, Object? result) = message as (int, Object?);
    final done = _pending.remove(id)!;
    if (result is RemoteError) {
      done.completeError(result, result.stackTrace);
    } else {
      done.complete(result as Uint8List);
    }
  }

  static void _workerMain(SendPort replies) {
    final commands = ReceivePort();
    replies.send(commands.sendPort);
    commands.listen((Object? message) {
      final (int id, Uint8List jpeg, int width) =
          message as (int, Uint8List, int);
      try {
        replies.send((id, resizeJpeg(jpeg, width)));
      } catch (e, s) {
        replies.send((id, RemoteError('$e', '$s')));
      }
    });
  }
}

// Your own pure-Dart decode, scale and encode step.
Uint8List resizeJpeg(Uint8List jpeg, int width) =>
    throw UnimplementedError('plug in a codec');

go deeper

for a junior

Know that two-way talk needs two ports: the worker creates its own ReceivePort and sends its sendPort back as the first message.

for a middle

Walk through the handshake, the id-plus-Completer map and why a RawReceivePort is wrapped with ReceivePort.fromRawReceivePort instead of listening twice.

for a senior

Cover the failure paths: per-job RemoteError replies, pending completers when the worker dies, shutdown that drains pending jobs, and memory cost of parallel workers.

for a principal

Decide between one long-lived worker, a small pool and one-shot Isolate.run calls from job rate, warm state and peak memory, and own that choice with measurements.

## When a long-lived worker is worth it `Isolate.run` starts a fresh isolate for one computation and tears it down. For a stream of jobs, such as an app that resizes every photo a user picks for upload, starting an isolate per job adds startup cost and loses any warm state the worker could keep, such as a decoder it built once. A **long-lived worker** stays up, receives jobs over a port and sends results back over another. Building one well is mostly about the **handshake**, **request matching**, **errors** and **shutdown**. ## The handshake Messages flow one way per port, so two-way talk needs two ports: 1. The main isolate creates a port and spawns the worker with that port's `sendPort` as the spawn argument. 2. The worker's entry point creates its own `ReceivePort` for commands. 3. The worker sends that port's `sendPort` back as its **first message**. 4. The main isolate stores it; from now on it sends jobs to the worker's port and listens for results on its own. The dart.dev "robust ports" pattern creates the main-side port as a **`RawReceivePort`**. Its `handler` runs for the first message only, completing a `Completer<(ReceivePort, SendPort)>` with `ReceivePort.fromRawReceivePort(initPort)` and the worker's `SendPort`. Two reasons: - A `ReceivePort` is a single-subscription stream. Listening once just to catch the handshake would use up the only subscription the response handler needs. - `fromRawReceivePort` replaces the raw handler, so the same underlying port moves from startup logic to response logic without a second port. A `RawReceivePort` must have its handler set before the first message arrives, otherwise that message is lost, and its handler always runs in the root `Zone`. If `Isolate.spawn` throws, close `initPort` so it does not keep the main isolate alive. ## Matching results to jobs A worker that awaits inside its message handler can finish jobs out of order. The dart.dev guide calls this out: responses come back in the order they complete, not the order they were sent. So: - Keep an `int` counter and a `Map<int, Completer<Uint8List>>` of pending jobs. - `resize()` allocates an id, stores a completer and sends a record such as `(id, bytes, width)`. - The worker always replies with a record whose first field is that id. - The response handler removes the completer by id and completes it. Records make this compact, and they are sendable because `Isolate.spawn` isolates share code. ## Errors that do not kill the worker If the resize throws inside the handler and nothing catches it, the uncaught error goes to the worker isolate's error handling; with `errorsAreFatal: true`, the default in `Isolate.spawn`'s signature, the worker dies and every pending completer in the main isolate waits forever. Catch per job instead and reply with `(id, RemoteError('$e', '$s'))`. | Field | What the main side does | |---|---| | a `Uint8List` result | `completer.complete(result)` | | a `RemoteError` | `completer.completeError(error, error.stackTrace)` | `RemoteError` keeps the original error's `toString()` and a `stackTrace` built from the stack text. It arrives as an ordinary **data event**: a `ReceivePort` never emits stream errors, and its `listen` ignores `onError`. ## Shutting it down Add a `close()` that marks the worker closed, rejects new jobs, sends a shutdown command so the worker closes its own `ReceivePort`, and closes the main-side port once the pending map is empty. Open ports keep isolates alive, so forgetting this leaves the worker resident for the life of the app. ## Testing the worker - Unit-test the worker's job handler as a plain function first; the port plumbing adds no logic worth testing twice. - In an integration test, spawn the real worker, send a job that succeeds and one that throws, and assert that the first future completes and the second fails with a `RemoteError`. - Always call the wrapper's close method at the end of the test, or the open ports keep the test isolate alive and the run can hang. ## Costs to keep in mind - Every job's bytes are **copied** on send, because a `Uint8List` is mutable; for very large images that copy runs on the sending isolate. - Keep the message small: bytes, width and id, never a widget, a `BuildContext` or an object holding a file handle. - A single worker whose handler runs each job synchronously processes one job at a time, which also caps memory: resizing ten 12-megapixel photos at once in ten isolates would hold ten decoded bitmaps.

  • What happens to callers waiting on resize() if the worker isolate dies from an uncaught error?
    Their completers never complete, because nothing will ever send their ids back. That is why the worker catches per job and replies with a `RemoteError`. For crashes you cannot catch, register exit and error listeners when spawning and fail every pending completer when one fires.
  • Why not create a ReceivePort for the handshake and call first on it?
    Awaiting `first` cancels the stream subscription after one event, and cancelling a `ReceivePort` subscription closes the port. You would then need a second port for responses. Starting with a `RawReceivePort` and wrapping it with `ReceivePort.fromRawReceivePort` reuses one port for both phases.
  • Would you run several of these workers for a batch of photo uploads?
    Only after measuring. Each isolate holds its own decoded images, so parallel workers multiply peak memory, and phones have few cores to spare while the UI isolate and raster thread are busy. A single worker or a small fixed pool, fed from a queue, usually gives steadier memory and frame times.

saying these in an interview costs you the question

  • The worker can reuse the SendPort it was spawned with to receive jobs
  • Results always come back in the order the jobs were sent
  • An exception thrown in the worker reaches the ReceivePort's onError handler
  • A RawReceivePort buffers messages until its handler is set
  • Listening to a ReceivePort twice is fine, it is just a Stream