Why isolates?
- Flutter’s UI thread (the main isolate) must hit 16.7 ms per frame for 60 FPS (8.3 ms for 120 FPS). CPU-bound work (parsing, compression, crypto, image transforms) can blow this budget.
- Dart isolates provide true parallelism (separate memory heaps) for CPU-heavy tasks while keeping the UI isolate responsive.
What isolates are (and are not)
- Are: separate memory heaps with message passing via SendPort/ReceivePort; can run on different CPU cores.
- Are not: shared memory threads. You cannot mutate objects across isolates; you pass values (copied or transferable).
Use cases
- Heavy JSON/Protobuf parsing, compression (gzip), encryption/decryption
- Image processing, thumbnail generation, PDF rendering
- Complex business calculations, large rule evaluation, ML inference (when FFI isn’t used)
- Background precomputation (e.g., index building, search preparation)
Key APIs
- spawn(): start a new isolate with an entrypoint
- Isolate.run(): structured helper (Dart 3) that runs a closure on a fresh isolate and returns a Future
- Compute (Flutter foundation): simplified pattern to offload a pure function to another isolate (avoid for long-lived workers)
Principles of structured concurrency
- Child tasks should be bounded in lifetime to avoid resource leaks.
- Prefer
Isolate.runfor one-off heavy CPU tasks — it manages the isolate lifecycle for you. - Use a worker isolate pool for repeated, homogeneous tasks to reduce spawn overhead.
Data transfer: copy vs. transfer
- Most values are copied across isolates. Deep copies are expensive for big graphs.
- Use TransferableTypedData for zero-copy transfer of binary data (Uint8List) where possible.
- For large JSON, consider streaming parse + schema-aware mapping to reduce copies.
Patterns
- One-off heavy CPU task with Isolate.run (Dart 3)
Future<Result> parseLargeJson(String input) => Isolate.run(() {
final map = jsonDecode(input) as Map<String, dynamic>;
return toDomain(map); // heavy mapping
});
- Pros: simple, structured lifecycle
- Cons: pays isolate spin-up each time; fine for occasional tasks
- Worker pool for repeated jobs
- Create N worker isolates pinned by CPUs
- Send jobs via SendPort, receive results on a shared ReceivePort
- Benefits: amortize spawn cost; handle backpressure
Sketch:
class Job {
final int id;
final TransferableTypedData data;
Job(this.id, this.data);
}
class WorkerPool {
final int size;
final _free = Queue<SendPort>();
final _pending = Queue<Job>();
final _responses = StreamController<MapEntry<int, Uint8List>>.broadcast();
WorkerPool(this.size);
Future<void> start() async {
final receiver = ReceivePort();
receiver.listen((msg) {
if (msg is SendPort) {
_free.add(msg);
_drain();
} else if (msg is Map) {
// {id: int, data: TransferableTypedData}
final id = msg['id'] as int;
final ttd = msg['data'] as TransferableTypedData;
_responses.add(MapEntry(id, ttd.materialize().asUint8List()));
_free.add(msg['reply'] as SendPort);
_drain();
}
});
// spawn
for (var i = 0; i < size; i++) {
await Isolate.spawn(_workerEntry, receiver.sendPort);
}
}
Stream<MapEntry<int, Uint8List>> get responses => _responses.stream;
void submit(Job job) {
_pending.add(job);
_drain();
}
void _drain() {
while (_pending.isNotEmpty && _free.isNotEmpty) {
final port = _free.removeFirst();
final job = _pending.removeFirst();
port.send({'id': job.id, 'data': job.data});
}
}
static void _workerEntry(SendPort host) {
final receive = ReceivePort();
host.send(receive.sendPort);
receive.listen((message) async {
final Map msg = message as Map;
final id = msg['id'] as int;
final ttd = msg['data'] as TransferableTypedData;
final bytes = ttd.materialize().asUint8List();
// do heavy work
final out = await heavyTransform(bytes);
host.send({'id': id, 'data': TransferableTypedData.fromList([out]), 'reply': receive.sendPort});
});
}
}
- Notes:
- Use TransferableTypedData to avoid copies for large binary payloads
- Backpressure naturally emerges from limited
_freeports
- Hybrid: FFI + isolates
- Offload CPU-bound native routines (e.g., image codecs, crypto) via FFI; call from a worker isolate to avoid blocking UI isolate on FFI calls
- Be careful with thread-affine native libraries; some require a single thread or specific initialization
Cancellation and deadlines
- Isolate APIs don’t have built-in cancellation for arbitrary work; design your jobs as cooperative:
- Check a shared cancellation flag (via message) periodically
- Use time-bounded chunks to yield and check mailbox
- Deadline budgeting:
- Wrap with a
Future.any([work, deadlineFuture])and design worker to stop on deadline signal
- Wrap with a
Error handling and isolation
- Uncaught errors in worker isolates don’t crash the UI isolate; catch and forward
- Use
Isolate.addErrorListenerfor global observe → log/metrics - Triage errors by type (OOM, FormatException, etc.) and report with context
Memory considerations
- Avoid unbounded queues; enforce max backlog
- Free large buffers promptly (set references to null to help GC)
- Prefer streaming (e.g., chunked parsing) over loading entire payloads
When not to use isolates
- IO-bound tasks (network/disk) that await; they don’t occupy UI thread—use async/await
- Tiny computations where isolate overhead > work
Testing strategy
- Unit test transform functions in pure Dart
- Integration test pool scheduling (deterministic with fake timers and measured concurrency)
- Property tests for idempotency and boundary sizes (e.g., 0 bytes, huge buffers)
Observability
- Measure task latency, queue depth, success/error rates
- Log max heap sizes per isolate (if available), and time to first job
- Alert on deadline misses and growing backlogs
Adoption checklist
- Identify CPU hotspots via profiling (DevTools CPU, instruments)
- Start with
Isolate.runfor one-off heavy work - Introduce a small worker pool where repeated tasks exist
- Use TransferableTypedData for big binaries
- Add cooperative cancellation + deadlines
- Instrument pool metrics and add dashboards
Conclusion Isolates are a powerful tool to keep Flutter UIs smooth under heavy CPU workloads. With structured concurrency, message-passing discipline, and careful memory management, you can scale performance safely without turning the app into a maze of brittle threading code.