ProFlutterDev

Understanding Concurrency in Dart and Flutter

By @RajnishOne
Published on
5 min read

Dart is famously single-threaded, but that does not mean your application has to execute everything sequentially. Instead of traditional shared-memory threads, Dart handles concurrency using an event loop for asynchronous tasks and isolates for parallel CPU execution.

Understanding how the event loop queues tasks and how to manage isolates is critical to maintaining a smooth 60fps or 120fps UI.


1. The Event Loop and Asynchronous Queues

At its heart, a Dart thread runs a single event loop with two distinct FIFO queues:

  1. Microtask Queue: High-priority internal tasks. The event loop blocks the event queue until the microtask queue is completely empty.
  2. Event Queue: Standard asynchronous events (I/O, timers, user interactions, and futures).

Microtask vs Event Queue Semantics

Consider this execution pipeline. If you queue a microtask, it will run before any pending items in the event queue, even if the event queue item was scheduled first.

void main() {
  print('1. Sync Start');

  // Scheduled in the Event Queue
  Future(() => print('4. Standard Event Queue Task'));

  // Scheduled in the Microtask Queue
  Future.microtask(() => print('3. Microtask Queue Task'));

  print('2. Sync End');
}
// Output order:
// 1. Sync Start -> 2. Sync End -> 3. Microtask Queue Task -> 4. Standard Event Queue Task

[!WARNING] Because microtasks take precedence, running a heavy or recursive microtask will starve the event queue, freezing your UI and preventing gesture events from processing.


2. Offloading CPU Work with Isolates

Because Dart threads do not share memory, they do not suffer from race conditions or lock overhead. However, passing data across isolates requires copying the payload, which has its own cost.

In modern Dart, you have two ways to execute heavy calculations:

Option A: Isolate.run() for Short-Lived Computations

For one-off CPU tasks (like parsing a large JSON file or applying an image filter), use the simplified Isolate.run() API. It handles spawning, messaging, and tearing down the isolate automatically.

import 'dart:convert';
import 'dart:isolate';

Future<Map<String, dynamic>> parseLargeJsonAsync(String rawJson) async {
  // Isolate.run takes a function and executes it on a worker isolate
  return await Isolate.run(() {
    return jsonDecode(rawJson) as Map<String, dynamic>;
  });
}

Option B: Bidirectional Long-Lived Worker Isolates (Isolate.spawn)

If you need a continuous worker (e.g. an active database connection or a background image downloader), spawning a persistent isolate with port-based communication is the standard pattern.

Establishing bidirectional communication requires a “handshake” process:

import 'dart:isolate';

// 1. Define message types for clean communication
sealed class WorkerCommand {}
class StartProcessing extends WorkerCommand {
  final List<int> data;
  StartProcessing(this.data);
}
class StopWorker extends WorkerCommand {}

class WorkerManager {
  late Isolate _isolate;
  late SendPort _toWorkerPort;
  final _fromWorkerPort = ReceivePort();

  Future<void> init() async {
    // Spawn worker, passing the manager's sendPort
    _isolate = await Isolate.spawn(_workerEntryPoint, _fromWorkerPort.sendPort);

    // The first message from the worker is always its SendPort (the handshake)
    _toWorkerPort = await _fromWorkerPort.first as SendPort;
  }

  void processData(List<int> bytes) {
    _toWorkerPort.send(StartProcessing(bytes));
  }

  void dispose() {
    _toWorkerPort.send(StopWorker());
    _fromWorkerPort.close();
    _isolate.kill(priority: Isolate.beforeNextEvent);
  }
}

// 2. The Isolate's entry point MUST be a top-level or static function
void _workerEntryPoint(SendPort managerSendPort) async {
  final workerReceivePort = ReceivePort();
  
  // Handshake: Send the worker's SendPort to the manager
  managerSendPort.send(workerReceivePort.sendPort);

  await for (final message in workerReceivePort) {
    if (message is StartProcessing) {
      final result = _heavyCompute(message.data);
      managerSendPort.send(result);
    } else if (message is StopWorker) {
      workerReceivePort.close();
      break;
    }
  }
}

List<int> _heavyCompute(List<int> raw) {
  // CPU intensive operation
  return raw.map((b) => b ^ 42).toList();
}

3. Advanced Concurrency: Zero-Copy Memory with TransferableTypedData

When passing large arrays (like images, video frames, or audio buffers) between isolates, the default message-passing copies the entire memory block. For files larger than 10MB, copying can create a noticeable frame drop on the main thread.

To avoid copying, Dart provides TransferableTypedData. It allows an isolate to hand over ownership of the underlying native memory block to another isolate directly. Once transferred, the source isolate can no longer read or write to that block, ensuring safe concurrency.

import 'dart:isolate';
import 'dart:typed_data';

void processLargeBytes(Uint8List largeBuffer) async {
  // 1. Wrap the buffer in TransferableTypedData
  final transferable = TransferableTypedData.fromList([largeBuffer]);
  
  final receivePort = ReceivePort();
  await Isolate.spawn(_processingEntryPoint, {
    'port': receivePort.sendPort,
    'data': transferable,
  });

  final result = await receivePort.first as Uint8List;
  print('Processed buffer size: ${result.length}');
}

void _processingEntryPoint(Map<String, dynamic> args) {
  final SendPort sendPort = args['port'];
  final TransferableTypedData transferable = args['data'];

  // 2. Materialize the data inside the worker isolate
  final Uint8List buffer = transferable.materialize().asUint8List();
  
  // Perform tasks on the buffer...
  for (int i = 0; i < buffer.length; i++) {
    buffer[i] = buffer[i] ^ 0xFF;
  }

  sendPort.send(buffer);
}

When to use TransferableTypedData

  • Use it: When processing images, cryptographically signing large database files, or decoding heavy media files.
  • Avoid it: For small primitives or simple JSON payloads, where the overhead of wrapping and materializing data outweighs the copying cost.

Discussion & ReactionsGitHub Powered

Share your thoughts & code snippets