Isolates in Dart
This is Part 8 of the Async & Concurrency in Dart series. Everything so far — futures, streams, async/await — runs on a single thread (Part 1). That's perfect for waiting on I/O, but useless for computing. If you parse a 50 MB JSON file or resize an image on the UI thread, no amount of await will save you — the CPU is busy, the event loop is blocked, and the app freezes.
For genuinely CPU-heavy work, you need a second worker running at the same time, on another core. In Dart, that worker is an isolate.
Why async can't help (recap)
From Part 1: async/await is concurrency on one thread. It interleaves tasks by parking work while it waits, but at any instant only one Dart statement runs. So:
- Waiting on the network/disk →
async/await(the thread is free during the wait). ✅ - Computing (sorting millions, parsing, crypto, image filters) → there's nothing to wait on; the thread is pinned. ❌
// ❌ Still freezes the UI — async doesn't add a thread.
Future<int> sumHuge() async {
var total = 0;
for (var i = 0; i < 2000000000; i++) total += i; // pure CPU, runs on UI thread
return total;
}
To run that loop without blocking the UI, it has to execute on a different thread. That's an isolate's whole job.
The isolate model: separate workers, no shared memory
Here's the mental model. A normal threading model is like several cooks sharing one kitchen — they can all grab the same knife or pot, which is powerful but causes chaos: race conditions, you need locks/mutexes, and bugs that only appear once in a thousand runs.
Dart takes a different approach. Each isolate is like a cook with their own private kitchen. Isolates:
- Have their own memory and own event loop. One isolate cannot reach into another's variables.
- Share nothing. "No shared state between isolates means concurrency complexities like mutexes or locks and data races won't occur." (Official docs.)
- Communicate only by passing messages — copies of data sent over ports.
┌─────────────────┐ message ┌──────────────────┐
│ Main isolate │ ───────────► │ Worker isolate │
│ (UI, event │ │ (heavy CPU work)│
│ loop, memory) │ ◄─────────── │ own memory+loop │
└─────────────────┘ result └──────────────────┘
no shared variables — only copied messages
This "share nothing, pass messages" design is what makes Dart concurrency safe by construction: you literally cannot have a data race, because no two isolates can touch the same object. The trade-off is that data sent between isolates is copied, not shared — so passing huge objects back and forth has a cost.
All Dart code already runs in an isolate — the main isolate, started for you. "Spawning an isolate" just adds another one.
The easy way: Isolate.run
For the overwhelmingly common case — "run this one expensive function off the main thread and give me the result" — Dart has a one-liner: Isolate.run (Dart 2.19+).
import 'dart:isolate';
int heavyComputation(int n) {
var total = 0;
for (var i = 0; i < n; i++) total += i; // expensive, CPU-bound
return total;
}
Future<void> main() async {
print('UI stays responsive...');
// Runs heavyComputation on a NEW isolate (another thread),
// returns a Future with the result. The main thread is never blocked.
final result = await Isolate.run(() => heavyComputation(1000000000));
print('Result: $result');
}
That's it. Isolate.run:
- Spins up a fresh isolate,
- runs your function there (on another core),
- sends the return value back (copied),
- completes the
Futurewith it, and - shuts the isolate down automatically.
You get parallelism with the ergonomics of await. Reach for Isolate.run first — it covers most "this computation is janking my UI" problems.
⚠️ The function you pass runs in a different isolate with separate memory, so it can't use objects from the calling isolate's state (controllers,
BuildContext, open DB handles). Pass in plain data; return plain data.
The Flutter way: compute()
Flutter ships a convenience wrapper called compute() that predates Isolate.run and does much the same thing: run a top-level or static function on a background isolate with a single argument.
import 'package:flutter/foundation.dart';
// Must be a top-level or static function (not a closure capturing state).
List<Photo> parsePhotos(String responseBody) {
final parsed = jsonDecode(responseBody) as List;
return parsed.map((j) => Photo.fromJson(j)).toList();
}
Future<List<Photo>> fetchPhotos() async {
final response = await http.get(Uri.parse('https://example.com/photos'));
// Parse the big JSON off the UI thread so the app stays smooth.
return compute(parsePhotos, response.body);
}
compute(callback, message) takes a function and a single message, runs the function on a worker isolate, and returns a Future of the result. Rule: the callback must be a top-level or static function (a closure that captures local state can't be sent to another isolate). In modern Flutter, Isolate.run and compute are largely interchangeable — Isolate.run is the newer, more flexible primitive; compute is the long-standing Flutter idiom.
Classic real-world use: parsing large JSON responses. The network wait is
await; the parsing (CPU) goes tocompute/Isolate.run. That single move fixes most "scrolling stutters while data loads" bugs.
The manual way: Isolate.spawn + ports
Isolate.run/compute are perfect for one-shot work. But sometimes you want a long-lived worker isolate that handles many messages over time (a background processing service). For that you use Isolate.spawn with explicit SendPort/ReceivePort message passing.
import 'dart:isolate';
void worker(SendPort toMain) async {
// Create our own receive port and tell main how to reach us.
final port = ReceivePort();
toMain.send(port.sendPort);
// Handle messages forever.
await for (final message in port) {
if (message is int) {
toMain.send(message * 2); // do work, send result back
}
}
}
Future<void> main() async {
final fromWorker = ReceivePort();
await Isolate.spawn(worker, fromWorker.sendPort);
// The first message the worker sends is ITS sendPort.
final events = StreamQueue(fromWorker); // (from package:async)
final SendPort toWorker = await events.next;
toWorker.send(21);
print(await events.next); // 42
}
The handshake pattern:
- A
ReceivePortis a mailbox (aStreamof incoming messages). ItssendPortis the address others use to mail it things. - To get two-way communication, each side creates its own
ReceivePortand shares itsSendPortwith the other (the "send me your address" handshake above). - Messages are copied between isolates (not all objects can be sent — see below).
This is more code than Isolate.run, so only reach for it when you genuinely need a persistent worker. For one-off jobs, Isolate.run does the port wiring for you.
What you can (and can't) send
Because messages cross isolate boundaries by copying, not everything is sendable. You can send primitives, strings, lists/maps of sendable things, and most plain data objects. You cannot send objects tied to native resources or the sending isolate's runtime, including: Socket, ReceivePort, DynamicLibrary, Finalizable/Finalizer/NativeFinalizer, Pointer, UserTag, and anything marked @pragma('vm:isolate-unsendable').
Two more practical notes:
- Web has no isolates. On the web, Dart maps this onto Web Workers with different semantics (data is still copied, but the APIs/behavior differ). For pure CPU offloading,
Isolate.run/computeare the portable choices. - Isolate groups. Isolates spawned with
Isolate.spawnjoin the same isolate group as their parent, which lets them share read-only program code and start faster/cheaper. (Isolate.spawnUri, which loads separate code, does not join the group and is slower.)
When to use what
Is the work CPU-bound (computing, not waiting)?
├── No → use async/await; an isolate won't help (Parts 2–3)
└── Yes → does it block the UI noticeably?
├── One-shot job (parse, sort, hash) → Isolate.run (or Flutter compute)
└── Long-lived worker handling many jobs → Isolate.spawn + ports
| Tool | Best for | Effort |
| --- | --- | --- |
| Isolate.run | one expensive computation | tiny (one await) |
| compute() (Flutter) | one computation, classic Flutter idiom | tiny |
| Isolate.spawn + ports | persistent worker, streaming jobs | manual port wiring |
Don't over-isolate. Spawning an isolate has overhead (memory + copying the message in and out). For tiny work it's slower than just doing it inline. Isolates pay off when the computation clearly dwarfs the copy/spawn cost.
Practice Challenges
Challenge 1 — Offload a computation. Move a CPU-heavy factorial/sum off the main thread with Isolate.run.
Show solution
import 'dart:isolate';
int sumTo(int n) {
var t = 0;
for (var i = 1; i <= n; i++) t += i;
return t;
}
Future<void> main() async {
final result = await Isolate.run(() => sumTo(100000000));
print(result); // computed on another thread; UI never blocked
}
Challenge 2 — Why doesn't this help? Explain why wrapping CPU work in async doesn't stop the freeze.
Future<void> crunch() async {
for (var i = 0; i < 1e9; i++) {} // still on the UI thread
}
Show solution
async/await doesn't create a new thread — it only lets the single thread interleave work while waiting on I/O. A tight CPU loop never yields and isn't waiting on anything, so the event loop (including frame drawing) is blocked the whole time. The fix is to run the loop in an isolate (Isolate.run), which executes on a different core.
Challenge 3 — Flutter JSON parsing. Show how to parse a large JSON body off the UI thread in Flutter.
Show solution
import 'package:flutter/foundation.dart';
List<Item> _parse(String body) =>
(jsonDecode(body) as List).map((j) => Item.fromJson(j)).toList();
Future<List<Item>> loadItems() async {
final res = await http.get(uri); // wait: async/await
return compute(_parse, res.body); // compute: isolate for parsing
}
_parse is top-level (required by compute). The network wait uses await; the CPU-bound parse uses compute.
Challenge 4 — Two-way handshake. Describe the message-passing handshake needed for the main isolate and a worker to talk both ways.
Show solution
Main creates a ReceivePort and passes its sendPort when spawning the worker. The worker creates its own ReceivePort and sends its sendPort back to main as the first message. Now each side holds the other's SendPort, enabling two-way communication. Each ReceivePort is a stream of incoming messages; messages are copied across the boundary.
Challenge 5 — Sendable or not? Which of these can be sent to another isolate: an int, a List<String>, an open Socket, a Map<String, int>?
Show solution
int, List<String>, and Map<String, int> are sendable (primitives and collections of sendable values, copied across). An open Socket is not sendable — it's tied to native resources bound to the originating isolate. (Other non-sendables include ReceivePort, Pointer, DynamicLibrary, finalizers, etc.)
Questions to test yourself
Q1 (basic). What problem do isolates solve that async/await cannot?
Show answer
They provide true parallelism for CPU-bound work. async/await only helps while waiting on I/O (one thread, interleaved). A heavy computation has nothing to wait on and would block the single thread; an isolate runs it on another thread/core so the UI stays responsive.
Q2 (basic). How do isolates share data with each other?
Show answer
They don't share memory — each isolate has its own memory and event loop. They communicate only by passing messages (copies of data) over ports (SendPort/ReceivePort). No shared state means no locks and no data races.
Q3 (intermediate). What does Isolate.run do, and why is it usually preferred over Isolate.spawn?
Show answer
Isolate.run(fn) spins up a new isolate, runs fn on it, copies the return value back, completes a Future with it, and shuts the isolate down — all in one call. It's preferred for one-shot work because it hides the manual ReceivePort/SendPort wiring Isolate.spawn requires. Use Isolate.spawn only for long-lived workers handling many messages.
Q4 (intermediate). What's the restriction on the function passed to Flutter's compute()?
Show answer
It must be a top-level or static function (taking a single message argument). A closure that captures local state can't be sent to another isolate, so compute/isolate entry points must be standalone functions. The data you pass in and get back is copied across the isolate boundary.
Q5 (advanced). Why is Dart's isolate concurrency "safe by construction" compared with traditional shared-memory threads?
Show answer
Because isolates share no memory — no two isolates can ever reference the same mutable object. Traditional threads share memory, which is why they need mutexes/locks and still suffer data races and deadlocks. Dart removes that entire class of bugs by design: communication is only via copied messages over ports, so there's nothing to race over. The trade-off is the cost of copying data across the boundary.
Q6 (advanced). When is spawning an isolate the wrong choice, even for work that isn't I/O?
Show answer
When the work is small relative to the isolate's overhead. Spawning an isolate costs memory and time, and every message in/out is copied. For a cheap computation, that overhead can exceed the work itself, making the isolate slower than running inline. Isolates pay off only when the computation clearly dwarfs the spawn + copy cost (e.g. parsing a large payload, heavy image/crypto work). Also avoid them for tasks needing live access to UI/BuildContext or open handles, which can't cross the boundary.
Wrapping up
Isolates are Dart's answer to true parallelism:
async/awaitis concurrency on one thread — great for waiting, useless for CPU-bound work. Isolates run on another core.- Each isolate has its own memory and event loop; they share nothing and communicate only via copied messages — so data races are impossible by construction.
Isolate.run(or Flutter'scompute) offloads a single computation with oneawait— your go-to for "this parse/sort is janking the UI."Isolate.spawn+SendPort/ReceivePortbuilds long-lived workers with two-way message passing.- Not everything is sendable (no sockets, pointers, ports), the web has no isolates, and over-isolating tiny work is slower than doing it inline.
We've now covered concurrency end to end — except one quietly powerful concept we keep bumping into: the zone, the execution context that catches stray async errors and lets Flutter run your whole app inside a guarded boundary. Part 9 demystifies Zones in Dart.