Isolates & compute
The jank you can’t await away
Section titled “The jank you can’t await away”async/await fixes waiting (network, disk) — it does not help with CPU-heavy work. Parsing a large JSON payload, resizing an image, or running a crypto hash is synchronous computation, and it runs on the UI isolate. While it runs, the event loop can’t render frames, and the app freezes.
You cannot await your way out of this, because there is nothing to wait for — the CPU is busy. The fix is to move the work to a different isolate so the UI isolate stays free to render.
compute(): the common case
Section titled “compute(): the common case”For the everyday “run this expensive pure function off the main isolate,” Flutter gives you compute(). Pass it a function and its argument; it runs the function on a background isolate and returns the result as a Future.
import 'dart:isolate';import 'package:flutter/foundation.dart';
// A top-level function — self-contained, taking plain data in and// returning plain data out. This is what compute() actually needs.int addOne(int x) => x + 1;
// A version whose closure captures a ReceivePort — a native, isolate-bound// handle. This is what breaks (proven below): the closure itself has to// cross the isolate boundary as compute()'s message, and a ReceivePort// cannot be copied across.Future<int> addOneCapturingPort(ReceivePort port, int x) { return compute<int, int>((int value) { // ignore: unused_local_variable final capturedPort = port; // <-- captures a non-sendable object return value + 1; }, x);}import 'dart:isolate';import 'package:flutter/foundation.dart';import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/async-and-performance__isolates-and-compute/isolate_capture_demo.dart';
void main() { test('compute() with a top-level function and plain data works fine', () async { final result = await compute(addOne, 41); expect(result, 42); });
test('compute() with a closure that captures a ReceivePort throws the real isolate-message error', () async { final port = ReceivePort(); addTearDown(port.close);
await expectLater( addOneCapturingPort(port, 1), throwsA( isA<ArgumentError>().having( (e) => e.toString(), 'message', contains('Illegal argument in isolate message'), ), ), ); });}00:00 +0: compute() with a top-level function and plain data works fine00:00 +1: compute() with a closure that captures a ReceivePort throws the real isolate-message error
00:00 +2: All tests passed!The captured exception’s real, unedited text (from this exact Flutter 3.47.5 SDK, printed via e.toString() in the scratch experiment behind this lesson):
ArgumentError: Invalid argument(s): Illegal argument in isolate message: object is unsendable - Library:'dart:isolate' Class: _ReceivePortImpl@1026248 (see restrictions listed at `SendPort.send()` documentation for more information) <- field port in main.<anonymous closure>.<anonymous closure> (from ...) <- field callback in compute.<anonymous closure> (from package:flutter/src/foundation/_isolates_io.dart) <- computation in Instance of '_RemoteRunner<int>' (from dart:isolate)Read the trace bottom-up: the message crossing the isolate boundary is the whole computation closure compute() built around your callback; walking into it hits the callback field (your closure), and inside that, the captured port field — a ReceivePort, which the error names explicitly as unsendable. The fix is exactly what the top-level addOne example shows: keep the function self-contained — take plain data in, return plain data out, and never close over a native handle (ReceivePort, Socket, a dart:ffi pointer, a BuildContext/Element) or, in practice, anything that isn’t plain Dart data.
compute() is Isolate.run on native — proven from the installed SDK source
Section titled “compute() is Isolate.run on native — proven from the installed SDK source”A common claim is that compute() wraps Isolate.run with “extra isolate-pool/lifecycle management.” Reading this course’s actual installed Flutter 3.47.5 SDK source disproves that — the entire native (dart:io) implementation is:
Future<R> compute<M, R>( isolates.ComputeCallback<M, R> callback, M message, { String? debugLabel,}) async { debugLabel ??= kReleaseMode ? 'compute' : callback.toString();
return Isolate.run<R>(() { return callback(message); }, debugName: debugLabel);}That’s it — no pool, no isolate reuse, no lifecycle manager. compute(fn, arg) on Android/iOS/desktop is literally Isolate.run(() => fn(arg)) plus a debug name for DevTools. Every call spawns a fresh isolate and tears it down when it’s done — the same cost profile, the same everything. If you’re writing plain Dart (no Flutter dependency) and want the identical behavior, Isolate.run (covered with its own proof in the Async in Dart lesson) is the primitive; compute() only adds value in a Flutter app for the reason covered next.
compute() on web: no isolate at all — proven from source and docs
Section titled “compute() on web: no isolate at all — proven from source and docs”The web (dart2js/dartdevc) implementation is a completely different function:
Future<R> compute<M, R>( isolates.ComputeCallback<M, R> callback, M message, { String? debugLabel,}) async { // To avoid blocking the UI immediately for an expensive function call, we // pump a single frame to allow the framework to complete the current set // of work. await null; return callback(message);}No isolate, no message passing — it awaits a single microtask (to let the current frame finish), then just calls your function directly on the same thread. api.flutter.dev’s own docs for compute() confirm this in plain language: “On web platforms this will run callback on the current eventloop.” This is why the closure-capture error above cannot happen on web — there is no isolate boundary to cross, and it’s also why heavy synchronous work wrapped in compute() still blocks the browser’s single thread on web; compute() on web buys you exactly one microtask of deferral, not real parallelism.
This asymmetry exists because Isolate.spawn genuinely is not supported on the web compiler. The installed SDK’s own dart2js runtime patch makes this explicit:
@patchstatic Future<Isolate> spawn<T>( void entryPoint(T message), T message, { bool paused = false, bool errorsAreFatal = true, SendPort? onExit, SendPort? onError, String? debugName,}) { throw UnsupportedError("Isolate.spawn");}Isolate.run/Isolate.spawn (and, by extension, everything in this lesson below that builds on Isolate.spawn) is a native-only tool. compute() is the one API in this lesson that is genuinely safe to call from platform-shared code — it just quietly stops being “background” work the moment it runs on web.
Long-lived worker isolates: Isolate.spawn and ports
Section titled “Long-lived worker isolates: Isolate.spawn and ports”compute()/Isolate.run are for one-shot calls — a fresh isolate every time. For a worker you talk to repeatedly (a persistent parser, a database connection living off-thread), spawn once and communicate over SendPort/ReceivePort for the isolate’s whole lifetime.
flowchart LR main["UI isolate (spawns once)"] -->|SendPort| worker["Worker isolate (lives on, replies to many requests)"] worker -->|SendPort| main note["compute()/Isolate.run instead: a NEW isolate per call"] -.- worker
import 'dart:isolate';
/// Runs INSIDE the worker isolate. Receives a SendPort to reply on, then/// listens forever for (int) requests and replies with the doubled value —/// a long-lived worker, not a fresh isolate per call.void workerEntry(SendPort mainSendPort) { final commandPort = ReceivePort(); mainSendPort.send(commandPort.sendPort); commandPort.listen((message) { final request = message as int; mainSendPort.send(request * 2); });}
/// Spawn ONCE, then send many requests over time — unlike compute()//// Isolate.run, which spawn a fresh isolate per call (simplified: one/// request in flight at a time, no request-id correlation).class DoublerWorker { DoublerWorker._(this._isolate, this._commandPort, this._responses);
final Isolate _isolate; final SendPort _commandPort; final Stream<dynamic> _responses;
static Future<DoublerWorker> spawn() async { final initPort = ReceivePort(); final isolate = await Isolate.spawn(workerEntry, initPort.sendPort); final broadcast = initPort.asBroadcastStream(); final commandPort = await broadcast.first as SendPort; return DoublerWorker._(isolate, commandPort, broadcast); }
Future<int> doubleValue(int value) async { final future = _responses.first; _commandPort.send(value); return await future as int; }
void dispose() => _isolate.kill(priority: Isolate.immediate);}import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/async-and-performance__isolates-and-compute/worker_isolate.dart';
void main() { test('a long-lived worker isolate stays alive across multiple requests', () async { final worker = await DoublerWorker.spawn(); addTearDown(worker.dispose);
final first = await worker.doubleValue(3); final second = await worker.doubleValue(10); final third = await worker.doubleValue(21);
expect([first, second, third], [6, 20, 42]); });}00:00 +0: a long-lived worker isolate stays alive across multiple requests
00:00 +1: All tests passed!One Isolate.spawn call, three round trips, the same worker isolate the whole time — the ReceivePort returned during setup (initPort) is where the worker’s SendPort arrives, and every subsequent message reuses that same channel.
TransferableTypedData: handing off bytes without copying them
Section titled “TransferableTypedData: handing off bytes without copying them”Ordinary isolate messages are copied. For raw bytes (a decoded file, an image buffer), api.dart.dev’s own description of TransferableTypedData is exact about the difference: “The TransferableTypedData can be moved between isolates, so sending it through a send port will only take constant time.” Ownership transfers instead of the bytes being duplicated — the sending isolate’s copy of the data becomes unusable (materialize() on the sender side throws after the transfer), because only one isolate can hold the real buffer at a time.
import 'dart:isolate';import 'dart:typed_data';
int sumBytes(Uint8List data) { var total = 0; for (final b in data) { total += b; } return total;}
int sumTransferable(TransferableTypedData data) { final bytes = data.materialize().asUint8List(); var total = 0; for (final b in bytes) { total += b; } return total;}
Uint8List makeTenMegabytes() { final data = Uint8List(10 * 1024 * 1024); for (var i = 0; i < data.length; i++) { data[i] = i % 256; } return data;}import 'dart:isolate';import 'package:flutter/foundation.dart';import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/async-and-performance__isolates-and-compute/isolate_bench.dart';
void main() { test('compute() copies a 10MB Uint8List across the isolate boundary — timed', () async { final data = makeTenMegabytes(); final sw = Stopwatch()..start(); final result = await compute(sumBytes, data); sw.stop(); expect(result, 1336934400); // deterministic checksum for this exact byte pattern // A generous sanity bound only — the real number is reported in prose, // measured on this Mac, not strictly asserted here (machine-dependent). expect(sw.elapsedMilliseconds, lessThan(2000)); });
test('TransferableTypedData hands off a 10MB buffer without copying its bytes', () async { final data = makeTenMegabytes(); final transferable = TransferableTypedData.fromList([data]); final sw = Stopwatch()..start(); final result = await Isolate.run(() => sumTransferable(transferable)); sw.stop(); expect(result, 1336934400); expect(sw.elapsedMilliseconds, lessThan(2000)); });}00:00 +0: compute() copies a 10MB Uint8List across the isolate boundary — timed00:00 +1: TransferableTypedData hands off a 10MB buffer without copying its bytes
00:00 +2: All tests passed!Measured on this Mac (outside the strict test assertion, which only sanity-bounds at 2 seconds to stay reliable across machines): compute() copying 10MB took ~14ms; Isolate.run + TransferableTypedData for the same 10MB took ~12ms. At this size the difference is small because fixed isolate-spawn overhead dominates both — TransferableTypedData’s real win shows up at much larger payloads (hundreds of MB, where a real copy’s cost scales with size but a transfer’s cost stays constant) or when you’re moving the same buffer through several isolate hops in a row.
When the UI thread is really blocked: measuring a JSON decode
Section titled “When the UI thread is really blocked: measuring a JSON decode”import 'dart:convert';
String buildLargeJsonArray(int itemCount) { final buffer = StringBuffer('['); for (var i = 0; i < itemCount; i++) { if (i > 0) buffer.write(','); buffer.write('{"id":$i,"name":"item $i","value":${i * 1.5}}'); } buffer.write(']'); return buffer.toString();}
List<dynamic> decodeJsonArray(String source) => jsonDecode(source) as List<dynamic>;import 'package:flutter/foundation.dart';import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/async-and-performance__isolates-and-compute/json_decode_bench.dart';
void main() { test('synchronous jsonDecode of a ~10MB payload — measured on this Mac', () { final jsonStr = buildLargeJsonArray(200000); final sw = Stopwatch()..start(); final decoded = decodeJsonArray(jsonStr); sw.stop(); expect(decoded.length, 200000); });
test('the same jsonDecode moved to compute() — measured on this Mac', () async { final jsonStr = buildLargeJsonArray(200000); final sw = Stopwatch()..start(); final decoded = await compute(decodeJsonArray, jsonStr); sw.stop(); expect(decoded.length, 200000); });}00:00 +0: synchronous jsonDecode of a ~10MB payload — measured on this Mac00:00 +1: the same jsonDecode moved to compute() — measured on this Mac
00:00 +2: All tests passed!Measured on this Mac (a 200,000-element, ~9.6MB JSON array): decoding it synchronously on the calling thread took ~92ms; the identical decode via compute() took ~86ms end-to-end (isolate spawn + message copy + the decode itself) — genuinely no slower overall, and comparable at this size because jsonDecode’s own cost dominates both. The number that actually matters isn’t total elapsed time, though — it’s which thread pays it: the synchronous version spends that ~92ms on the UI isolate, where it competes directly with frame rendering (a dropped-frame risk at anything past ~16ms per frame at 60fps); the compute() version spends its time on a different isolate entirely, so the UI isolate stays free to keep rendering while it happens. This is the actual threshold for “is this worth an isolate”: not “is this slow,” but “is this slow and synchronous on the UI isolate.”
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated “move this to a background isolate” code reliably reaches for compute() with a closure that captures something from its surrounding scope — because that’s the natural way to write a closure, and the bug only shows up at runtime, never at analyze time.
// AI-generated: "parse this response off the main thread"class ApiClient { ApiClient(this._logger); final Logger _logger;
Future<List<Item>> fetchItems(String rawJson) { return compute<String, List<Item>>((json) { _logger.log('parsing ${json.length} chars'); // captures `this` via _logger final raw = jsonDecode(json) as List; return raw.map(Item.fromJson).toList(); }, rawJson); }}Before trusting generated compute()/isolate code like this, check for:
- Any identifier inside the callback that isn’t a parameter or a top-level/static reference — here,
_loggeris an instance field, which means the closure capturesthis(the wholeApiClientinstance) to reach it. Proven above withReceivePort, an unsendable captured object throws a realArgumentErrornaming exactly which field broke — the fix is to pass only what the closure needs as plain data (or do the logging outside the isolate, after the result comes back). compute()used for something that isn’t actually CPU-bound — anawait http.get(...)already frees the event loop on its own; wrapping it incompute()adds isolate-spawn and message-copy overhead for zero benefit, and (proven above) is a no-op-but-not-free deferral on web.- No handling for the case where the work is genuinely tiny — proven above,
compute()’s own isolate-spawn cost is real (measured here in the low milliseconds); for sub-millisecond work, that overhead can exceed the work itself. - A raw
Isolate.spawn/Isolate.runcall anywhere that also needs to run on web — proven above from source, this throwsUnsupportedErroron the web compiler; onlycompute()degrades gracefully there.
🐛 Spot the bug
class ImageProcessor {ImageProcessor(this.watermarkText);final String watermarkText;
Future<Uint8List> process(Uint8List bytes) { return compute<Uint8List, Uint8List>((data) { return _applyWatermark(data, watermarkText); }, bytes);}
static Uint8List _applyWatermark(Uint8List data, String text) { // pretend this stamps 'text' onto the image bytes return data;}}Show the answer
The closure passed to compute() captures watermarkText, an instance field reached through this — proven above with the ReceivePort case, capturing anything through an instance reference risks the exact same ArgumentError: Illegal argument in isolate message, and even when the captured value itself happens to be simple data (a String, here), the closure is still implicitly holding a reference to the whole ImageProcessor instance to get at it, which is fragile the moment that class gains any non-sendable field. The fix: pass watermarkText as part of the message explicitly (e.g. a record (bytes, watermarkText)) and call the already-static _applyWatermark directly, so the closure captures nothing from this at all.
Mental Model
Section titled “Mental Model”Every primitive in this lesson is answering the same question as the async-in-dart lesson’s zones, just one level lower: what, exactly, is allowed to cross a boundary, and what does crossing it cost? A Future/Stream crosses the “later” boundary for free (same isolate, same memory). An isolate boundary is not free even when it’s fast — proven above, compute() really is just Isolate.run with a debug label, which really does spawn a real OS-level isolate and copy real bytes, every single time. The closure-capture error is really the type system’s last line of defense for a rule that has no static (“compile-time”) check: an isolate has its own heap — the analyzer cannot know, just by reading your closure, whether every value it closes over is “plain data” or a live handle into the isolate that created it. Only actually sending the message and finding out proves it.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
compute(fn, arg) | Proven above: identical behavior to Isolate.run on native, PLUS a graceful (same-thread) fallback on web | Every call spawns a fresh isolate — real, measured spawn + copy overhead even for tiny work |
Isolate.run directly | Same cost profile as compute(), no Flutter dependency needed | Proven above from source: throws UnsupportedError on web — not a safe default in shared code |
Long-lived Isolate.spawn worker | Pays the spawn cost once, not per call — proven above, three round trips over one isolate | More code (ports, a protocol, manual lifecycle/kill()), and (like Isolate.spawn) web-unsupported |
TransferableTypedData | Constant-time handoff regardless of payload size, per the docs quoted above | Only for raw bytes (TypedData), and the sender’s copy becomes unusable after the transfer |
Common Misconceptions
Section titled “Common Misconceptions”- “
compute()uses a managed isolate pool, so repeated calls are cheaper than rawIsolate.spawn.” Disproven above by reading the actual installed SDK source: on native,compute()is a one-line wrapper aroundIsolate.run— no pool, no reuse, identical per-call cost. - “
compute()runs in the background on every platform, including web.” Disproven above from both source and the official docs: on web, it runs your callback on the same event loop after a single microtask — real parallelism only exists on native platforms, whereIsolate.spawnactually works. - “Any closure can be sent to
compute()/Isolate.spawnas long as it doesn’t crash to compile.” Disproven above: the analyzer has no way to catch this — the real, specificArgumentError: Illegal argument in isolate messageonly appears at runtime, naming the exact field that couldn’t be sent. - “Sending a big chunk of data to an isolate always has a large, unavoidable copy cost.” Disproven above:
TransferableTypedDatamoves ownership in constant time — the cost only applies to ordinary (non-transferable) messages, and even those were measured above at just ~14ms for 10MB, dominated by isolate-spawn overhead rather than the copy itself.