Isolates in Dart: True Parallelism for Flutter Apps

Understand how Dart Isolates enable true parallel execution, how to spawn and communicate with them, and when to use Isolates vs compute() vs async/await in your Flutter applications.

Hero image for Isolates in Dart: True Parallelism for Flutter Apps

Dart is single-threaded — but that doesn’t mean it can’t run code in parallel. Dart uses Isolates to achieve true parallelism. Understanding how they work is essential for building Flutter apps that stay smooth and responsive even under heavy computation.

Dart’s Concurrency Model

In Dart, every program runs on a single event loop inside a single isolate. Async/await doesn’t run things in parallel — it just schedules work cooperatively so the UI thread isn’t blocked while waiting for I/O.

// This is NOT parallel — it just yields while waiting
Future<void> fetchData() async {
  final result = await http.get(Uri.parse('https://api.example.com/data'));
  print(result.body); // Runs on the SAME thread
}

For CPU-intensive work like JSON parsing, image manipulation, or cryptography, async/await isn’t enough. Those tasks run on the main thread and block the UI, causing dropped frames and janky animations.

That’s where Isolates come in.

What is an Isolate?

An Isolate is an independent worker with:

  • Its own memory heap — no shared memory between isolates
  • Its own event loop
  • Communication via message passing (using SendPort and ReceivePort)

Because isolates don’t share memory, there’s no risk of race conditions or deadlocks. They’re safe by design.

Main Isolate             Worker Isolate
───────────────          ────────────────
UI thread                Heavy computation
Event loop               Own event loop
Own heap                 Own heap
     │                        │
     │ ←── SendPort/Port ────► │
     │        messages         │

Spawning a Basic Isolate

import 'dart:isolate';

void heavyTask(SendPort sendPort) {
  // This runs in a completely separate isolate
  int result = 0;
  for (int i = 0; i < 100000000; i++) {
    result += i;
  }
  sendPort.send(result); // Send the result back
}

Future<void> main() async {
  final receivePort = ReceivePort();

  // Spawn a new isolate, passing our ReceivePort's SendPort
  await Isolate.spawn(heavyTask, receivePort.sendPort);

  // Wait for the result
  final result = await receivePort.first;
  print('Result: $result'); // Result: 4999999950000000
}

Two-Way Communication

For more complex scenarios, you need bidirectional communication:

import 'dart:isolate';

// The worker function — runs in the spawned isolate
void workerIsolate(SendPort mainSendPort) {
  final workerReceivePort = ReceivePort();

  // Send our own SendPort to the main isolate so it can send us messages
  mainSendPort.send(workerReceivePort.sendPort);

  workerReceivePort.listen((message) {
    if (message is int) {
      // Process the number and send the result back
      final result = _expensiveCompute(message);
      mainSendPort.send(result);
    } else if (message == 'close') {
      workerReceivePort.close();
    }
  });
}

int _expensiveCompute(int n) {
  // Simulate heavy work
  return n * n;
}

Future<void> main() async {
  final mainReceivePort = ReceivePort();
  await Isolate.spawn(workerIsolate, mainReceivePort.sendPort);

  // First message is the worker's SendPort
  final workerSendPort = await mainReceivePort.first as SendPort;

  final results = ReceivePort();

  // Send a task to the worker
  workerSendPort.send(42);
  final result = await results.first;
  print('42 squared = $result'); // 42 squared = 1764

  // Clean up
  workerSendPort.send('close');
  mainReceivePort.close();
}

Isolate.run() — The Simple Way (Dart 2.19+)

For one-off computations, the Isolate.run() API is the simplest approach:

import 'dart:isolate';

String parseHeavyJson(String jsonString) {
  // Simulate heavy JSON parsing
  final parsed = json.decode(jsonString);
  return parsed['result'] as String;
}

Future<void> main() async {
  final jsonString = '{"result": "parsed value", "data": [...]}';

  // Runs on a new isolate, returns the value directly
  final result = await Isolate.run(() => parseHeavyJson(jsonString));
  print(result); // "parsed value"
}

Isolate.run() automatically:

  • Spawns an isolate
  • Sends the closure and arguments
  • Returns the result
  • Kills the isolate when done

Flutter’s compute() Function

Flutter provides a convenience wrapper called compute() that’s designed for use in Flutter apps:

import 'package:flutter/foundation.dart';

// Must be a top-level function (not a closure or method)
List<Photo> parsePhotos(String responseBody) {
  final parsed = jsonDecode(responseBody) as List;
  return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}

Future<List<Photo>> fetchPhotos(http.Client client) async {
  final response = await client.get(Uri.parse('https://jsonplaceholder.typicode.com/photos'));

  // Run parsePhotos on a separate isolate to avoid janking the UI
  return compute(parsePhotos, response.body);
}

compute() is essentially a simplified Isolate.run() — it’s great for Flutter but Isolate.run() is preferred in pure Dart code.

Isolate Pools for Repeated Work

Spawning an isolate has overhead. For repeated heavy tasks, maintain a pool:

import 'dart:isolate';
import 'dart:async';

class IsolatePool {
  final int size;
  final List<_WorkerState> _workers = [];
  int _nextWorker = 0;

  IsolatePool(this.size);

  Future<void> initialize(void Function(SendPort) workerEntry) async {
    for (int i = 0; i < size; i++) {
      final receivePort = ReceivePort();
      final isolate = await Isolate.spawn(workerEntry, receivePort.sendPort);
      final sendPort = await receivePort.first as SendPort;
      _workers.add(_WorkerState(isolate, sendPort, receivePort));
    }
  }

  Future<T> run<T>(dynamic task) {
    // Round-robin load distribution
    final worker = _workers[_nextWorker % size];
    _nextWorker++;

    final completer = Completer<T>();
    final replyPort = ReceivePort();

    worker.sendPort.send([task, replyPort.sendPort]);
    replyPort.first.then((result) {
      replyPort.close();
      completer.complete(result as T);
    });

    return completer.future;
  }

  void dispose() {
    for (final worker in _workers) {
      worker.isolate.kill();
      worker.receivePort.close();
    }
  }
}

class _WorkerState {
  final Isolate isolate;
  final SendPort sendPort;
  final ReceivePort receivePort;
  _WorkerState(this.isolate, this.sendPort, this.receivePort);
}

What Can Be Passed Between Isolates?

Isolates don’t share memory, so only transferable objects can be sent via ports:

✅ Can Send❌ Cannot Send
Primitives (int, double, bool, String)Closures / anonymous functions
List, Map, Set of transferable objectsSocket, File, Stream handles
Uint8List, ByteBufferNative objects with finalizers
Plain Dart classes (deeply copied)BuildContext (Flutter)

Note: Objects are deep-copied when sent between isolates. For very large data, use TransferableTypedData to avoid copying:

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

// Transfer without copying (zero-copy)
final data = Uint8List.fromList([1, 2, 3, 4, 5]);
final transferable = TransferableTypedData.fromList([data]);

// Send the transferable — no copy made
sendPort.send(transferable);

// On the receiving end
receivePort.listen((msg) {
  final received = (msg as TransferableTypedData).materialize().asUint8List();
});

When to Use What

ScenarioSolution
I/O operations (network, file)async/await — no isolate needed
Simple one-off heavy computationIsolate.run() or compute()
Repeated heavy workIsolate pool
Background service / long-runningIsolate.spawn() with message loop
Flutter list parsing from APIcompute()

Practical Example: Image Processing in Flutter

import 'dart:isolate';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';

// Top-level function required for compute()
Uint8List applyGrayscaleFilter(Uint8List imageBytes) {
  // Convert RGBA pixels to grayscale
  final result = Uint8List(imageBytes.length);
  for (int i = 0; i < imageBytes.length; i += 4) {
    final r = imageBytes[i];
    final g = imageBytes[i + 1];
    final b = imageBytes[i + 2];
    final a = imageBytes[i + 3];

    // Luminosity formula
    final gray = (0.299 * r + 0.587 * g + 0.114 * b).round();
    result[i] = gray;
    result[i + 1] = gray;
    result[i + 2] = gray;
    result[i + 3] = a;
  }
  return result;
}

class ImageProcessor {
  Future<Uint8List> processImage(Uint8List bytes) async {
    // This runs on a separate isolate — UI stays smooth
    return await compute(applyGrayscaleFilter, bytes);
  }
}

Common Pitfalls

1. Passing closures to isolates

// ❌ This will throw — closures can't be sent to isolates
final multiplier = 3;
await Isolate.spawn((SendPort port) {
  port.send(value * multiplier); // Error!
}, receivePort.sendPort);

// ✅ Use Isolate.run() which handles closures correctly
final result = await Isolate.run(() => value * multiplier);

2. Forgetting to close ports

// ❌ Memory leak — ReceivePort never closed
final port = ReceivePort();
await Isolate.spawn(worker, port.sendPort);
final result = await port.first;
// Port still open!

// ✅ Use .first which auto-closes, or close manually
port.close();

3. Not handling isolate errors

// ✅ Handle errors from spawned isolate
final errorPort = ReceivePort();
await Isolate.spawn(
  heavyWork,
  receivePort.sendPort,
  onError: errorPort.sendPort,
);

errorPort.listen((error) {
  print('Isolate error: $error');
});

Conclusion

Dart Isolates are the key to true parallelism. For most Flutter use cases, reach for compute() or Isolate.run() when you need to offload heavy work. Use the lower-level Isolate.spawn() when you need persistent workers or bidirectional communication.

The rule of thumb: if a piece of code takes more than a few milliseconds, it probably belongs in an isolate. Your users will thank you for it in the form of a buttery-smooth 60fps UI.


💬 Want to learn, build, and grow with a community of developers? Join the King Technologies Discord — where code meets community!