Dart Isolates
last modified May 29, 2026
Introduction
A Dart isolate is an independent unit of execution that has its own memory heap. Unlike threads in most other languages, isolates never share memory. Instead they communicate exclusively by passing copies of objects through ports. This design eliminates data races without the need for locks or mutexes, making concurrent Dart code both safe and predictable.
Every Dart program already runs inside an isolate — the main isolate. Spawning additional isolates lets CPU-bound work run truly in parallel on multi-core hardware without blocking the main event loop.
import 'dart:isolate';
// A top-level function is required as the isolate entry point.
void greet(String name) {
print('Hello from an isolate, $name!');
}
void main() async {
await Isolate.spawn(greet, 'World');
// Give the isolate time to print before main exits.
await Future.delayed(Duration(seconds: 1));
print('Main isolate finished.');
}
Running this program prints a greeting from the spawned isolate and then a completion message from the main isolate. Both lines execute concurrently.
Isolates vs async/await and the Event Loop
async/await and Future schedule work on
the event loop of the current isolate. They are great for I/O-bound
tasks (network, file) because the isolate stays idle while waiting for the OS.
However, heavy computation still blocks the event loop because it runs on the
same thread. Spawning a separate isolate moves that work to a different thread
entirely.
| Feature | async/await | Isolate |
|---|---|---|
| Execution thread | Same thread (event loop) | New OS thread |
| Memory | Shared heap | Separate heap |
| Good for | I/O-bound tasks | CPU-bound tasks |
| Communication | Direct (variables) | Message passing (ports) |
| Overhead | Very low | Higher (spawn cost) |
The table above summarizes the key differences between the two approaches.
import 'dart:isolate';
// Heavy computation - blocks the event loop when run with async/await.
int heavySum(int n) {
int total = 0;
for (int i = 0; i < n; i++) total += i;
return total;
}
void isolateEntry(SendPort port) {
port.send(heavySum(100000000)); // runs on a separate thread
}
void main() async {
// --- async/await approach (blocks event loop) ---
final result1 = heavySum(100000000);
print('async result: $result1');
// --- isolate approach (non-blocking) ---
final receivePort = ReceivePort();
await Isolate.spawn(isolateEntry, receivePort.sendPort);
final result2 = await receivePort.first;
print('isolate result: $result2');
}
Both approaches ultimately return the same value, but they serve different purposes depending on the nature of the task. The isolate version leaves the main event loop entirely free to handle UI updates, timers, or network callbacks while a heavy computation runs in the background. Understanding when and where to use each is crucial for optimizing your app's performance. You should rely on async/await for I/O-bound tasks—such as fetching network data, reading files, or interacting with a database.
In UI development, you must use async/await for these operations in order not to block the UI thread while waiting for an external response. However, because async/await still executes on the main thread, running heavy data processing or complex mathematics there will cause the interface to freeze. Therefore, whenever you need to perform intensive, CPU-bound work (like parsing huge JSON payloads or filtering massive lists), you should offload it to an isolate, ensuring your application remains perfectly smooth and responsive.
Isolate.spawn
Isolate.spawn is the primary way to create an isolate. It takes a
top-level (or static) function as the entry point and one argument to pass to
it. The call returns a Future<Isolate> that completes once
the isolate has been created.
import 'dart:isolate';
// Entry point - must be a top-level or static function.
void worker(SendPort replyPort) {
// This code executes in the new isolate.
final result = List.generate(5, (i) => i * i); // [0,1,4,9,16]
replyPort.send(result); // send result back to main
}
void main() async {
// ReceivePort lets the main isolate listen for messages.
final receivePort = ReceivePort();
// Spawn the isolate, handing it our SendPort so it can reply.
await Isolate.spawn(worker, receivePort.sendPort);
// Await the single reply, then close the port.
final data = await receivePort.first;
print('Squares: $data'); // Squares: [0, 1, 4, 9, 16]
}
receivePort.first is a convenient shorthand that closes the port
automatically after the first message arrives, avoiding resource leaks.
ReceivePort and SendPort
A ReceivePort is the listening end of a one-way channel and can
only be owned by one isolate. Its paired SendPort can be copied
and shared freely — even sent to other isolates. To set up bidirectional
communication, each side creates its own ReceivePort and
exchanges SendPorts during the handshake.
import 'dart:isolate';
void worker(SendPort mainPort) {
// Create our own receive port and send its SendPort to main.
final workerPort = ReceivePort();
mainPort.send(workerPort.sendPort); // handshake
workerPort.listen((msg) {
print('Worker got: $msg');
mainPort.send('Echo: $msg'); // reply
workerPort.close(); // done - clean up
});
}
void main() async {
final mainPort = ReceivePort();
await Isolate.spawn(worker, mainPort.sendPort);
final stream = mainPort.asBroadcastStream();
// Receive the worker's SendPort from the handshake.
final SendPort workerPort = await stream.first;
// Now send a message to the worker.
workerPort.send('ping');
// Receive the echo.
final reply = await stream.first;
print('Main got: $reply'); // Main got: Echo: ping
mainPort.close();
}
The pattern — spawn, exchange ports, then communicate — is the standard Dart idiom for bidirectional isolate interaction.
Practical Example - Factorial in an Isolate
Factorial of a large number is a typical CPU-bound task. Moving it to a separate isolate keeps the main event loop responsive while the calculation runs.
import 'dart:isolate';
// Worker receives [SendPort, n] and sends back n!
void factorialWorker(List<dynamic> args) {
final SendPort port = args[0];
final int n = args[1];
BigInt result = BigInt.one;
for (int i = 2; i <= n; i++) {
result *= BigInt.from(i);
}
port.send(result);
}
void main() async {
const int number = 20;
final receivePort = ReceivePort();
await Isolate.spawn(factorialWorker, [receivePort.sendPort, number]);
final BigInt result = await receivePort.first;
print('$number! = $result');
// 20! = 2432902008176640000
receivePort.close();
}
Using BigInt avoids integer overflow for larger inputs. The
list [receivePort.sendPort, number] bundles multiple values
into the single argument that Isolate.spawn allows.
Limitations
Isolates intentionally impose constraints to guarantee safety:
- No shared objects. Messages are deep-copied
(
SendPort.sendserialises the value). Two isolates holding a reference to "the same" list actually hold independent copies. - Top-level entry points only.
Isolate.spawnrequires a top-level or static function; closures that capture local state cannot be used. - No DOM access in Flutter/web workers. Web isolates run as Web Workers and cannot touch the DOM.
- Spawn cost. Creating an isolate allocates a new
heap. For short-lived micro-tasks,
async/awaitorcompute()(Flutter) is usually more efficient.
import 'dart:isolate';
void worker(SendPort port) {
// The list sent here is a COPY - mutating it does not affect main.
port.send([1, 2, 3]);
}
void main() async {
final receivePort = ReceivePort();
await Isolate.spawn(worker, receivePort.sendPort);
final List<int> list = await receivePort.first;
list.add(4); // only the local copy is modified
print(list); // [1, 2, 3, 4]
receivePort.close();
}
Because every message is copied, isolates provide message-passing concurrency: safe by construction, with no shared mutable state to reason about.
Source
Dart Isolate class, Dart concurrency documentation
In this tutorial we covered the Dart Isolate API: how isolates
differ from async/await, how to spawn them, exchange ports, pass data, and
what constraints they impose.
Author
List all Dart tutorials.