Your Flutter app runs on a single thread. Every widget build, every animation frame, every gesture callback — all on the same thread. The moment you drop a 2MB JSON parse or a heavy sorting algorithm into that thread, your UI freezes. Frames get skipped. Users see jank.
Dart's async/await doesn't fix this. It handles waiting — network calls, file reads, timers — but it
doesn't move computation off the main thread. For CPU-bound work, you need isolates: Dart's model for true
parallelism. Each isolate runs on its own thread with its own memory heap, communicating through message passing.
I've built isolate-based architectures for a courier app processing 50,000+ live tracking points and a fintech app decrypting transaction payloads in the background. The patterns in this guide come from those production systems.
What You'll Learn
- When to use
async/awaitvs isolates - One-shot tasks with
compute()andIsolate.run() - Long-lived workers with
Isolate.spawn() - Bidirectional communication with ports
- Background JSON parsing and image processing
- Worker pool pattern for task queues
- Error handling and debugging in isolates
1. Why Async/Await Isn't Always Enough
Flutter targets 60 frames per second. That gives you 16.6 milliseconds per frame. If your code takes longer than that on the main thread, the frame gets dropped. Users see stuttering.
Here's the distinction:
| Task Type | Solution | Example |
|---|---|---|
| I/O-bound (waiting) | async/await |
HTTP request, file read, database query |
| CPU-bound (computing) | Isolates | JSON parsing, encryption, image processing, sorting large lists |
The key insight: await yields control back to the event loop while waiting for an external result.
But during a synchronous computation like jsonDecode(massiveString), the event loop is blocked. No
frames render, no gestures process, no animations advance.
// This blocks the UI — jsonDecode runs synchronously on the main thread
Future<void> loadData() async {
final response = await http.get(Uri.parse('https://api.example.com/huge-dataset'));
// ↓ This line freezes the UI if the JSON is large
final data = jsonDecode(response.body); // Runs on main thread!
setState(() => _items = data['items']);
}
// Fix: Move the CPU-heavy work to an isolate
Future<void> loadDataProperly() async {
final response = await http.get(Uri.parse('https://api.example.com/huge-dataset'));
// ↓ This runs on a separate thread
final data = await compute(jsonDecode, response.body);
setState(() => _items = data['items']);
}
2. How Dart Isolates Work
An isolate is a separate execution context with its own memory heap and event loop. Unlike threads in Java or C++, isolates never share memory. They communicate through message passing — sending copies of data between send ports and receive ports.
This model eliminates entire categories of bugs: no race conditions, no deadlocks, no mutex locks, no shared state corruption. The trade-off is the cost of copying data between isolates.
// Conceptual model:
//
// ┌─────────────────┐ message ┌─────────────────┐
// │ Main Isolate │ ──────────────→│ Worker Isolate │
// │ │ │ │
// │ - UI rendering │ message │ - JSON parsing │
// │ - Event loop │ ←──────────────│ - Computation │
// │ - Gestures │ │ - Own heap │
// └─────────────────┘ └─────────────────┘
//
// Each isolate has its own memory heap.
// Data is copied (not shared) when sent between isolates.
// Dart can send most types efficiently, including typed data lists.
What Can Be Sent Between Isolates?
Most Dart types can be sent: primitives, strings, lists, maps, typed data (Uint8List), and even closures in some cases. Things you cannot send: open sockets, file handles, UI objects (widgets, RenderObjects), and objects with native resources (like a platform channel instance).
3. The compute() Function
compute() is the simplest way to run a function on a background isolate. It
spawns an isolate, runs
the function, returns the result, and kills the isolate. One shot.
import 'package:flutter/foundation.dart';
// The function MUST be a top-level function or a static method.
// It cannot be a closure, instance method, or anonymous function.
List<Map<String, dynamic>> _parseUsers(String jsonString) {
final decoded = jsonDecode(jsonString) as Map<String, dynamic>;
final users = (decoded['users'] as List)
.cast<Map<String, dynamic>>()
.where((u) => u['active'] == true)
.toList();
// Sort by name — CPU-intensive for large lists
users.sort((a, b) => (a['name'] as String).compareTo(b['name'] as String));
return users;
}
// Usage in a widget
Future<void> _loadUsers() async {
setState(() => _isLoading = true);
final response = await http.get(Uri.parse('$baseUrl/users'));
final users = await compute(_parseUsers, response.body);
setState(() {
_users = users;
_isLoading = false;
});
}
compute() Gotcha: Top-Level Functions Only
The function passed to compute() must be a top-level function or a static method. Instance
methods, closures, and anonymous functions will throw a runtime error because they can't be sent across isolate
boundaries — they capture references to the main isolate's memory.
4. Isolate.spawn() and Isolate.run()
Dart 2.19+ introduced Isolate.run() as a cleaner replacement for compute(). It works
the same way but is part of dart:isolate (no Flutter dependency) and accepts closures:
import 'dart:isolate';
// Isolate.run() — one-shot, accepts closures
Future<List<int>> findPrimes(int limit) async {
return Isolate.run(() {
final primes = <int>[];
for (var i = 2; i <= limit; i++) {
var isPrime = true;
for (var j = 2; j * j <= i; j++) {
if (i % j == 0) {
isPrime = false;
break;
}
}
if (isPrime) primes.add(i);
}
return primes;
});
}
// Usage
final primes = await findPrimes(1000000);
print('Found ${primes.length} primes');
For long-lived background workers, use Isolate.spawn():
import 'dart:isolate';
class BackgroundWorker {
Isolate? _isolate;
SendPort? _sendPort;
final _receivePort = ReceivePort();
Future<void> start() async {
_isolate = await Isolate.spawn(
_workerEntryPoint,
_receivePort.sendPort,
);
// First message from the worker is its SendPort
_sendPort = await _receivePort.first as SendPort;
}
void processData(Map<String, dynamic> data) {
_sendPort?.send(data);
}
Stream<dynamic> get results => _receivePort.asBroadcastStream();
void dispose() {
_isolate?.kill(priority: Isolate.beforeNextEvent);
_receivePort.close();
}
static void _workerEntryPoint(SendPort mainSendPort) {
final workerReceivePort = ReceivePort();
// Send back our SendPort so the main isolate can talk to us
mainSendPort.send(workerReceivePort.sendPort);
workerReceivePort.listen((message) {
if (message is Map<String, dynamic>) {
// Do heavy processing...
final result = _processPayload(message);
mainSendPort.send(result);
}
});
}
static Map<String, dynamic> _processPayload(Map<String, dynamic> data) {
// Heavy computation here
return {'processed': true, 'itemCount': data.length};
}
}
5. Communication with SendPort and ReceivePort
Isolates communicate through ports. A ReceivePort creates a SendPort. You send messages
through the SendPort, and they arrive on the ReceivePort's stream.
import 'dart:isolate';
Future<void> bidirectionalExample() async {
final mainReceivePort = ReceivePort();
await Isolate.spawn(
_bidirectionalWorker,
mainReceivePort.sendPort,
);
// Get the worker's SendPort
final workerSendPort = await mainReceivePort.first as SendPort;
// Create a new ReceivePort for results
final resultPort = ReceivePort();
// Send work with a reply port
workerSendPort.send({
'task': 'sort',
'data': [5, 3, 8, 1, 9, 2, 7, 4, 6],
'replyPort': resultPort.sendPort,
});
final result = await resultPort.first;
print('Sorted: $result'); // [1, 2, 3, 4, 5, 6, 7, 8, 9]
resultPort.close();
}
void _bidirectionalWorker(SendPort mainPort) {
final workerPort = ReceivePort();
mainPort.send(workerPort.sendPort);
workerPort.listen((message) {
final task = message['task'] as String;
final replyPort = message['replyPort'] as SendPort;
switch (task) {
case 'sort':
final data = List<int>.from(message['data']);
data.sort();
replyPort.send(data);
default:
replyPort.send({'error': 'Unknown task: $task'});
}
});
}
6. Background JSON Parsing
The most common isolate use case. APIs return JSON strings that need to be decoded and mapped to model classes. For responses under 100KB, parsing on the main thread is fine. For anything larger, use an isolate:
class Product {
final String id;
final String name;
final double price;
final String category;
const Product({
required this.id,
required this.name,
required this.price,
required this.category,
});
factory Product.fromJson(Map<String, dynamic> json) => Product(
id: json['id'] as String,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
category: json['category'] as String,
);
}
// Top-level function for compute()
List<Product> _parseProducts(String jsonString) {
final decoded = jsonDecode(jsonString) as Map<String, dynamic>;
return (decoded['products'] as List)
.map((item) => Product.fromJson(item as Map<String, dynamic>))
.toList();
}
class ProductRepository {
final http.Client _client;
ProductRepository(this._client);
Future<List<Product>> fetchProducts() async {
final response = await _client.get(Uri.parse('$baseUrl/products'));
if (response.statusCode != 200) {
throw HttpException('Failed to load products: ${response.statusCode}');
}
// Parse on background isolate if response is large
if (response.contentLength != null && response.contentLength! > 100000) {
return compute(_parseProducts, response.body);
}
// Small response — parse on main thread
return _parseProducts(response.body);
}
}
7. Background Image Processing
Image manipulation — resizing, filtering, compression — is CPU-intensive. Move it to an isolate to keep the UI smooth:
import 'dart:typed_data';
import 'package:image/image.dart' as img;
class ImageProcessingResult {
final Uint8List bytes;
final int width;
final int height;
const ImageProcessingResult(this.bytes, this.width, this.height);
}
ImageProcessingResult _resizeImage(Map<String, dynamic> params) {
final bytes = params['bytes'] as Uint8List;
final maxWidth = params['maxWidth'] as int;
final quality = params['quality'] as int;
final original = img.decodeImage(bytes);
if (original == null) throw Exception('Failed to decode image');
// Only resize if wider than maxWidth
final resized = original.width > maxWidth
? img.copyResize(original, width: maxWidth)
: original;
final encoded = img.encodeJpg(resized, quality: quality);
return ImageProcessingResult(
Uint8List.fromList(encoded),
resized.width,
resized.height,
);
}
// Usage
Future<ImageProcessingResult> processImage(Uint8List imageBytes) async {
return compute(_resizeImage, {
'bytes': imageBytes,
'maxWidth': 1080,
'quality': 85,
});
}
8. Building a Worker Pool
Spawning a new isolate for every task wastes resources — each isolate takes a few milliseconds to start and a few MB of memory. A worker pool reuses isolates across tasks:
import 'dart:async';
import 'dart:isolate';
import 'dart:collection';
typedef TaskFunction<R> = FutureOr<R> Function();
class WorkerPool {
final int _poolSize;
final List<_Worker> _workers = [];
final Queue<_PendingTask> _taskQueue = Queue();
bool _isDisposed = false;
WorkerPool({int poolSize = 4}) : _poolSize = poolSize;
Future<void> start() async {
for (var i = 0; i < _poolSize; i++) {
final worker = _Worker(i);
await worker.initialize();
_workers.add(worker);
}
}
Future<R> execute<R>(TaskFunction<R> task) async {
if (_isDisposed) throw StateError('Pool is disposed');
// Find an idle worker
final idleWorker = _workers.cast<_Worker?>().firstWhere(
(w) => !w!.isBusy,
orElse: () => null,
);
if (idleWorker != null) {
return idleWorker.execute(task);
}
// All workers busy — queue the task
final completer = Completer<R>();
_taskQueue.add(_PendingTask(task, completer));
return completer.future;
}
void dispose() {
_isDisposed = true;
for (final worker in _workers) {
worker.dispose();
}
_workers.clear();
}
}
class _Worker {
final int id;
bool isBusy = false;
Isolate? _isolate;
SendPort? _sendPort;
_Worker(this.id);
Future<void> initialize() async {
final receivePort = ReceivePort();
_isolate = await Isolate.spawn(_entryPoint, receivePort.sendPort);
_sendPort = await receivePort.first as SendPort;
}
Future<R> execute<R>(TaskFunction<R> task) async {
isBusy = true;
try {
return await Isolate.run(task);
} finally {
isBusy = false;
}
}
void dispose() {
_isolate?.kill(priority: Isolate.beforeNextEvent);
}
static void _entryPoint(SendPort mainPort) {
final workerPort = ReceivePort();
mainPort.send(workerPort.sendPort);
workerPort.listen((message) {
// Process messages
});
}
}
class _PendingTask<R> {
final TaskFunction<R> task;
final Completer<R> completer;
_PendingTask(this.task, this.completer);
}
9. Streaming Data Between Isolates
For use cases like real-time data processing (sensor feeds, live locations, chat messages), stream results from a background isolate to the UI:
Stream<List<DataPoint>> processLiveData(Stream<String> rawStream) async* {
final receivePort = ReceivePort();
final isolate = await Isolate.spawn(
_dataProcessor,
receivePort.sendPort,
);
final broadcastPort = receivePort.asBroadcastStream();
final workerSendPort = await broadcastPort.first as SendPort;
// Forward raw data to the isolate
rawStream.listen((chunk) => workerSendPort.send(chunk));
// Yield processed results
await for (final result in broadcastPort) {
if (result is List) {
yield result.cast<DataPoint>();
} else if (result == 'done') {
break;
}
}
isolate.kill();
receivePort.close();
}
void _dataProcessor(SendPort mainPort) {
final workerPort = ReceivePort();
mainPort.send(workerPort.sendPort);
final buffer = <Map<String, dynamic>>[];
workerPort.listen((message) {
if (message is String) {
final decoded = jsonDecode(message) as Map<String, dynamic>;
buffer.add(decoded);
// Process in batches of 50
if (buffer.length >= 50) {
final processed = buffer
.map((d) => DataPoint(
timestamp: DateTime.parse(d['ts'] as String),
value: (d['val'] as num).toDouble(),
))
.toList();
mainPort.send(processed);
buffer.clear();
}
}
});
}
class DataPoint {
final DateTime timestamp;
final double value;
const DataPoint({required this.timestamp, required this.value});
}
10. Error Handling in Isolates
Exceptions in isolates don't propagate to the main isolate by default — they crash the isolate silently. Always wrap entry points in try-catch and send errors through the port:
void _safeWorkerEntryPoint(SendPort mainPort) {
final workerPort = ReceivePort();
mainPort.send(workerPort.sendPort);
workerPort.listen((message) {
try {
final result = _doHeavyWork(message);
mainPort.send({'status': 'success', 'data': result});
} catch (e, stackTrace) {
// Send the error back instead of crashing silently
mainPort.send({
'status': 'error',
'error': e.toString(),
'stackTrace': stackTrace.toString(),
});
}
});
}
// On the main isolate side:
void _handleWorkerMessage(dynamic message) {
if (message is Map<String, dynamic>) {
switch (message['status']) {
case 'success':
_onResult(message['data']);
case 'error':
_onError(message['error'], message['stackTrace']);
}
}
}
// For Isolate.run(), errors propagate automatically:
Future<void> safeRun() async {
try {
final result = await Isolate.run(() {
throw FormatException('Bad data');
});
} on FormatException catch (e) {
// This catches the error from the isolate
print('Caught from isolate: $e');
}
}
11. Production Patterns and Best Practices
- Benchmark first. Don't isolate everything. Use DevTools Timeline to measure. If a task takes <16ms, leave it on the main thread. The isolate spawn overhead (2-5ms) might negate the benefit.
- Minimize data transfer. Sending large objects between isolates means copying. Pass
Uint8ListorTransferableTypedDatawhen possible — typed data can be transferred with zero-copy. - Reuse isolates. Don't spawn per request. Keep a pool of 2-4 workers and route tasks to them. Spawning is cheap but not free.
- Never access UI from isolates. No
BuildContext, nosetState, no widget references. Isolates have separate memory — they physically can't see the UI tree. - Use
Isolate.run()overcompute().Isolate.run()is the modern API (Dart 2.19+). It supports closures, doesn't require Flutter, and has cleaner error propagation. - Handle isolate death. Isolates can be killed by the OS under memory pressure. Use
Isolate.addOnExitListener()to detect when a worker dies and restart it. - Profile on real devices. Emulators have different CPU characteristics. A task that's fine on your M2 MacBook might freeze on a budget Android phone. Check the Flutter performance best practices for profiling tips.
- Watch memory. Each isolate allocates its own ~2MB heap minimum. Four isolates = 8MB baseline. On low-end devices with 2GB RAM, that matters.
Related Guides
- Flutter Performance: Optimization Techniques That Work
- Building E-Commerce Apps with Flutter and Stripe
- GetX vs Bloc vs Riverpod: State Management Comparison 2026
- Flutter Testing Strategy: Unit, Widget, and Integration Tests
- Flutter Clean Architecture: A Practical Guide
- Flutter App Security: Protecting User Data
- Flutter Google Maps Integration: Geolocation & Custom Markers
- Deep Linking in Flutter: Universal Links, App Links & GoRouter
- Flutter Responsive Design: Building Adaptive UIs
- Flutter DevOps: CI/CD Pipeline with GitHub Actions
Frequently Asked Questions
What's the difference between async/await and isolates?
async/await handles
I/O-bound tasks (network calls, file reads) on the same thread — it doesn't
help with CPU-bound work. Isolates run code on a separate thread with separate memory. Use
async/await for waiting on external services, and isolates for heavy computation like JSON
parsing, image processing, or cryptographic operations.
When should I use compute() vs Isolate.spawn()?
Use compute() (or Isolate.run()) for one-shot tasks: parse a JSON blob, compress an
image, run a calculation. It handles spawning and cleanup. Use Isolate.spawn() for persistent
workers that process multiple messages over time — like a real-time data pipeline or background sync engine.
Can isolates access Flutter widgets or BuildContext?
No. Isolates have their own memory heap with no access to the main isolate's objects. They can't touch
widgets, BuildContext, or any UI code. Send raw data to the isolate, process it, send the result
back. Only the main isolate can update the UI.
How many isolates should I spawn?
Don't exceed CPU cores minus one. On most mobile devices, that's 3-7 isolates. Each isolate uses a few MB minimum. Pool and reuse them instead of spawning per task. For most apps, 1-2 persistent worker isolates is enough.
Do isolates work on Flutter Web?
Dart isolates compile to Web Workers on Flutter Web, but with limitations.
Isolate.spawn()
doesn't work — only compute() works for simple functions. SharedArrayBuffer support varies by
browser. For web, prefer compute() for simple offloading and keep heavy processing server-side.
How do I debug code running in an isolate?
Use the Isolates tab in DevTools. Print statements from isolates appear in the console normally. For
production, wrap isolate entry points in try-catch and send errors back through the SendPort —
otherwise exceptions in isolates are silently swallowed.