Isolate Groups and Shared Memory

FlutterDart
Share on LinkedIn

Dart concurrency is isolates, not threads—no shared mutable memory by default, message passing instead of locks. Isolate groups add a nuance: spawned isolates share read-only heap chunks, cutting memory for duplicated constants. Large payload passing still needs TransferableTypedData or you copy megabytes per message. Getting this wrong shows up as jank from main-isolate JSON parsing, not mysterious race conditions—because races barely exist.

Isolate basics

Future<void> main() async {
  final receivePort = ReceivePort();
  await Isolate.spawn(worker, receivePort.sendPort);
  final sendPort = await receivePort.first as SendPort;
  final response = ReceivePort();
  sendPort.send(['compute', 42, response.sendPort]);
  print(await response.first); // worker result
}

void worker(SendPort mainSendPort) {
  final port = ReceivePort();
  mainSendPort.send(port.sendPort);
  port.listen((message) {
    final op = message[0] as String;
    if (op == 'compute') {
      final n = message[1] as int;
      final reply = message[2] as SendPort;
      reply.send(n * n);
    }
  });
}

Messages must be copyable primitives, typed data, or SendPort—objects copy deeply by default.

Isolate.run for one-shot work

final result = await Isolate.run(() {
  return heavyComputation(inputData);
});

Spawns, runs, returns, kills isolate—cleaner than manual spawn for single tasks. Dart 2.19+.

Flutter compute

import 'package:flutter/foundation.dart';

final parsed = await compute(_parseJson, rawBytes);

List<Map<String, dynamic>> _parseJson(Uint8List bytes) {
  return jsonDecode(utf8.decode(bytes)).cast<Map<String, dynamic>>();
}

Top-level or static function required—closures cannot cross isolate boundary.

TransferableTypedData — zero-copy transfer

void sendLargeBuffer(SendPort port, Uint8List data) {
  final transferable = TransferableTypedData.fromList([data]);
  port.send(transferable);
}

void receiveLargeBuffer(SendPort replyPort, TransferableTypedData transferable) {
  final buffer = transferable.materialize().asUint8List();
  // process buffer — sender's copy invalidated after transfer
  replyPort.send('done');
}

Use for image bytes, file chunks, protobuf payloads.

Shared memory within isolate group

Isolates from Isolate.spawn share:

Spawning many workers does not duplicate entire program heap—savings vs OS processes.

FFI and isolates

Native code via dart:ffi can share pointers across isolates with explicit synchronization—you own thread safety. NativeCallable and Isolate.exit patterns for background workers returning results.

Do not pass Pointer objects casually—lifetime bugs crash VM.

Architecture patterns

Main isolate — UI, AnimationController, platform channels

Worker isolates — parsing, compression, ML inference

Long-lived service isolate — database write queue serializing mutations

class DbWriteIsolate {
  late SendPort _commands;
  late Isolate _isolate;

  Future<void> init() async {
    final initPort = ReceivePort();
    _isolate = await Isolate.spawn(_entry, initPort.sendPort);
    _commands = await initPort.first as SendPort;
  }

  Future<void> insert(Record r) async {
    final reply = ReceivePort();
    _commands.send([Insert(r), reply.sendPort]);
    await reply.first;
  }
}

Single writer avoids SQLite concurrent write locks from multiple isolates.

Common mistakes

Parsing 5 MB JSON on main isolate before compute—pass bytes to isolate, decode there.

Sending huge object graphs copying on every message—transfer or process in worker from source file path.

Spawning isolate per frame—pool workers or batch work.

Isolate pools for steady throughput

For apps processing many items (image gallery thumbnails, batch sync), pool workers instead of spawning per task:

class IsolatePool {
  final List<SendPort> _workers = [];
  int _next = 0;

  Future<void> init(int count) async {
    for (var i = 0; i < count; i++) {
      final port = ReceivePort();
      await Isolate.spawn(_workerEntry, port.sendPort);
      _workers.add(await port.first as SendPort);
    }
  }

  Future<T> run<T>(T Function() computation) async {
    final reply = ReceivePort();
    _workers[_next % _workers.length].send([computation, reply.sendPort]);
    _next++;
    return await reply.first as T;
  }
}

Pool size = CPU cores - 1 (leave one for main/UI). Reuse isolates across many tasks — amortizes spawn cost.

Platform channel and isolate interaction

Platform channels must be called from the main isolate — plugins aren't isolate-safe:

// Wrong: platform channel from compute isolate
final result = await compute((_) {
  return methodChannel.invokeMethod('getData');  // CRASH or hang
}, null);

// Correct: platform call on main, heavy processing in isolate
final rawData = await methodChannel.invokeMethod('getData');
final processed = await compute(processData, rawData);

Architecture: main isolate owns all platform channel communication. Worker isolates receive serializable data, return results.

Memory profiling across isolates

Each isolate has its own heap — memory usage scales with isolate count:

Main isolate: UI + state (~50MB)
Worker 1: image processing (~30MB during decode)
Worker 2: JSON parsing (~20MB during parse)
Total: ~100MB peak

Monitor with DevTools memory tab. Kill idle worker isolates — don't keep pool larger than needed. Isolate.kill(immediate: true) for shutdown.

Failure modes

Production checklist

Common production mistakes

Teams get isolate groups shared memory wrong in predictable ways:

Production implementations of isolate groups shared memory fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.

Resources

Frequently asked questions

What is an isolate group in Dart?

Isolates spawned from the same root isolate share an isolate group and can share immutable portions of the heap—canonicalized strings, compile-time constants, and read-only assets loaded before spawn. Mutable objects still cannot be shared; communication uses message passing with copy or transfer semantics.

How do I pass large binary data between isolates efficiently?

Use TransferableTypedData to transfer ownership of byte buffers without copying—the sending isolate loses access after transfer. For JSON or structured data, serialize to Uint8List and transfer, or use Isolate.run (Dart 2.19+) which handles spawn and teardown for one-shot tasks.

Should I use compute() or a persistent isolate?

Use compute or Isolate.run for occasional heavy work—image decode, JSON parse of large payloads. Use persistent isolates with ReceivePort for steady throughput—background sync, audio processing—where spawn overhead dominates. Flutter compute wraps isolate spawn with platform thread pool considerations.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →