Isolates: Dart's Model of Concurrency
Some programs need to do heavy work: process a large file, crunch numbers, resize hundreds of images. If that work happens on the same thread as your user interface, everything freezes. Dart solves this problem with isolates.
An isolate is an independent unit of work with its own memory and its own single thread of execution. The name is deliberate: isolates cannot share memory with each other. One isolate never reaches into another isolate's variables. Instead, they pass messages back and forth. This chapter explains why Dart works this way, how to use isolates, and when to reach for them.
Why Dart chose isolation
In many languages, multiple threads share the same memory. Any thread can read or change any object at any time. That power comes at a cost: two threads can modify the same value at once, causing bugs that are famously hard to reproduce and fix.
Dart takes a different path. Each isolate owns its memory completely. No locks, no shared state, no race conditions between isolates. The price is that communication happens through messages, which are copies of data sent from one isolate to another. If you want another isolate to see your data, you send it a copy (or, in modern Dart, certain immutable objects can be shared directly, but copying is the common case).
The event loop in every isolate
Every isolate runs a single thread that processes an event loop. The isolate takes one event at a time, finishes it, and moves to the next. This is why async and await work without locks: while one piece of code waits for a network response, the isolate can handle other events, but only one thing runs at a time.
Future<void> main() async {
print('one');
await Future.delayed(Duration(milliseconds: 100));
print('two');
}Here a single isolate prints one, waits without blocking, then prints two. The event loop kept everything orderly. But if you compute something slow, nothing else in that isolate can run until the computation finishes. That is exactly when you spawn another isolate.
Spawning an isolate
Isolate.spawn starts a new isolate running a given function. Isolates talk through ports: a SendPort for sending and a ReceivePort for receiving.
import 'dart:isolate';
void worker(SendPort sendPort) {
// Heavy work happens here, in its own memory.
var total = 0;
for (var i = 0; i < 2000000000; i++) {
total += i;
}
sendPort.send(total);
}
Future<void> main() async {
final receivePort = ReceivePort();
await Isolate.spawn(worker, receivePort.sendPort);
final result = await receivePort.first;
print('The total is $result');
}Read the diagram, then note what happens in the code:
- The main isolate creates a
ReceivePortto listen for messages. - It spawns a new isolate, passing along the port to send replies to.
- The worker isolate computes the sum and sends the result.
await receivePort.firstreceives the message and closes the port.
Show diagram source
sequenceDiagram
participant Main as Main isolate
participant Worker as Worker isolate
Main->>Worker: Isolate.spawn(worker, sendPort)
Worker->>Worker: heavy computation (own memory)
Worker->>Main: sendPort.send(result)
Main->>Main: await receivePort.firstThe easier way: `compute`-style helpers
Managing ports by hand is verbose. For a one-shot calculation, the modern standard library offers Isolate.run, which handles spawning, communication, and cleanup for you:
import 'dart:isolate';
int sumRange(int n) {
var total = 0;
for (var i = 0; i <= n; i++) {
total += i;
}
return total;
}
Future<void> main() async {
final result = await Isolate.run(() => sumRange(2000000000));
print('The total is $result');
}The closure runs on a fresh isolate, and its return value is sent back automatically. Prefer this form whenever you simply have one expensive function.
What can and cannot be sent
Messages between isolates must be sendable:
- Numbers, strings, booleans, lists, maps, and other simple values: fine, copied.
- Objects of your own classes: generally fine in recent Dart versions, copied.
- Closures capturing local state, and open handles like
ReceivePort: not sendable, and Dart will throw an error if you try.
A useful mental model: assume everything is copied. If two isolates each have "the same" list, they are really two independent lists. Changing one does not change the other.
When to use isolates
Use an isolate when:
- A computation takes more than a few milliseconds and runs on the main isolate of a UI (for example, parsing a large JSON document).
- You want to keep one isolate responsive while work happens in the background.
Do not use an isolate when:
- The work is mostly waiting: file reads, network calls, timers. Async code with
awaitalready handles waiting efficiently on the same isolate. - The work is trivial. Spawning an isolate copies data and costs time, so for tiny tasks it is a net loss.
A rough rule: await for waiting, isolates for calculating.
Errors and cleanup
If an isolate crashes, it does not take your program down automatically; you should listen for errors. Also, close your ReceivePort when done to avoid leaking resources.
import 'dart:isolate';
void risky(SendPort sendPort) {
sendPort.send('about to fail');
throw StateError('worker crashed');
}
Future<void> main() async {
final receivePort = ReceivePort();
final errorPort = ReceivePort();
errorPort.listen((message) {
print('error arrived: $message');
});
await Isolate.spawn(risky, receivePort.sendPort,
onError: errorPort.sendPort);
print(await receivePort.first);
}The onError port receives errors thrown by the worker as messages, letting the main isolate report or recover from failures.
Putting it together
Dart's concurrency story has two layers. async/await keep one isolate responsive while waiting on the outside world. Isolates give you true parallelism by giving each worker its own memory and message-passing communication. Together they let you write code that is both easy to reason about and fast under load.
Exercise
Why can two isolates modify a shared list without causing race conditions?
Show answer
They cannot share it at all. When a list is sent between isolates, each isolate receives its own copy. Since no memory is shared, there is no possibility of two threads changing the same value at the same time.
Exercise
You need to download a file and then count how often each word appears in it. Which parts belong in async code and which in an isolate?
Show answer
The download is waiting on the network, so use async code with await on the main isolate. Parsing the text and counting words is CPU-heavy calculation, so run that in an isolate with Isolate.run, returning the result via the await.
Spotted a mistake, or code that does not work?