Dart Isolates and Offloading Heavy Computation
Dart Isolates and Offloading Heavy Computation
Flutter renders its UI at 60 or 120 frames per second, meaning the main isolate has roughly 8–16 milliseconds per frame to complete all work. If you perform CPU-intensive tasks — parsing a large JSON file, encrypting data, processing images, or running complex algorithms — directly on the main isolate, you will block the UI thread and cause visible jank or frozen frames. Dart isolates are the solution: each isolate is an independent unit of execution with its own memory heap, communicating only by message passing, never by shared memory.
How Dart Isolates Work
Unlike threads in Java or C++, Dart isolates do not share memory. They each have their own heap, event loop, and garbage collector. Communication between isolates happens by passing messages through SendPort and ReceivePort objects. This design eliminates data races and the need for locks, making concurrent code far safer. The key rule is: objects sent between isolates are copied, not referenced (with a few zero-copy exceptions for typed data in newer Dart versions).
The Easy Path: compute()
Flutter provides the top-level compute() function as a convenience wrapper around Isolate.spawn. It is ideal for simple fire-and-forget tasks where you want to run a pure function in a background isolate and get a single result back. compute() automatically handles spawning, message passing, and cleanup.
Using compute() to Parse Heavy JSON
import 'dart:convert';
import 'package:flutter/foundation.dart';
// This function MUST be a top-level or static function
// (not a closure or instance method) — isolates cannot capture closures
List<Map<String, dynamic>> _parseProductJson(String jsonString) {
final List<dynamic> decoded = jsonDecode(jsonString) as List<dynamic>;
return decoded
.map((item) => item as Map<String, dynamic>)
.toList();
}
// In your widget or service class:
Future<List<Map<String, dynamic>>> loadProducts(String rawJson) async {
// Runs _parseProductJson in a background isolate
// The UI thread stays free during parsing
final products = await compute(_parseProductJson, rawJson);
return products;
}
compute() accepts only a single argument. If your function needs multiple parameters, wrap them in a single object or a Map. For example, pass {'data': rawJson, 'locale': 'en'} and unpack inside the function.Full Control: Isolate.spawn and Ports
When you need bidirectional communication, streaming results, or long-lived background workers, use Isolate.spawn directly with SendPort and ReceivePort. This gives you complete control over the isolate lifecycle.
Long-Lived Isolate with Bidirectional Communication
import 'dart:isolate';
import 'dart:convert';
// Entry point for the background isolate — must be top-level
void _encryptionWorker(SendPort mainSendPort) {
final ReceivePort workerReceivePort = ReceivePort();
// Send our port to the main isolate so it can communicate back
mainSendPort.send(workerReceivePort.sendPort);
workerReceivePort.listen((message) {
if (message is Map<String, dynamic>) {
final String plainText = message['text'] as String;
// Simulate heavy encryption work
final String encrypted = base64Encode(
utf8.encode(plainText.split('').reversed.join()),
);
mainSendPort.send({'result': encrypted, 'id': message['id']});
} else if (message == 'stop') {
workerReceivePort.close();
}
});
}
class EncryptionService {
Isolate? _isolate;
SendPort? _workerSendPort;
final ReceivePort _mainReceivePort = ReceivePort();
Future<void> start() async {
_isolate = await Isolate.spawn(_encryptionWorker, _mainReceivePort.sendPort);
// First message back from worker is its SendPort
_workerSendPort = await _mainReceivePort.first as SendPort;
}
Future<String> encrypt(String text, int id) async {
_workerSendPort!.send({'text': text, 'id': id});
// Listen for the matching response
final result = await _mainReceivePort
.where((msg) => msg is Map && msg['id'] == id)
.first as Map<String, dynamic>;
return result['result'] as String;
}
void stop() {
_workerSendPort?.send('stop');
_isolate?.kill(priority: Isolate.immediate);
}
}
When to Use compute() vs Isolate.spawn
- compute(): One-shot tasks, single input → single output (JSON parsing, image filtering, heavy sorting, encryption of a single payload). Simplest API, no manual port management.
- Isolate.spawn: Long-lived workers, streaming data, multiple back-and-forth messages, tasks that must persist between user interactions (e.g., a background download processor or real-time audio pipeline).
- Dart 2.15+ Isolate.run(): A cleaner alternative to
compute()that works with any async function, not just Flutter apps. Functionally equivalent for simple cases.
Common Real-World Use Cases
- JSON parsing: API responses with thousands of records should never be decoded on the main isolate.
- Image processing: Resizing, compressing, applying filters — all are CPU-bound and ideal for background isolates.
- Encryption / hashing: Generating password hashes (bcrypt, Argon2) or encrypting files can take hundreds of milliseconds.
- Search and filtering: Full-text search over large in-memory datasets blocks the UI if done synchronously.
- PDF or file generation: Building complex documents involves heavy string/byte manipulation that should run off-thread.
TransferableTypedData), so avoid passing enormous data structures unnecessarily; prefer passing only primitive values or compact encodings like JSON strings.Profiling: Confirming Work is Off the Main Thread
After offloading work, open Flutter DevTools → Performance tab and record a trace. CPU-bound work done in background isolates appears in separate thread lanes (labelled "Isolate" or "Worker"). The main UI thread lane should show thin, evenly spaced frames. If frames are still thick (red/yellow), the heavy work is still running on the main isolate — double-check that your function is top-level and that compute() or Isolate.spawn is actually awaited before the result is used.
Summary
Dart's isolate model is a safe and efficient way to achieve true parallelism without data races. Use compute() for simple, one-shot CPU tasks and Isolate.spawn (or Isolate.run in Dart 2.15+) for persistent workers with richer communication. Always verify the offload actually worked using DevTools — keeping the main isolate free is the foundation of smooth Flutter animations.