Each isolate has its own heap and event loop, so no object is ever shared mutably; two isolates talk only by sending copied messages through ports. A ReceivePort is the inbox, its sendPort is the address you give the other side.
No shared state, no locks: one isolate cannot reach another's objects, so Dart code has no data races on objects and the language has no synchronized or mutex
Single-threaded inside: each isolate handles one event at a time; real parallelism comes only from running several isolates
Ports are the only channel: a ReceivePort is a stream you listen to, and SendPort.send copies the message (immutable values such as strings are shared)
One-way: for a two-way conversation each side creates its own ReceivePort and sends the matching SendPort across
FlutterSenior
Isolates & True Concurrency
Isolate.run, spawn, ports, message copying, worker pools, TransferableTypedData, DevTools
Questions closed
0 / 18
- 0Review
- 3Asked often
Questions
Not quite: the engine uses several threads, but your Dart code runs on exactly one, the root isolate. Since Flutter 3.29 on Android and iOS that is the platform main thread; the raster thread draws on the GPU and spawned isolates run on other threads.
Platform / main: OS callbacks and plugin handlers, and since 3.29 (Android, iOS; macOS and Windows since 3.35) also build, layout and your Dart code, so a long Dart task can end in an ANR
UI: on platforms where it is still separate, the thread that runs the root isolate: build, layout, paint recording, gestures, animation callbacks
Raster: turns the layer tree into GPU commands in C++; blurs, saveLayer and oversized images cost time here, and no isolate helps
IO and worker threads: the engine decodes images and reads assets off the Dart thread
Your spawned isolates: run on threads from the Dart VM's pool, truly in parallel, with no access to widgets, BuildContext or the renderer
So in DevTools a tall UI bar means your Dart is slow, and a tall raster bar means the picture is too expensive to draw.
compute and Isolate.run are one-shot helpers: start a worker, run one function, return one result, exit; on native platforms Flutter's compute simply calls Isolate.run. Isolate.spawn is the low-level primitive that gives you a long-lived isolate you talk to through ports.
**compute(fn, message): Flutter only; one argument in, one value out, and on the web it runs on the main thread
Isolate.run(() => ...)**: plain Dart; a closure, so it can capture several values and may be async; the result returns through Isolate.exit, so it is not copied
Errors: both rethrow the worker's exception at the caller's await
**Isolate.spawn(entry, message)**: returns an Isolate handle for kill, pause and addOnExitListener, and you build the port protocol yourself
**Pick spawn when**: the worker must live across many requests (warm caches, an open database), report progress, or be cancelled; run and compute give you no handle, so they cannot be cancelled
Is it worth a worker at all: move work only when it takes longer than a frame (16.7 ms at 60 Hz, 8.3 ms at 120 Hz) on the slowest supported device, measured in a profile build; otherwise async is enough
Future(() => ...) moves nothing off the UI isolate: it only queues the callback on the same event loop, so jsonDecode and fromJson still run there as one long event and every frame waits for them. Run the parse in Isolate.run.
Why it happens: Future(fn) runs fn a moment later from a timer on the current isolate; async and await only wait for work done elsewhere (I/O, timers), they never split or offload your own code
The fix: Isolate.run(() => _parse(body)) decodes on another thread; the String input is shared rather than copied, and the result comes back through Isolate.exit without a copy
Move the whole parse: fromJson over thousands of objects often costs as much as jsonDecode, so both go to the worker; _parse is static so the closure captures only body, not this
Same trap: Future.microtask, Future.delayed(Duration.zero) and scheduleMicrotask also run on the calling isolate; in Flutter compute(_parse, body) is the equivalent of the fix
How to catch it: in a profile build, the DevTools Performance view shows one long UI frame whose CPU profile is jsonDecode; offload work only when it exceeds a frame on the slowest supported device
It prints 100 and then 7. Every isolate gets its own fresh copy of every top-level and static variable, so the worker starts from the initializer 0, adds 100 to its own copy, and the main isolate's counter never changes.
Statics start over: a static final singleton runs its constructor again in the worker, and a lazily built 30 MB cache is built once per isolate
Registrations do not travel: GetIt, a configured Dio, Firebase.initializeApp live in the root isolate only; the worker sees an empty locator
Plugins need setup: a background isolate can use platform channels only after BackgroundIsolateBinaryMessenger.ensureInitialized(token), with the RootIsolateToken.instance passed in from the root isolate
Captured values are copies: the closure given to Isolate.run is sent with what it captured, and changes inside touch only those copies
Rule: treat a worker as a pure function: everything it needs arrives in the message, everything it produces comes back as the result
The whole State. _raw is really this._raw, so the closure captures this, and sending it deep-copies the State, its element, the widget tree and the ScrollController, until the VM reaches a native resource it cannot copy and throws.
Read the error: the message lists the path to the unsendable object, such as <- Instance of '_ReportPageState'; that path shows what the closure dragged along
The fix: start the isolate from a top-level function that receives plain data: compute(parseRows, _raw) or a helper like parseInBackground(String raw)
A local copy is not always enough: the VM can share one context between closures of the same scope, so a setState(() => ...) in the same method may still capture this
What cannot be sent: ReceivePort, Socket, open files, DynamicLibrary, Finalizer and Finalizable objects, and dart:ui objects backed by native memory such as Image and Picture
What can: everything else inside the same isolate group, your own classes and closures included, deep-copied, so identity is lost
A ReceivePort is a single-subscription stream: first subscribes, takes one message and cancels, which also closes the port, so the second listen throws. Listen once and treat the first message (the worker's SendPort) as the handshake.
Why a handshake at all: a SendPort is one-way: spawn gives the worker a way to reach you, so the worker creates its own ReceivePort and sends its sendPort back first
One listener for everything: route the first message to a Completer<SendPort> and every later one to your reply handler
Do not send early: spawn completing does not mean the worker is listening; until the handshake arrives there is nowhere to send a job
Watch for death at startup: pass onExit to spawn, or a worker that throws before the handshake leaves await ready.future hanging forever
Close the ports on shutdown: an open ReceivePort keeps its isolate alive
It killed the worker and was printed to the console. An uncaught error never crosses into the isolate that spawned it, so the reply you await never arrives; that is the spinner.
**onError**: a SendPort that receives each uncaught error as a two-element list: the error's toString() and the stack trace as a String or null; the error object never crosses, so you cannot catch it by type
**onExit**: fires whenever the isolate ends, a kill() included; use it to fail every request still waiting
**errorsAreFatal**: true by default; set it to false for a long-lived worker that should survive one bad job
Better: errors as data: wrap each job in try/catch inside the worker and send (id, error) back, so one request fails and the rest go on
**Isolate.run does this for you**: an error thrown or awaited in the computation is rethrown at the caller with its type and stack trace; only an uncaught error, such as one from an un-awaited future, arrives as a RemoteError
Crash reporting: runZonedGuarded and PlatformDispatcher.instance.onError on the root isolate never see worker errors; forward them yourself
kill() defaults to priority: Isolate.beforeNextEvent: stop when control next returns to the event loop. A synchronous loop is one single event, so the worker finishes it first; Isolate.immediate interrupts it mid-loop.
**Isolate.immediate: the VM interrupts the running code at its next safepoint check
Nothing unwinds**: no finally, no pending futures, no timers; a half-written file stays half-written, so never rely on kill to close a database or flush data
It is asynchronous: kill() posts a control message and returns; the isolate is still alive on the next line, so wait for onExit when you need it gone
**run and compute have no handle: you cannot kill them, only ignore their result
Prefer a cooperative cancel**: send a message and let the worker check a flag between chunks; keep kill(priority: Isolate.immediate) as the fallback for a worker that is stuck
dispose never closes _results or kills the worker, so every visit leaves a scanner isolate running and an open port whose listener keeps the dead State alive and calls setState on it. Close the port and kill the isolate in dispose.
Nothing stops an isolate for you: it lives until its entry function returns with no pending work, calls Isolate.exit or is killed; Flutter disposes widgets, not isolates, so a timer or loop inside scanFolder runs forever
The open port is the second leak: a ReceivePort stays open until close(), and its listener holds the State, so the disposed page is never collected and each message calls setState on a disposed State and throws (a FlutterError in debug)
The fix: close the port first, so no message reaches the State, then kill(priority: Isolate.immediate), which does not wait for the worker's current event; the worker would never notice on its own, because send to a closed port is silently dropped
The spawn race: Isolate.spawn completes asynchronously; if the page closes first, _worker is still null, so the then kills the new isolate when mounted is already false
How to catch it: the DevTools isolate selector lists one more scanner per visit (that is what debugName is for); in plain Dart an open ReceivePort keeps the process from exiting
send deep-copies the whole object graph synchronously, on the sending isolate. Deeply immutable values (strings, numbers, booleans, null, constants) are shared instead of copied, so the string costs almost nothing while the byte array is a full memory copy.
The sender pays: the copy happens inside send, so a big message sent from the UI isolate is jank on the UI isolate, even though the worker does the rest
The cost is linear: a 40 MB String sends in a fraction of a millisecond on a desktop VM, the same bytes as a Uint8List take several milliseconds, and a phone is slower still
Object graphs are the worst case: a decoded JSON map is millions of small objects, each one copied; send the raw String in and a compact result out
**Isolate.exit: hands the last message over without a copy (see the isolate-group question)
TransferableTypedData: moves a byte buffer instead of copying it again
Practical rule**: if you have both response.body and response.bodyBytes, hand the worker the String
Since Dart 2.15, Isolate.spawn creates the new isolate in the caller's isolate group: the isolates share the loaded program and one managed heap, though each sees only its own objects. Because the heap is shared, Isolate.exit can hand its last message over instead of copying it.
What a group shares
Code and class metadata: nothing is reloaded or recompiled, so a spawn costs well under a millisecond and little memory
One heap and garbage collector: objects stay private to their isolate, but a worker that allocates heavily triggers collections that can briefly pause the UI isolate too
Any object can be sent: your own classes and closures included, because both sides have the same classes; only native-resource holders are refused
Isolate.exit
Send and die in one step: Isolate.exit(port, message) ends the isolate at once and the receiver gets the very same objects, no copy
Nothing after it runs: no finally, no pending futures; call it last, with the result ready
**Isolate.run is built on it**: which is why a one-shot worker returns a huge result almost for free, while a long-lived worker pays a copy for every send
Outside the group
**Isolate.spawnUri**: loads a separate program in a new group; only primitives, lists and maps of them, SendPort and TransferableTypedData cross, and Isolate.exit cannot target it. It belongs to tools and servers; Flutter apps practically never use it
It makes each send a constant-time handover of an off-heap buffer, but TransferableTypedData.fromList copies the bytes once when you create it. For a single hop that is the same one copy a plain send makes, so the gain comes from several hops or a spawnUri boundary.
The copy is at construction: fromList copies into memory outside the Dart heap; send then moves a pointer, and materialize() gives the receiver a ByteBuffer with no further copy
Once, ever: materialize() may be called once, in exactly one isolate; after a send the sender's handle is empty and calling it there throws ArgumentError
The source is not released: fromList copied it, so the original Uint8List still holds its 40 MB until you drop the reference; keep it and peak memory doubles
It joins chunks: fromList takes a list of typed data and concatenates them, handy for a buffer built from many encoded pieces
When to skip it: for one hop inside a group, a plain send or Isolate.exit is as cheap and simpler
VM only: on the web it throws UnsupportedError
When the work per call is small compared with what a spawn costs and throws away. Each Isolate.run starts an isolate, copies the captured inputs and discards everything warm; a long-lived worker pays the startup once and keeps its caches.
Spawn cost: well under a millisecond on a desktop VM and a few milliseconds on a low-end phone, plus a new event loop and fresh globals; a message to an existing worker takes microseconds
Lost warm state: a parsed lookup table, a compiled RegExp, an open database are rebuilt on every call, and that usually dwarfs the spawn
No back-pressure: fifty rows scrolling into view become fifty isolates competing for four cores and their memory
A warm worker needs a protocol: a request id, a Map<int, Completer> to route replies, errors sent back as data, and a shutdown message instead of kill
Rule of thumb: Isolate.run for occasional heavy jobs such as one big response; a worker or a small pool for a stream of small ones such as thumbnails
Progress is a SendPort the worker writes to as it goes. Cancel is a message the worker must check for, because nothing outside an isolate can interrupt a running Dart loop except killing it.
Two directions, two ports: the worker creates its own ReceivePort for control messages and sends its SendPort back first, since a ReceivePort cannot be sent
Messages arrive on an event-loop turn: one long synchronous loop never turns, so the worker would read the cancel only after the job is done
Chunk and yield: await Future<void>.delayed(Duration.zero) between chunks; await null is a microtask and does not deliver port messages
Chunk size is your cancel latency: size it for the responsiveness you promised, not for throughput
**kill(priority: Isolate.immediate)**: the fallback for a stuck worker; it skips finally, so temp files stay behind
Close every port at the end: an open ReceivePort keeps its isolate alive, the most common isolate leak
Size the pool from Platform.numberOfProcessors and the memory budget, and let each worker pull the next job from one shared queue when it goes idle. Assigning jobs to workers up front is exactly what lets one huge image block the jobs queued behind it.
**Start at numberOfProcessors - 1, cap at three or four: one core belongs to the UI, and a phone counts its slow efficiency cores in that number too
Memory is the real ceiling: each worker has its own objects, and a 40 MP RGBA bitmap is about 160 MB while it is processed; four in flight and the OS kills the app before the CPU is saturated
One shared queue: round-robin dispatch is head-of-line blocking with extra steps
Newest first for scroll work: the visible row was requested last; drop jobs for rows that scrolled away, and bound the queue so one fling cannot enqueue a thousand jobs
No preemption**: a running job cannot be interrupted, so a watchdog kills that worker with Isolate.immediate, fails its job and spawns a replacement
The web has no Dart isolates. Flutter's web compute is just await null; followed by return callback(message);: one microtask yield, then your function on the browser's main thread.
Isolate.spawn and Isolate.run throw UnsupportedError in both the JavaScript and the WebAssembly builds, so --wasm does not help. compute exists on the web only so one call site compiles everywhere.
Best: avoid the work in the browser: move it to the server, or use browser APIs that already run off the main thread, such as crypto.subtle or createImageBitmap
Next: slice and yield: await Future<void>.delayed(Duration.zero) between chunks lets the browser paint; await null is a microtask and does not, which is why compute freezes
Last: a real Web Worker: a second Dart entry point compiled to JavaScript and driven with postMessage; data crosses by structured clone, so send strings or plain JS values, never Dart objects. Packages such as squadron and isolate_manager wrap this
Keep the call site clean: a conditional export picks the isolate or the web version, instead of kIsWeb checks everywhere
DevTools inspects one isolate at a time: the CPU profiler, the debugger and heap snapshots all follow the isolate picked in the isolate selector, which defaults to the main one. Your worker's work and memory are simply not on screen until you select it.
Name the worker: debugName: 'export-worker' on spawn or run; unnamed workers appear as interchangeable ids
Catch short jobs: spawn with paused: true, select the isolate in DevTools, then call isolate.resume(isolate.pauseCapability!)
Mark the phases: wrap them in Timeline.timeSync('export.encode', ...) from dart:developer; the frame chart shows only UI and raster work, timeline events from the worker show up on their own track in the Performance view
Profile mode on a real device: debug builds run the JIT and distort every timing
Find the leak by diffing heap snapshots: take two before and after a run and filter by the worker's classes; spawned isolates share one heap with the root isolate, so the snapshot covers the whole group. The usual causes are per-isolate: a top-level cache that never evicts, a ReceivePort never closed, a subscription outliving its request
External bytes hide from the heap chart: image buffers and TransferableTypedData show up in ProcessInfo.currentRss and the OS memory tools first