Dart Isolates — High Performance Tutorial
Lesson 1 — What is an Isolate?
Short explanation in plain words, then a minimal example to get started.
Conceptually: Dart runs code in an isolate. The Flutter UI runs on the main isolate. If a heavy CPU task runs on the main isolate (e.g., parsing tens of thousands of JSON objects), the UI will stutter. Move CPU-bound operations to other isolates.
// minimal_isolate.dart
import 'dart:isolate';
void worker(SendPort sendBack) {
// This worker receives nothing here — in more advanced examples
// we'll set up a port to receive messages. For demo, just send back a message.
sendBack.send('worker: hello from isolate');
}
Future main() async {
final receivePort = ReceivePort();
await Isolate.spawn(worker, receivePort.sendPort);
// listen for a single message and then close
final msg = await receivePort.first;
print(msg); // worker: hello from isolate
receivePort.close();
}
Step-by-step
- Create a
ReceivePort
in the main isolate to receive messages. - Call
Isolate.spawn(worker, receivePort.sendPort)
; the worker receives the send port. - Worker can use that port to send messages back to main.
- Close your ports when done (
receivePort.close()
).
Lesson 2 — Message Passing: SendPort & ReceivePort
Understanding how two-way communication works and building a small echo worker that replies to messages.
// message_passing.dart
import 'dart:isolate';
void echoWorker(SendPort initialReplyTo) {
// create a port to receive messages from main
final port = ReceivePort();
// send the worker's SendPort back to the main isolate
initialReplyTo.send(port.sendPort);
port.listen((message) {
// message is expected: [SendPort replyTo, data]
if (message is List && message.length == 2) {
final SendPort replyTo = message[0];
final data = message[1];
// perform work (echo)
replyTo.send('echo: $data');
}
});
}
Future main() async {
final ready = ReceivePort();
await Isolate.spawn(echoWorker, ready.sendPort);
final SendPort workerSendPort = await ready.first as SendPort;
final responsePort = ReceivePort();
// send [responsePort.sendPort, data]
workerSendPort.send([responsePort.sendPort, 'hello worker']);
final resp = await responsePort.first;
print(resp); // echo: hello worker
responsePort.close();
ready.close();
}
Key points
- To call a worker and get a reply: send a message that includes a
SendPort
the worker can use to reply. - Workers must call
ReceivePort()
and expose theSendPort
back to main. - Always guard message shapes (types) to avoid runtime errors.
Lesson 3 — compute(): Convenient one-shot background work
Flutter's compute()
is a helper for running a single function in a new isolate
and getting the result back. Good for one-off heavy jobs.
// compute_example.dart (Flutter)
import 'package:flutter/foundation.dart';
int heavySum(int n) {
int s = 0;
for (int i = 0; i < n; i++) s += i;
return s;
}
// somewhere in your widget code or init
Future runHeavy() async {
final result = await compute(heavySum, 100000000);
print('sum = $result');
}
Notes
compute
requires that both function and message are top-level or static (so they can be sent across isolates).- Each compute call spawns a fresh isolate and kills it after completion — this has overhead, so avoid frequent small compute() calls.
Lesson 4 — Parsing large JSON in background
A practical pattern: load a big JSON string on main, convert it to a light-weight structure (List<Map>), then process chunks in isolates.
// item_model.dart
class Item {
final int id;
final String name;
final double value;
Item({required this.id, required this.name, required this.value});
factory Item.fromJson(Map<String, dynamic> j) => Item(
id: j['id'] as int,
name: j['name'] as String,
value: (j['value'] as num).toDouble(),
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'value': value,
};
}
// parse_worker.dart (worker entrypoint - receives List<Map> chunks)
import 'dart:isolate';
import 'item_model.dart';
void parseWorker(SendPort initialReplyTo) {
final port = ReceivePort();
initialReplyTo.send(port.sendPort);
port.listen((message) {
// message: [SendPort replyTo, List
Pattern
- Main: load JSON string → decode → get List<Map>.
- Chunk that list (e.g., 1000 items per chunk).
- Send each chunk to a worker with a reply
SendPort
. - Worker converts maps → domain objects, processes, sends result back.
Lesson 5 — Image processing off the main isolate
Examples: thumbnail generation, basic filters, or blur before upload. Heavy pixel ops should run off the main isolate.
// image_resize_worker.dart (outline)
import 'dart:isolate';
import 'dart:typed_data';
import 'package:image/image.dart' as img;
void imageWorker(SendPort initialReplyTo) {
final port = ReceivePort();
initialReplyTo.send(port.sendPort);
port.listen((message) {
// message: [SendPort reply, Uint8List bytes, int targetWidth]
if (message is List && message.length >= 3) {
final SendPort reply = message[0];
final Uint8List bytes = message[1];
final int width = message[2];
final img.Image? image = img.decodeImage(bytes);
if (image != null) {
final resized = img.copyResize(image, width: width);
final png = img.encodePng(resized);
reply.send(Uint8List.fromList(png));
} else {
reply.send(null);
}
}
});
}
Notes
- Use
image
package functions inside isolates to avoid UI jank. - Return
Uint8List
(binary) to main; for very large binary transfers considerTransferableTypedData
.
Lesson 6 — Worker Pool Pattern (Recommended)
For many repeated tasks, use a long-lived pool of isolates rather than spawn/kill each time.
// worker_pool.dart (simplified)
import 'dart:isolate';
import 'worker_entry.dart'; // workerEntryPoint that sends its SendPort back
import 'domain.dart'; // ProcessingRequest, ProcessingChunkResult
class WorkerPool {
final int size;
final List<Isolate> _isolates = [];
final List<SendPort> _sendPorts = [];
bool _started = false;
WorkerPool({int? size}) : size = size ?? 4;
Future<void> start() async {
if (_started) return;
for (var i = 0; i < size; i++) {
final readyPort = ReceivePort();
final iso = await Isolate.spawn(workerEntryPoint, readyPort.sendPort);
final sendPort = await readyPort.first as SendPort;
_isolates.add(iso);
_sendPorts.add(sendPort);
}
_started = true;
}
Future<void> dispose() async {
for (final iso in _isolates) iso.kill(priority:Isolate.immediate);
_isolates.clear();
_sendPorts.clear();
_started = false;
}
Future<void> process({
required List<Map<String,dynamic>> json,
required ProcessingRequest request,
required void Function(ProcessingChunkResult) onChunk,
int chunkSize = 1000,
}) async {
if (!_started) await start();
final receivePort = ReceivePort();
int idx = 0;
int nextWorker = 0;
int inFlight = 0;
void sendChunk(List<Map<String,dynamic>> chunk) {
final msg = _WorkMessage(chunk, request, receivePort.sendPort);
_sendPorts[nextWorker].send(msg);
nextWorker = (nextWorker + 1) % _sendPorts.length;
inFlight++;
}
// initial wave
while (idx < json.length && inFlight < size) {
final end = (idx + chunkSize).clamp(0, json.length);
sendChunk(json.sublist(idx, end));
idx = end;
}
await for (final res in receivePort) {
if (res is ProcessingChunkResult) {
onChunk(res);
inFlight--;
if (idx < json.length) {
final end = (idx + chunkSize).clamp(0, json.length);
sendChunk(json.sublist(idx, end));
idx = end;
} else if (inFlight == 0) {
receivePort.close();
break;
}
}
}
}
}
Tuning
- Pool size: try values like 2,4,6 depending on CPU cores and other app load.
- Chunk size: smaller chunks = more UI updates but more overhead; larger = less overhead but slower incremental feedback.
Lesson 7 — Streaming Partial Results to UI
Use a StreamController
in your service layer to convert worker callbacks into a
stream the UI can subscribe to.
// processor_service.dart (excerpt)
import 'dart:async';
import 'item_local_source.dart';
import 'worker_pool.dart';
import 'domain.dart';
class ProcessorService {
final ItemLocalSource source;
final WorkerPool pool;
ProcessorService(this.source, this.pool);
Stream<ProcessingChunkResult> processStream(ProcessingRequest request) async* {
final items = await source.load();
final jsonList = items.map((e) => e.toJson()).toList();
final controller = StreamController<ProcessingChunkResult>();
pool.process(
json: jsonList,
request: request,
onChunk: controller.add,
chunkSize: 1500,
);
yield* controller.stream;
await controller.close();
}
}
UI subscription pattern
In the widget, call service.processStream(request)
and listen()
. Update state
per chunk to avoid excessive rebuilds.
Lesson 8 — Best Practices & Pitfalls
- Close ReceivePorts & kill isolates on dispose.
- Measure and record timings (single-thread vs pool) for your portfolio.
- Use TransferableTypedData for very large byte transfers.
- Throttle UI updates if chunks arrive too frequently.
Common mistakes
- Running heavy tasks on main isolate → UI jank.
- Sending objects with closures/methods across isolate boundaries (not serializable).
- Leaving orphaned ReceivePorts or not killing isolates (memory leaks).
Portfolio suggestions
- Show benchmarks (ms) for single-thread vs pool.
- Provide screenshots/GIF of progress bar updating while processing.
- Explain design decisions (chunk size choice, number of workers).