Dart Advanced Features

Isolates & Concurrency

55 min Lesson 5 of 16

Understanding Dart’s Single-Threaded Model

Unlike languages such as Java or C++ that rely on shared-memory threads, Dart is fundamentally single-threaded. Your entire application runs on a single thread called the main isolate. This design eliminates an entire category of bugs -- race conditions, deadlocks, and data corruption from concurrent memory access -- but it also means that CPU-intensive work can block the event loop and freeze your UI.

The Dart event loop processes events one at a time: user taps, network responses, timer callbacks, and Future completions all queue up and execute sequentially. async/await lets you write non-blocking I/O code, but it does not run code in parallel. An await merely yields control back to the event loop while waiting for an external result (like a network response). If you have a function that spends 500ms crunching numbers, async/await will not help -- the main thread is still blocked for 500ms.

The Event Loop in Action

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

  // This Future is scheduled on the event loop, not run in parallel
  Future.delayed(Duration(seconds: 1), () {
    print('3. Timer fired (after 1 second)');
  });

  // This blocks the main thread for ~2 seconds
  // async/await does NOT help here -- it is pure CPU work
  final result = heavyComputation();
  print('2. Heavy computation done: $result');
}

int heavyComputation() {
  int sum = 0;
  for (int i = 0; i < 1000000000; i++) {
    sum += i;
  }
  return sum;
}
Note: The timer callback prints after the heavy computation, even though the timer was set to 1 second. This is because the event loop cannot process the timer event while the main thread is busy with the computation. In a Flutter app, this would mean a frozen UI for the entire duration of the computation.

What Are Isolates?

An isolate is Dart’s unit of concurrency. Each isolate has its own memory heap, its own event loop, and runs independently of all other isolates. The name "isolate" comes from the fact that memory is isolated -- no isolate can directly access another isolate’s variables, objects, or state. Communication happens exclusively through message passing.

Think of isolates like separate workers in different rooms, each with their own desk and supplies. They cannot reach over to each other’s desks, but they can pass notes (messages) through a mail slot (ports). This architecture guarantees memory safety without locks or mutexes.

Tip: Every Dart program starts with one isolate -- the main isolate. When you call main(), you are running inside the main isolate. You can spawn additional isolates to perform work in true parallel, on separate CPU cores.

Isolate.run() -- The Simple API (Dart 2.19+)

Dart 2.19 introduced Isolate.run(), a high-level convenience method that handles all the boilerplate of spawning an isolate, sending data, receiving results, and cleaning up. This is the recommended way to offload CPU-intensive work.

Basic Isolate.run() Usage

import 'dart:isolate';

Future<void> main() async {
  print('Main isolate: starting heavy work on background isolate');

  // Isolate.run takes a top-level or static function
  // and returns a Future with the result
  final result = await Isolate.run(() {
    // This runs in a separate isolate!
    int sum = 0;
    for (int i = 0; i < 1000000000; i++) {
      sum += i;
    }
    return sum;
  });

  print('Main isolate: result = $result');
  // The background isolate is automatically terminated
}

You can also pass data to the isolate function. The function must be a top-level function or a static method -- it cannot be an instance method or a closure that captures mutable state.

Passing Data to Isolate.run()

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

// Top-level function -- required for isolates
List<Map<String, dynamic>> parseJsonList(String jsonString) {
  final List<dynamic> decoded = jsonDecode(jsonString);
  return decoded
      .map((item) => Map<String, dynamic>.from(item as Map))
      .toList();
}

Future<void> main() async {
  // Simulate a large JSON string
  final largeJson = List.generate(
    100000,
    (i) => '{"id": $i, "name": "Item $i", "value": ${i * 1.5}}',
  ).join(',');
  final jsonString = '[$largeJson]';

  print('Parsing ${jsonString.length} characters of JSON...');

  // Parse in a background isolate to keep UI responsive
  final parsed = await Isolate.run(() => parseJsonList(jsonString));

  print('Parsed ${parsed.length} items');
  print('First item: ${parsed.first}');
}
Warning: The data passed to and returned from an isolate must be sendable. Primitive types (int, double, String, bool, null), lists, maps, and typed data are sendable. Objects with native resources (like sockets, file handles, or RawReceivePort) are not sendable. Since Dart 2.15, classes can implement the SendPort message protocol, and since Dart 3.x, you can use TransferableTypedData for efficient large data transfers.

Isolate.spawn() -- The Low-Level API

Isolate.spawn() gives you more control over the isolate lifecycle. You manage ports, communication, and cleanup yourself. This is useful when you need ongoing communication with the isolate (like a worker pool) rather than a single request-response.

Using Isolate.spawn() with Ports

import 'dart:isolate';

// The entry point for the spawned isolate
// Must be a top-level function that takes a single argument
void workerEntryPoint(SendPort mainSendPort) {
  // Create a port to receive messages from main
  final workerReceivePort = ReceivePort();

  // Send our receive port back to main so it can talk to us
  mainSendPort.send(workerReceivePort.sendPort);

  // Listen for messages from main
  workerReceivePort.listen((message) {
    if (message is int) {
      // Perform expensive computation
      int result = 0;
      for (int i = 1; i <= message; i++) {
        result += i * i;
      }
      // Send result back to main
      mainSendPort.send(result);
    } else if (message == 'close') {
      workerReceivePort.close();
    }
  });
}

Future<void> main() async {
  // Create a port to receive messages from the worker
  final mainReceivePort = ReceivePort();

  // Spawn the isolate
  final isolate = await Isolate.spawn(
    workerEntryPoint,
    mainReceivePort.sendPort,
  );

  // Wait for the worker to send us its SendPort
  final workerSendPort = await mainReceivePort.first as SendPort;

  // Now set up a new receive port for ongoing communication
  final responsePort = ReceivePort();

  // We need to send our new port to the worker...
  // Actually, let’s simplify with a complete bidirectional example:
  print('Sending work to isolate...');

  // For ongoing communication, use a stream
  final stream = mainReceivePort.asBroadcastStream();

  // Get the worker’s send port (first message)
  final SendPort sendPort = await stream.first as SendPort;

  // Send a number to compute
  sendPort.send(1000000);

  // Wait for the result
  final result = await stream.first;
  print('Sum of squares up to 1000000: $result');

  // Clean up
  sendPort.send('close');
  mainReceivePort.close();
  isolate.kill(priority: Isolate.immediate);
}

SendPort & ReceivePort Explained

Ports are the communication mechanism between isolates. They work like one-way message channels:

ReceivePort -- A port that listens for incoming messages. It implements Stream, so you can use listen(), first, forEach(), and other stream methods. Each ReceivePort has a corresponding SendPort.

SendPort -- A lightweight reference that can send messages to its corresponding ReceivePort. Unlike ReceivePort, a SendPort can be sent to other isolates (it is sendable). This is how isolates share communication channels.

Port Communication Pattern

import 'dart:isolate';

Future<void> main() async {
  // Step 1: Main creates a ReceivePort
  final mainReceivePort = ReceivePort();

  // Step 2: Main spawns isolate, passing its SendPort
  await Isolate.spawn(
    workerFunction,
    mainReceivePort.sendPort,
  );

  // Step 4: Main receives the worker’s SendPort
  final workerSendPort = await mainReceivePort.first as SendPort;

  // Now main can send messages to the worker
  // And the worker can send messages to main
}

void workerFunction(SendPort mainSendPort) {
  // Step 3: Worker creates its own ReceivePort
  final workerReceivePort = ReceivePort();

  // Worker sends its SendPort to main
  mainSendPort.send(workerReceivePort.sendPort);

  // Worker listens for messages from main
  workerReceivePort.listen((message) {
    print('Worker received: $message');
    // Process and send response
    mainSendPort.send('Processed: $message');
  });
}

The compute() Function (Flutter)

If you are working in Flutter, the compute() function (from package:flutter/foundation.dart) provides an even simpler API than Isolate.run(). It was available before Isolate.run() existed and remains widely used in Flutter apps.

Flutter’s compute() Function

import 'package:flutter/foundation.dart';

// Must be a top-level function
int fibonacci(int n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

// Must be a top-level function with a single argument
Map<String, dynamic> processData(Map<String, dynamic> input) {
  final n = input['n'] as int;
  final result = fibonacci(n);
  return {'input': n, 'result': result};
}

// In a Flutter widget:
Future<void> calculateFibonacci() async {
  // compute() runs processData in a separate isolate
  final result = await compute(
    processData,
    {'n': 40},
  );
  print('Fibonacci(${result['input']}) = ${result['result']}');
}
Note: In Dart 2.19+, Isolate.run() is essentially the same as Flutter’s compute() but available in pure Dart (without the Flutter SDK). If you are writing a Flutter app, both work fine. For pure Dart projects, use Isolate.run().

When to Use Isolates vs async/await

Choosing between async/await and isolates is a critical architectural decision. Here is a clear guide:

Use async/await when:

  • Waiting for network requests (HTTP, WebSocket)
  • Reading/writing files (I/O-bound operations)
  • Waiting for user input or timer events
  • Any operation where the work is done by the OS or an external service

Use isolates when:

  • Parsing large JSON files (> 1MB)
  • Image processing (resize, filter, compress)
  • Complex mathematical computations
  • Data encryption/decryption
  • Search/sort on large datasets (> 10,000 items)
  • Any computation that takes more than ~16ms (one frame at 60 FPS)

Comparison: async/await vs Isolate

import 'dart:isolate';
import 'dart:io';

// CORRECT: Use async/await for I/O
Future<String> fetchData() async {
  // The OS handles the network request; Dart thread is free
  final client = HttpClient();
  final request = await client.getUrl(Uri.parse('https://api.example.com/data'));
  final response = await request.close();
  return await response.transform(utf8.decoder).join();
}

// CORRECT: Use isolate for CPU-heavy parsing
Future<List<Map<String, dynamic>>> fetchAndParse() async {
  // Step 1: Fetch data (I/O -- use async/await)
  final jsonString = await fetchData();

  // Step 2: Parse data (CPU -- use isolate)
  final parsed = await Isolate.run(
    () => parseJsonList(jsonString),
  );

  return parsed;
}

// WRONG: Using isolate for I/O (unnecessary overhead)
// WRONG: Using async/await for heavy computation (blocks event loop)
Tip: A good rule of thumb for Flutter: if the operation takes less than 16 milliseconds (one frame at 60 FPS), keep it on the main isolate. If it takes longer, move it to a background isolate to keep animations and scrolling smooth. Use Stopwatch to profile your code if you are unsure.

Practical Example: Image Processing Pipeline

Here is a realistic example of using isolates for CPU-intensive image processing. This pattern is common in Flutter apps that handle photos.

Image Processing with Isolates

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

// Represents pixel data for an image
class ImageData {
  final int width;
  final int height;
  final Uint8List pixels; // RGBA bytes

  ImageData(this.width, this.height, this.pixels);
}

// Top-level function: apply grayscale filter
Uint8List applyGrayscale(Uint8List pixels) {
  final result = Uint8List(pixels.length);
  for (int i = 0; i < pixels.length; i += 4) {
    final r = pixels[i];
    final g = pixels[i + 1];
    final b = pixels[i + 2];
    final a = pixels[i + 3];

    // Luminance formula (perceptual weighting)
    final gray = (0.299 * r + 0.587 * g + 0.114 * b).round();
    result[i] = gray;     // R
    result[i + 1] = gray; // G
    result[i + 2] = gray; // B
    result[i + 3] = a;    // A (unchanged)
  }
  return result;
}

// Top-level function: apply brightness adjustment
Uint8List adjustBrightness(List<dynamic> args) {
  final pixels = args[0] as Uint8List;
  final factor = args[1] as double; // -1.0 to 1.0

  final result = Uint8List(pixels.length);
  final adjustment = (factor * 255).round();

  for (int i = 0; i < pixels.length; i += 4) {
    result[i] = (pixels[i] + adjustment).clamp(0, 255);
    result[i + 1] = (pixels[i + 1] + adjustment).clamp(0, 255);
    result[i + 2] = (pixels[i + 2] + adjustment).clamp(0, 255);
    result[i + 3] = pixels[i + 3]; // Alpha unchanged
  }
  return result;
}

Future<void> main() async {
  // Simulate a 1920x1080 image (about 8MB of pixel data)
  final width = 1920;
  final height = 1080;
  final pixels = Uint8List(width * height * 4);

  // Fill with sample data
  for (int i = 0; i < pixels.length; i += 4) {
    pixels[i] = 128;     // R
    pixels[i + 1] = 64;  // G
    pixels[i + 2] = 192; // B
    pixels[i + 3] = 255; // A
  }

  print('Processing ${width}x${height} image...');

  // Run grayscale filter on a background isolate
  final grayscaled = await Isolate.run(
    () => applyGrayscale(pixels),
  );

  // Run brightness adjustment on a background isolate
  final brightened = await Isolate.run(
    () => adjustBrightness([grayscaled, 0.2]),
  );

  print('Done! Processed ${brightened.length} bytes');
  print('Sample pixel: R=${brightened[0]} G=${brightened[1]} B=${brightened[2]}');
}

Practical Example: Parallel Computation

You can spawn multiple isolates to run computations in parallel, taking advantage of multiple CPU cores.

Running Multiple Isolates in Parallel

import 'dart:isolate';

// Top-level function: check if a number is prime
bool isPrime(int n) {
  if (n < 2) return false;
  if (n < 4) return true;
  if (n % 2 == 0 || n % 3 == 0) return false;
  for (int i = 5; i * i <= n; i += 6) {
    if (n % i == 0 || n % (i + 2) == 0) return false;
  }
  return true;
}

// Top-level function: count primes in a range
int countPrimesInRange(List<int> range) {
  final start = range[0];
  final end = range[1];
  int count = 0;
  for (int i = start; i <= end; i++) {
    if (isPrime(i)) count++;
  }
  return count;
}

Future<void> main() async {
  const maxNumber = 10000000;
  const numIsolates = 4;
  const rangeSize = maxNumber ~/ numIsolates;

  print('Counting primes up to $maxNumber using $numIsolates isolates...');

  final stopwatch = Stopwatch()..start();

  // Create ranges for each isolate
  final futures = <Future<int>>[];
  for (int i = 0; i < numIsolates; i++) {
    final start = i * rangeSize + 1;
    final end = (i == numIsolates - 1) ? maxNumber : (i + 1) * rangeSize;
    futures.add(Isolate.run(() => countPrimesInRange([start, end])));
  }

  // Wait for all isolates to complete
  final results = await Future.wait(futures);
  final totalPrimes = results.reduce((a, b) => a + b);

  stopwatch.stop();
  print('Found $totalPrimes primes in ${stopwatch.elapsedMilliseconds}ms');
}
Warning: Spawning an isolate has overhead -- each isolate needs its own memory heap and startup time (typically 5-50ms). Do not spawn isolates for trivial computations. Also, avoid spawning too many isolates at once; a good rule is to use at most Platform.numberOfProcessors isolates for CPU-bound work. Spawning 100 isolates on a 4-core device will not make things 100x faster -- it will add overhead from context switching.

Error Handling in Isolates

Errors thrown inside an isolate do not automatically propagate to the main isolate. With Isolate.run(), errors are caught and re-thrown as RemoteError in the calling isolate. With Isolate.spawn(), you need to handle errors explicitly.

Error Handling with Isolate.run()

import 'dart:isolate';

int riskyComputation(int input) {
  if (input < 0) {
    throw ArgumentError('Input must be non-negative: $input');
  }
  return input * input;
}

Future<void> main() async {
  try {
    // This will throw a RemoteError
    final result = await Isolate.run(() => riskyComputation(-5));
    print('Result: $result');
  } on RemoteError catch (e) {
    print('Error from isolate: $e');
  }

  // Successful case
  try {
    final result = await Isolate.run(() => riskyComputation(7));
    print('Result: $result'); // Result: 49
  } on RemoteError catch (e) {
    print('Error from isolate: $e');
  }
}

Summary

Dart’s isolate model provides true parallelism while guaranteeing memory safety. Use Isolate.run() (Dart 2.19+) for simple offload-and-return tasks, Isolate.spawn() with SendPort/ReceivePort for ongoing worker communication, and Flutter’s compute() as a convenient wrapper. Reserve isolates for CPU-intensive work that would block the event loop for more than 16ms, and use async/await for I/O-bound operations.

Tip: When in doubt, profile first. Use Stopwatch to measure your computation’s duration. If it exceeds 16ms and runs during user interaction, move it to an isolate. Premature optimization with isolates adds complexity -- only use them when you have a real performance problem.