Isolates & Concurrency
By the end of this lesson you will know exactly why a CPU-heavy loop freezes a Flutter app even though the code is written with async/await, what an isolate actually is (its own heap, its own event loop, no memory shared with anyone else), how to hand off one-off and long-running work with Isolate.run and a hand-built worker isolate, exactly what gets copied when isolates "talk" to each other, and when isolates are the wrong tool for the job.
1. Concurrency vs. parallelism — and why async alone isn't enough
Dart's async/await and Futures (the machinery you met before this lesson) give you concurrency: your program's single isolate has one thread and one event loop — a loop that keeps picking the next ready piece of work (a completed I/O callback, a timer, a microtask) and running it to completion before picking the next one. Waiting for a network response or a file read doesn't block that thread, because while you wait, the event loop is free to run other code. But it is still fundamentally one thread doing one thing at a time — no matter how many Futures you juggle, they never run two lines of Dart at the same literal instant. That's why async alone gives you concurrency, not parallelism, and never uses a second CPU core.
Both halves as code (verify/d15.dart runs exactly this): two async tasks interleave as A1 B1 A2 B2 on one isolate, while a top-level variable changed inside Isolate.run never changes in the main isolate.
Input size → what is feasible: one isolate can run ≈ 108 simple steps per second; a frame budget of 16 ms is ≈ 1.6·106 steps — a loop over 107 items already costs ≈ 100 ms and must leave the UI isolate.
This matters enormously the moment you have CPU-heavy work — a big loop doing real computation (parsing a huge JSON file, resizing an image, running a search) rather than waiting on I/O. A Future or async function doesn't slice that loop into small pieces for you; it just runs your synchronous code start to finish, exactly like a normal function call. While it runs, nothing else on that isolate can run — including the code that would repaint the UI. In Flutter that is exactly what causes visible jank: frozen animations, an unresponsive tap, a spinner that doesn't spin.
The animation as a runnable panel. The loop is 108 iterations: on the main isolate the already-scheduled callbacks must wait (order printed), on another isolate a 5 ms heartbeat timer keeps ticking (checked with a tick counter, not a quoted time).
async or awaiting it does nothing to stop it blocking the event loop. async only changes when a function's result becomes available to callers (via a Future) — it does not chop the function's own synchronous body into interruptible pieces. If the body has no await inside its loop, the loop runs uninterrupted from start to finish on the one thread it's already on.Future, async/await) gives you the first. To get the second — and to stop a heavy computation from blocking the UI thread — you need a genuinely separate thread of execution: an isolate.2. What an isolate actually is
A Dart isolate is exactly that: an independent worker with its own memory heap (its own "fridge" of objects) and its own event loop (it manages its own queue of work, completely separately from any other isolate). No isolate can read or write another isolate's variables, objects, or heap directly — there is no shared mutable memory between isolates at all. The only way information moves between two isolates is by explicitly sending a message through a SendPort — the service hatch — and, as you'll see in section 4, every message that crosses that hatch is a copy.
synchronized blocks, or similar. Getting locking right is notoriously hard: forget one lock and you get a race condition; use two locks in different orders in two places and you get a deadlock (each thread waits forever for a lock the other one holds). Dart isolates sidestep this ENTIRE category of bug by construction: because no two isolates can ever touch the same mutable object, there is nothing to race over, and Dart has no lock/mutex primitives in its standard concurrency model because none are needed between isolates.3. Isolate.run: one-off work off the main isolate
For a single one-off chunk of heavy work, Dart 2.19+ gives you Isolate.run: it spawns a brand-new isolate, runs the function you give it there, sends the return value back as a message, shuts that isolate down, and gives you a normal Future with the result — all in one call.
Isolate.run on heavySum(1000000), checked against the closed form Σi=0n−1 i = n(n−1)/2, plus the error propagation described below.
Input size → what is feasible: n ≤ 105 → just call heavySum (≈ 105 steps ≈ 1 ms, cheaper than a spawn); n ≈ 108 → ≈ 1 s of CPU, use Isolate.run.
In Flutter, the equivalent helper is compute(callback, message) from package:flutter/foundation.dart — it's a thin, Flutter-flavoured wrapper that does the same job (spawn, run, return, tear down), historically predating Isolate.run and still common in existing codebases. For new code, Isolate.run is the plain-Dart, no-package-needed way to do the same thing.
A compute-style helper written with Isolate.run: same job, same result, one line.
Isolate.run and compute spawn a brand-new isolate every time you call them — there is real overhead in creating an isolate and copying its input/output (more on this trade-off in section 7). They are meant for occasional, chunky work, not for calling in a tight loop thousands of times per second.What happens if the function you hand to Isolate.run throws? The error is not swallowed. Isolate.run propagates it back through the Future it returns, so await Isolate.run(() => mightThrow()) throws in the calling isolate exactly as if the call had been made without any isolate at all — for a built-in exception type like StateError, the same runtime type and message come back, and you catch it with an ordinary try/catch around the await. This is checked in the isorun panel above, not assumed from documentation.
await Isolate.run(...) call in try/catch doesn't make an error inside the isolate vanish — it becomes an ordinary uncaught exception on your calling isolate, exactly as if a plain synchronous call had thrown. The isolate boundary changes WHERE the code ran, not whether errors still need handling.Isolate.run(() => someHeavyFunction()) is the go-to for a single one-off CPU-heavy task: it returns a Future, keeps your main isolate's event loop free the whole time, cleans itself up automatically, and — if the function throws — surfaces that error on the awaited Future exactly like a normal synchronous call would.4. Message passing: copies, what can be sent, Isolate.exit, TransferableTypedData
Two isolates that need to keep talking (not just a single request/result) communicate through SendPort/ReceivePort pairs. A ReceivePort is a mailbox that lives in one isolate; its matching SendPort can be handed to another isolate (sent as a message itself, since a SendPort IS a valid sendable value) so that isolate can drop messages into the mailbox.
The handshake as runnable code: the worker hands its own SendPort to main, then answers two pings.
The single most important fact about SendPort.send(x): from your program's point of view, x is copied. Mutate the copy that arrived on the receiving side and the sender's original is provably unaffected — always program as if a brand-new, independent object landed on the receiver's own heap, because that is exactly what you will observe. (There is one narrow nuance to this, covered in the card right after the next animation, for data that can never change in the first place.) This is exactly what keeps the no-shared-memory guarantee intact even while isolates actively exchange data.
The round trip as code: the original stays [1, 2, 3]; a const list may keep its identity, a mutable one never does.
Input size → what is feasible: a message of a few hundred bytes copies in microseconds; a list of 5·106 ints copies ≈ 5·106 elements (interview question 30 measures it) — send ids or ranges instead of big data when you can.
const (deeply immutable) list through SendPort.send to a separately spawned isolate handed back a value whose identityHashCode matched the original exactly — the Dart VM shared the underlying memory instead of duplicating it, because nothing can ever write to a const value, so sharing it is indistinguishable from copying it and cheaper. Do the same with an ordinary mutable list (like the one in the animation above) and the identities are always different — a real, separate copy, every time. Keep "send() always copies" as your safe mental model — it correctly predicts every observable behavior — but know that the VM's actual implementation only duplicates memory when duplication is the only way to keep the two isolates fully independent.null, bool, int, double, String), SendPorts, Capabilitys, TransferableTypedData, and — this surprises people coming from other languages — plain Dart collections and class instances made only of sendable values (a List, Map, or your own class with int/String/etc. fields) copy across without implementing any special interface. Since Dart 2.15, closures are sendable too, including ones that capture local state — as long as both isolates are running the same compiled program (spawned isolates share the running code; you can't ship a closure to some totally different unrelated Dart process). What is documented as not sendable: objects wrapping live OS resources — an open Socket or RawDatagramSocket, an open file handle (RandomAccessFile) — and a ReceivePort object itself (only its .sendPort is transferable; we reproduced this exact failure below, it is not a guess). Treat "no OS-resource handles" as the safe general rule, since new unsendable types can be added as the platform evolves.What crosses a port, tested by sending each value through a real SendPort.
Copying a small int is essentially free, but copying a multi-megabyte List or byte buffer costs real time and memory — the receiving isolate has to allocate an entirely new structure and walk the original to duplicate it. Two features exist specifically to avoid paying that cost:
Isolate.exit(port, message)— instead of computing a result and then separately sending it before returning, an isolate callsIsolate.exitas its very last action: it deliversmessagetoportand terminates the isolate in one step. Because the isolate is dying anyway and never touches that data again, the runtime can often hand the memory over rather than duplicating it — which is why, for a large payload, it is typically faster than an equivalent plainsend(the harness in Expert question 23 lets you measure it on your own machine). Treat "faster" as typical, not guaranteed: the exact margin depends on the machine, system load, and payload size, and could vary run to run. It only makes sense as the FINAL message from an isolate that is about to shut down.TransferableTypedData— wraps aUint8List/byte buffer so it can be handed to another isolate with its ownership transferred rather than duplicated. The receiver calls.materialize()once to get a liveTypedDataview of the bytes. Critically, a givenTransferableTypedDatacan only be materialized once, ever — after that, the underlying buffer is "neutered" (unusable), which is exactly what makes the transfer zero-copy: ownership moved, it never existed twice.
Isolate.exit vs a plain send: identical content arrives. (Speed is machine-dependent, so the panel checks the content; interview question 23 contains the timing harness.)
.materialize() the same TransferableTypedData a second time — even from the isolate that created it — throws. This isn't a memory leak or a bug to work around; it's the mechanism itself: the whole point is that the bytes exist in exactly one place at a time.TransferableTypedData in code: the receiver sees the bytes; a second materialize() throws.
Input size → what is feasible: a 1 MB image buffer copied through send is a 106-byte walk; transferring it is O(1) ownership hand-over, so use it for buffers above roughly a megabyte.
send() behaves as an independent copy — mutations never cross back, which is the only thing you can rely on (the VM may share memory under the hood for deeply immutable/const data, since that's unobservable). Plain objects, collections, and closures over the same program all send successfully; live OS-resource handles and a bare ReceivePort do not. Isolate.exit and TransferableTypedData exist specifically to avoid the copy cost for a final result or a big byte buffer.5. A long-lived worker isolate with two-way communication
Isolate.run is like calling out to a specialist for one job and sending them home afterward, a long-lived worker isolate is like keeping that specialist on staff in their own room: you keep sending them new requests through the service hatch, and each finished answer comes back through the same hatch, so you never pay the cost of hiring (spawning) them again.The pattern that makes this reliable: every request you send carries a unique id, and every reply echoes that same id back. That way, replies can arrive in any order (the worker might be mid-way through several things, or you might have several workers) and you can still match each answer to the right caller using a map of pending Completers keyed by id.
Five concurrent requests to ONE worker, deliberately answered in reverse order: ids still route each result to the right caller.
Input size → what is feasible: k in-flight requests need k Completers (O(k) space); k ≤ 104 is trivial, but each message copy is O(size), so keep requests small.
SendPort, and reuse it for many requests. Tag every request/response pair with an id so replies can be routed back correctly regardless of arrival order — this is the backbone of the worker-pool pattern in the interview bank below.6. Error handling & shutdown
(Isolate.run, from section 3, already gives you error handling for free — an uncaught error inside it simply completes its Future with that error. The ports below are for Isolate.spawn, where you manage a longer-lived isolate's lifecycle yourself, so there is no single Future to attach the error to — you have to ask for it explicitly.)
Isolate.spawn accepts two optional ports: onError and onExit. If the spawned isolate throws an uncaught error, the onError port receives a message describing it (a 2-element list: the error's string form, then its stack trace's string form) — the isolate then terminates. onExit fires whenever the isolate terminates for any reason (finishing normally, calling Isolate.exit, or crashing). Its payload is null in BOTH the crash case AND the ordinary "the function just returned" case — onExit does not automatically forward a return value for you. The only way to make onExit deliver something other than null is for the isolate to call Isolate.exit(thatSameSendPort, someValue) itself as its very last action (section 4); verified directly for this lesson in verify/d15.dart.
All three onExit cases and the onError payload, exactly as printed.
ReceivePort for replies but no onError port, and that worker throws an uncaught error, your caller's await replyPort.first hangs forever — an uncaught error does not deliver anything to an ordinary reply port, only to an onError port you explicitly wired up. Always attach onError (and complete your caller's Completer with an exception from it) for any hand-rolled worker that might crash — see the "recovering from a crashed worker" question in the interview bank below, verified directly in verify/d15.dart.The hang, reproduced safely: we wait only 300 ms for a reply that never comes.
To shut an isolate down from the outside — for example after a timeout, or because the work it was doing is no longer needed — call isolate.kill(priority: Isolate.immediate) on the Isolate handle returned by Isolate.spawn. From the outside this is the only reliable way to actually stop a runaway computation: cancelling a Future or ignoring its result does not make the isolate itself stop running.
Proof: after we stop listening the worker is still alive; after kill it really exits (also for a busy-looping isolate).
ReceivePort (or kill a spawned Isolate) when you're done with it is a common source of a Dart program that just never exits — an open port or a live isolate keeps the event loop (and the whole process) alive indefinitely, waiting for a message that will never come.close() in code: the port's stream ends, so nothing keeps the process alive.
onError/onExit ports let the spawning isolate observe a worker's failure or termination without needing a try/catch inside the worker itself. isolate.kill() is the only way to forcibly stop a runaway isolate; forgetting to close ports/kill isolates is a classic cause of a Dart process that hangs forever.7. When NOT to use isolates — and the web
Spawning an isolate and copying data into and out of it is not free — for small or short jobs, that overhead can easily cost more time than the work itself would have taken running synchronously. Isolates pay off when the work is genuinely CPU-heavy and takes long enough that spawn-and-copy overhead is a small fraction of the total.
| Situation | Use an isolate? | Why |
|---|---|---|
| Parsing a 50 MB JSON file | ✅ Yes | CPU-heavy, takes long enough (milliseconds to seconds) that spawn/copy overhead is negligible by comparison |
| Waiting on a network request or a database query | ❌ No — use async/await | This is I/O, not CPU work; the thread isn't busy while waiting, so there's nothing an isolate would parallelize |
| Adding two numbers, formatting one short string | ❌ No | The spawn + copy overhead vastly exceeds the actual work; you'd make the program slower, not faster |
| Resizing a large image / running a heavy search/sort on a big dataset | ✅ Yes | Real, sustained CPU work — exactly what isolates are for |
| Calling a heavy function hundreds of times per second | ⚠️ Use a long-lived worker/pool, not repeated Isolate.run | Repeated spawning pays the setup cost every single call; a persistent worker pays it once |
The cost side, measured with a Stopwatch and compared by inequality (no invented numbers): one Isolate.run round trip costs more than 1000 inline tiny calls, yet still finishes in well under half a second.
The benefit side: a CPU-bound Collatz sum over 600,000 start values split across 4 isolates gives the same total, and is faster whenever the machine has more than one core.
Input size → what is feasible: rule of thumb with ~108 steps/s: work below ≈ 106 steps (≈ 10 ms) → stay on the main isolate; ≥ 107 steps (≥ 100 ms) → move it; ≥ 109 steps → split across cores.
async function in Isolate.run "to be safe". If the function is mostly waiting on I/O (a network call, a file read), it was never blocking the event loop in the first place — wrapping it in an isolate only adds spawn and copy overhead for no benefit.dart:isolate support (in particular Isolate.spawn) is limited or unavailable there compared to native (VM/AOT) targets — the browser's equivalent primitive is a Web Worker, a separate JavaScript execution context with its own event loop and no shared memory by default, conceptually similar to a Dart isolate. Exactly which isolate APIs work on which web compilation target has been an evolving area across Dart/Flutter releases, so treat "isolates are unavailable or limited on the web" as the safe assumption and verify against the current Dart/Flutter web documentation for your specific SDK version before depending on isolate behavior in a web build.A tiny runtime check you can run: on the native VM numbers are 64-bit ints (identical(0, 0.0) is false), on the web they are JS doubles.
dart:isolate is limited/uncertain compared to native — check current docs for your target.Quiz
Interview questions
Cheat sheet
| Concept | Syntax / rule |
|---|---|
| Concurrency | One thread interleaving multiple tasks (Dart's event loop, Future/async) — never two lines of Dart at the same instant |
| Parallelism | Genuinely simultaneous execution on separate threads/cores — requires isolates in Dart |
| Isolate | Independent worker: own heap, own event loop, zero shared mutable memory with any other isolate |
| One-off heavy work | await Isolate.run(() => fn()) (Dart 2.19+); Flutter's older equivalent is compute(fn, arg) |
| Message passing | SendPort.send(x) behaves as a COPY onto the receiver's heap (mutations never cross back); the VM may share memory instead of duplicating it for deeply immutable/const data, since that's unobservable. A SendPort itself is sendable, a ReceivePort is not |
| Sendable | Primitives, SendPort, Capability, TransferableTypedData, plain collections/objects built only from sendable values, and closures (same program, since Dart 2.15) |
| Not sendable | Live OS-resource handles (open Socket/file), a bare ReceivePort object itself |
| Zero-copy-ish hand-off | Isolate.exit(port, msg) — final message + terminate in one step, avoids a separate copy for data the isolate no longer needs |
| Big byte buffers | TransferableTypedData.fromList([bytes]) → receiver calls .materialize() exactly once; a second .materialize() throws |
| Long-lived worker | Spawn once, keep its SendPort, tag every request/response with an id so out-of-order replies still route correctly |
| Error/exit observation | Isolate.spawn(fn, arg, onError: p1.sendPort, onExit: p2.sendPort) — onError gives [errorString, stackString]; onExit fires on any termination, with null unless the isolate explicitly called Isolate.exit(thatPort, value) as its last act |
| Force-stop | isolate.kill(priority: Isolate.immediate) — the only reliable way to stop a runaway isolate |
| When NOT to use | Pure I/O waits, tiny computations, or calling Isolate.run in a hot loop (use a persistent worker instead); web support is limited — check current docs |