Isolates and concurrency in Flutter

By Tochi Nwachukwu · 1 January 2026979 views
Isolates and concurrency in Flutter

Dart isolates are the mechanism for true parallelism. By default, all Dart code runs on a single thread — the UI thread. Isolates run Dart code in separate threads with separate memory, enabling CPU-bound work without blocking the UI.

When to use isolates

Isolates add complexity and communication overhead. Only use them when:

  • You have CPU-bound work that takes > 16ms (causes frame drops)
  • Parsing large JSON responses (1MB+)
  • Image processing or compression
  • Cryptography
  • Complex calculations (pathfinding, simulations)

For I/O-bound work (network requests, file reads), async/await is sufficient — the event loop handles concurrency without isolates.

compute() — the easy way

compute() runs a top-level function in a new isolate and returns the result:

// Must be a top-level function (not a method or closure)
List<Order> _parseOrders(String jsonString) {
  final list = jsonDecode(jsonString) as List;
  return list.map((e) => Order.fromJson(e as Map<String, dynamic>)).toList();
}

// In your repository:
Future<List<Order>> getOrders() async {
  final response = await dio.get('/orders');
  // Parse JSON off the main thread
  return compute(_parseOrders, response.data as String);
}

Limitations:

  • The function must be top-level or static
  • Arguments and return values must be sendable (primitives, lists, maps, typed data)
  • Each compute() spawns a new isolate — expensive for many small tasks

Isolate.run() — Dart 2.19+

A cleaner alternative that accepts closures:

Future<ProcessedImage> processImage(Uint8List bytes) async {
  return Isolate.run(() {
    // This closure runs in a new isolate
    return expensiveImageProcessing(bytes);
  });
}

Long-lived isolates

For repeated work, spawning a new isolate each time is expensive. Use a persistent isolate with ReceivePort/SendPort:

class DatabaseIsolate {
  late SendPort _sendPort;
  final _completerMap = <int, Completer>{};
  int _nextId = 0;

  Future<void> init() async {
    final receivePort = ReceivePort();
    await Isolate.spawn(_isolateEntry, receivePort.sendPort);

    // Get the isolate's send port
    _sendPort = await receivePort.first as SendPort;

    // Listen for responses
    receivePort.listen((message) {
      final response = message as Map;
      final id = response['id'] as int;
      final result = response['result'];
      _completerMap.remove(id)?.complete(result);
    });
  }

  Future<T> send<T>(String operation, dynamic data) async {
    final id = _nextId++;
    final completer = Completer<T>();
    _completerMap[id] = completer;
    _sendPort.send({'id': id, 'operation': operation, 'data': data});
    return completer.future;
  }
}

void _isolateEntry(SendPort mainPort) {
  final receivePort = ReceivePort();
  mainPort.send(receivePort.sendPort);

  receivePort.listen((message) {
    final msg = message as Map;
    final id = msg['id'] as int;
    final operation = msg['operation'] as String;
    final data = msg['data'];

    Object? result;
    if (operation == 'parse') {
      result = _parseData(data as String);
    }

    mainPort.send({'id': id, 'result': result});
  });
}

Sendable types

Not all objects can be sent between isolates. Sendable types:

  • Primitives: int, double, bool, String, null
  • Collections of sendable types: List, Map, Set
  • Uint8List and other typed data
  • SendPort

Not sendable:

  • Closures
  • Custom class instances (unless they only contain sendable fields)
  • Futures
  • Streams

For complex objects, serialize to JSON first:

// Main isolate: serialize
final json = jsonEncode(order.toJson());
final result = await compute(_processOrder, json);
final processedOrder = Order.fromJson(jsonDecode(result) as Map<String, dynamic>);

// Worker:
String _processOrder(String orderJson) {
  final order = Order.fromJson(jsonDecode(orderJson) as Map<String, dynamic>);
  // process...
  return jsonEncode(order.toJson());
}

Drift and isolates

Drift has first-class isolate support:

// Opens the database in a background isolate
final database = AppDatabase(
  NativeDatabase.createInBackground(File(path)),
);

// All queries automatically run on the background isolate
final orders = await database.getAllOrders();

Common pitfalls

Using closures with compute(). compute() requires top-level or static functions. A closure that captures variables from the enclosing scope won't work and throws at runtime.

Spawning isolates for small tasks. Isolate creation takes ~1-2ms. If your task takes 1ms, you've doubled its time. Use isolates for tasks > 10-20ms.

Sending large objects repeatedly. Sending a 10MB list between isolates copies all the data. For very large data, use TransferableTypedData which transfers ownership without copying.

Comments

No comments yet. Be the first!

Sign in to leave a comment.