← Articles

Isolates and concurrency in Flutter

By John · 1 January 2026

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.

Sign in to like, dislike, or report.

Isolates and concurrency in Flutter — ANN Tech