Dart Isolates
last modified May 29, 2026
Isolate.run
Isolate.run() was introduced in Dart 2.19 and became the
recommended API for one-shot CPU-bound tasks in Dart 3. Unlike
Isolate.spawn(), it accepts an ordinary closure, spawns a
temporary isolate, runs the closure, forwards the return value as a
Future, and tears the isolate down automatically. There are no
ports to create, no handshake, and no cleanup code.
import 'dart:isolate';
// Expensive computation – will run in a fresh isolate.
int heavyWork(int n) {
int sum = 0;
for (int i = 0; i < n; i++) sum += i;
return sum;
}
void main() async {
// Isolate.run handles spawn, port wiring, result forwarding, and cleanup.
final result = await Isolate.run(() => heavyWork(100000000));
print('Result: $result'); // Result: 4999999950000000
}
The closure can capture local variables as long as the captured values are
sendable (primitives, strings, lists, maps). The isolate is discarded
once the closure returns, so Isolate.run() is not suitable for
tasks that need to stay alive or exchange more than one message.
Isolate.run vs Isolate.spawn
Choosing between the two APIs comes down to the lifetime and communication pattern of the work. The table below summarises the key differences.
| Isolate.run() | Isolate.spawn() | |
|---|---|---|
| API level | High-level | Low-level |
| Return value | Future<T> | Future<Isolate> |
| Port wiring | Automatic | Manual |
| Cleanup | Automatic | Manual |
| Closures | Yes | No (top-level / static only) |
| Multiple messages | No | Yes |
| Long-lived isolate | No | Yes |
| Stream output | No | Yes |
Use Isolate.run() whenever the task returns a single value and
does not need to persist. Switch to Isolate.spawn() when the
isolate must stay alive across multiple messages, emit a stream of results,
or serve as a reusable background worker.
Isolates with Streams
An isolate can produce a sequence of values over time, but its
SendPort is not a Stream. The bridge is a
StreamController on the main side: forward every incoming
port message to the controller and close it when a sentinel value arrives.
The function below wraps this pattern into a typed Stream<int>.
import 'dart:async';
import 'dart:isolate';
// Generator: emits integers 0..count-1, then sends null as a sentinel.
void generator(List<dynamic> args) {
final SendPort port = args[0];
final int count = args[1] as int;
for (int i = 0; i < count; i++) {
port.send(i);
}
port.send(null); // signals end of stream
}
// Bridges an isolate's output to a Stream<int>.
Stream<int> isolateStream(int count) {
final controller = StreamController<int>();
final receivePort = ReceivePort();
Isolate.spawn(generator, [receivePort.sendPort, count]);
receivePort.listen((msg) {
if (msg == null) {
controller.close();
receivePort.close();
} else {
controller.add(msg as int);
}
});
return controller.stream;
}
void main() async {
await for (final value in isolateStream(5)) {
print('Received: $value'); // 0, 1, 2, 3, 4
}
}
The StreamController is not buffered by default, so values are
delivered as fast as the listener consumes them. For a back-pressured variant,
use StreamController(sync: false) and pause the
ReceivePort when the controller is paused.
Server-Side: Offloading CPU Work from an HTTP Handler
In a Dart HTTP server every request is handled on the main isolate's event
loop. A slow synchronous computation inside a request handler blocks all
concurrent connections until it finishes. Using Isolate.run()
moves the work off the event loop so the server stays responsive.
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
// CPU-bound: decode a JSON array and return count + sum of 'value' fields.
Map<String, dynamic> summarise(String body) {
final List items = jsonDecode(body) as List;
final total = items.fold<int>(0, (s, e) => s + (e['value'] as int));
return {'count': items.length, 'total': total};
}
void main() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 8080);
print('Listening on http://localhost:8080');
await for (final HttpRequest req in server) {
if (req.method == 'POST' && req.uri.path == '/sum') {
// Read the full request body as a string.
final body = await req.transform(utf8.decoder).join();
// Offload JSON work – the event loop handles other requests meanwhile.
final result = await Isolate.run(() => summarise(body));
req.response
..headers.contentType = ContentType.json
..write(jsonEncode(result))
..close();
} else {
req.response
..statusCode = HttpStatus.notFound
..close();
}
}
}
Test with: curl -X POST http://localhost:8080/sum -d
'[{"value":10},{"value":20}]'. The server responds with
{"count":2,"total":30} while remaining free to accept new
connections during the computation.
CLI: Parallel File Processing
A command-line tool can use one isolate per input file to count lines in
parallel. All isolates share the same ReceivePort on the main
isolate and send their results back as they finish. The main isolate
aggregates once all expected replies have arrived.
import 'dart:io';
import 'dart:isolate';
// Worker: counts lines in the given file, sends [path, lineCount] back.
void countLines(List<dynamic> args) {
final SendPort port = args[0];
final String path = args[1] as String;
try {
port.send([path, File(path).readAsLinesSync().length]);
} catch (_) {
port.send([path, -1]); // -1 signals a read error
}
}
void main(List<String> arguments) async {
if (arguments.isEmpty) {
print('Usage: dart cli.dart <file1> [file2 ...]');
exit(1);
}
final receivePort = ReceivePort();
// Spawn one isolate per file – all run in parallel.
for (final path in arguments) {
await Isolate.spawn(countLines, [receivePort.sendPort, path]);
}
int received = 0;
int total = 0;
await for (final msg in receivePort) {
final res = msg as List;
final int count = res[1] as int;
if (count < 0) {
print(' error: ${res[0]}');
} else {
print(' ${res[0]}: $count lines');
total += count;
}
if (++received == arguments.length) break;
}
receivePort.close();
print('Total: $total lines in ${arguments.length} file(s)');
}
Run it with dart cli.dart a.txt b.txt c.txt. Because results
arrive in completion order rather than argument order, the printed lines may
appear in any sequence.
Performance Guidelines
Spawning an isolate allocates a fresh heap and initialises a Dart VM worker
thread, which costs roughly 1–5 milliseconds on a typical desktop or
server. A computation that finishes in under a millisecond therefore pays more
in spawn overhead than it saves in parallelism. For short one-shot tasks
Isolate.run() is the right default. For frequently repeated work,
keep a long-lived isolate alive with Isolate.spawn() and send
jobs to it over a port rather than spawning and tearing down for each job.
Message copy cost scales with payload size. Every value sent through a
SendPort is deep-copied, so transferring a 10 MB list can
take longer than the computation itself. The escape hatch for large binary
buffers is TransferableTypedData: wrap a Uint8List
in TransferableTypedData.fromList() before sending, and the
buffer is transferred rather than copied. The sender's reference
becomes invalid after the transfer, but the receiver gets the data
instantaneously regardless of size.
A frequent mistake in server code is spawning a new isolate for every incoming
HTTP request. At low traffic this works, but under load the cumulative spawn
cost and memory pressure from dozens of simultaneous heaps can degrade
throughput more than the parallelism helps. A fixed-size worker pool — a small
number of long-lived isolates accepting job messages — amortises the startup
cost and caps peak memory consumption. As a rule of thumb: one-shot tasks suit
Isolate.run(); sustained high-frequency work suits a pool built
on Isolate.spawn().
Source
Dart Isolate.run, Dart Isolate class, Dart concurrency documentation
This tutorial covered Isolate.run() and when to prefer it over
Isolate.spawn(), bridging isolate output to a
Stream, offloading CPU work in an HTTP server, parallel file
processing from the CLI, and practical performance considerations.
Author
List all Dart tutorials.