Isolates & True Concurrency
Irbisa · cheatsheetSeptember 13, 2026

Isolates & True Concurrency

Isolate.run, spawn, ports, message copying, worker pools, TransferableTypedData, DevTools

Senior Developer16 itemscompressed for a skim
  1. 01

    Your teammate wraps the parser in compute() and you wrap yours in Isolate.run() — is there any difference, and when is neither enough?

    Medium

    On native platforms there is no difference at all: Flutter's compute is implemented as Isolate.run(() => callback(message), debugName: debugLabel).

    import 'dart:convert';
    import 'dart:isolate';
    import 'package:flutter/foundation.dart';
    
    // 1. compute — one argument in, one value out, named in the timeline.
    Future<List<Order>> parseWithCompute(String body) =>
    …
  2. 02

    Sending a job into a worker throws "Illegal argument in isolate message: object is unsendable" — what did you put in the message?

    Medium

    Something in the transitive object graph of the message holds a native resource: the VM walks every field of everything you send, and one open handle anywhere in that graph fails the whole send.

    import 'dart:convert';
    import 'dart:io';
    import 'dart:isolate';
    import 'package:flutter/foundation.dart';
    
    // ❌ The closure is written where an open file handle is in scope.
    …
  3. 03

    Handing a worker a 40 MB response body costs 0.1 ms, but handing it the same 40 MB as a byte array costs 4 ms — why?

    Medium

    SendPort.send deep-copies the transitive object graph, with one exception: values the VM identifies as immutable — strings, numbers, booleans, null — are shared by reference between isolates in the same group.

    import 'dart:convert';
    import 'dart:isolate';
    import 'dart:typed_data';
    
    // Measured on a desktop VM with 40 MB payloads — time for send() to return:
    //   String      0.13 ms   immutable, shared by reference
    …
  4. 04

    Your isolate spawns fine, but the first job you send it vanishes — what does the ReceivePort handshake have to look like?

    Medium

    A SendPort is one-way, so Isolate.spawn can only give the child a way to reach you — the child has to create its own ReceivePort and send its SendPort back before you have anywhere to send work.

    import 'dart:async';
    import 'dart:isolate';
    
    class Worker {
      Worker._(this._isolate, this._fromWorker, this._toWorker);
    …
  5. 05

    An exception inside a spawned isolate never reaches your try/catch and the screen just spins forever — where did that error go?

    Medium

    It went to stderr.

    import 'dart:isolate';
    
    Future<void> supervise() async {
      final results = ReceivePort();
      final errors = ReceivePort();
      final exits = ReceivePort();
    …
  6. 06

    You call isolate.kill() on a worker stuck in a two-second loop and it finishes the loop anyway — what did kill actually promise?

    Medium

    kill() defaults to priority: Isolate.beforeNextEvent, which means shut down when control next returns to the event loop — and a synchronous loop is one event, so the worker finishes it first.

    import 'dart:isolate';
    
    void spinner(SendPort toParent) {
      toParent.send('started');
      final sw = Stopwatch()..start();
      while (sw.elapsedMilliseconds < 1500) {} // no await: this is ONE event
    …
  7. 07

    Your worker isolate increments a global counter and the UI isolate never sees the change — where did the shared state go?

    Medium

    Every isolate has its own heap and its own copy of every global and static variable, initialised from scratch — the worker incremented its own counter, not yours.

    import 'dart:isolate';
    import 'package:flutter/foundation.dart';
    
    int requestCount = 0; // ONE COPY PER ISOLATE
    late final Map<String, Rule> rules = loadRules(); // rebuilt per isolate
    …
  8. 08

    How big does a JSON payload have to get before moving the decode to an isolate pays for itself, and how do you find that number?

    Hard

    There is no universal threshold — you measure the decode on the slowest device you ship, in a profile build, and compare it against the frame budget: 16.7 ms at 60 Hz, 8.3 ms at 120 Hz.

    import 'dart:convert';
    import 'dart:developer';
    import 'dart:typed_data';
    import 'package:flutter/foundation.dart';
    import 'package:flutter/scheduler.dart';
    …
  9. 09

    You call Isolate.run once per list row and the grid still stutters — when does a long-lived worker beat spawning per task?

    Hard

    A spawn is cheap but not free, and it throws away everything the isolate learned — once the work per call is the same order as the spawn plus the message copy, a warm worker wins.

    // ❌ a spawn per row: the isolate is born, parses once, and dies again
    Future<Uint8List> thumbFor(Uint8List src) =>
        Isolate.run(() => makeThumb(src));
    
    // ✅ one worker, many requests, replies matched by id
    class Thumbnailer {
    …
  10. 10

    One 40-megapixel image ties up your thumbnail pool and the whole grid stops — how do you size and schedule that pool?

    Hard

    Size the pool from Platform.numberOfProcessors, then hand each job to whichever worker has gone idle instead of assigning jobs to workers up front — static assignment is what lets one slow job block the three queued behind it.

    class ThumbPool {
      ThumbPool(this.size);
      final int size;
      final _idle = <Worker>[];
      final _queue = <Job>[];
      final _cancelled = <int>{};
    …
  11. 11

    A worker hands back a 40 MB buffer and both isolates spike — what does TransferableTypedData actually move, and what survives on the sender?

    Hard

    It makes the send constant-time instead of proportional to the payload, but TransferableTypedData.fromList still copies the bytes once — the move happens on the wire, not at construction.

    import 'dart:isolate';
    import 'dart:typed_data';
    
    // ❌ every hop copies the whole buffer, on the sender's turn
    void decodeAndForward(Uint8List raw, SendPort next) {
      final pixels = decode(raw); // Uint8List, 40 MB
    …
  12. 12

    Isolate.spawn happily sends your Product object but the same send to a spawnUri isolate throws — what is different about the two?

    Hard

    Isolate.spawn starts the new isolate inside the caller's isolate group — same program, same compiled code, same class identities — while spawnUri loads a second, unrelated program, so the two sides share no classes and almost nothing can cross between them.

    // ── Same group: Isolate.spawn ──────────────────────────────
    class Product {
      Product(this.id, this.name);
      final int id;
      final String name;
    }
    …
  13. 13

    compute() on Flutter web returns the right answer and still freezes the tab for two seconds — what is it doing there?

    Hard

    The web has no isolates, and Flutter's web compute is literally await null; followed by return callback(message); — one microtask yield, then your function, on the browser's main thread.

    // ── work_io.dart — mobile/desktop: a genuine isolate ───────
    import 'package:flutter/foundation.dart';
    
    Future<int> countWords(String text) => compute(_count, text);
    int _count(String text) => text.split(RegExp(r'\s+')).length;
    …
  14. 14

    The export takes thirty seconds and needs a progress bar and a working Cancel button — how do you get both out of a spawned isolate?

    Hard

    Progress is a SendPort the worker writes to as it goes; cancellation only works if the worker actually looks, because nothing outside an isolate can interrupt a running Dart loop except killing it.

    // Worker: reports progress, and looks for a cancel between chunks.
    void _exportWorker(SendPort toMain) {
      final control = ReceivePort(); // a ReceivePort can't be sent —
      toMain.send(control.sendPort); // the worker makes its own and sends the port
      var cancelled = false;
      control.listen((msg) {
    …
  15. 15

    Memory only grows while your export worker runs, and the CPU profiler shows an idle app — how do you profile a spawned isolate?

    Hard

    DevTools works one isolate at a time: the CPU profiler, the debugger and every heap snapshot apply to whichever isolate is picked in the isolate selector, and that defaults to the main one.

    import 'dart:developer';
    import 'dart:io';
    import 'dart:isolate';
    
    // ⚠️ per-isolate state: this map is a separate, invisible cache inside every
    // worker. The main isolate's heap snapshot will never show it growing.
    …
  16. 16

    You moved the work into an isolate and the raster thread still spikes — which threads does a Flutter app have, and who runs your Dart?

    Hard

    Your application Dart runs on the engine's UI task runner, which hosts the root isolate; the raster, IO and platform runners execute no app Dart at all, so an isolate can only ever fix a UI-thread problem.

    // Everything in build() is UI-runner work, charged to this frame's budget.
    
    // ❌ a sync read on the root isolate: the UI runner stops dead
    final raw = File(path).readAsBytesSync();
    final report = Report.fromJson(jsonDecode(utf8.decode(raw)));
    …