Dart Isolates — High Performance Tutorial

In-depth, example-driven guide to using isolates in Dart & Flutter. Learn spawn, compute, worker pools, chunking, streaming, and production considerations.
Level: Advanced
Focus: Performance
Use-case: e-commerce & large datasets
Last updated: Aug 2025

Lesson 1 — What is an Isolate?

Short explanation in plain words, then a minimal example to get started.

Tip: An isolate is like a worker with its own tools (memory). It does heavy work so the main UI thread remains smooth.

Conceptually: Dart runs code in an isolate. The Flutter UI runs on the main isolate. If a heavy CPU task runs on the main isolate (e.g., parsing tens of thousands of JSON objects), the UI will stutter. Move CPU-bound operations to other isolates.

// minimal_isolate.dart
import 'dart:isolate';

void worker(SendPort sendBack) {
  // This worker receives nothing here — in more advanced examples
  // we'll set up a port to receive messages. For demo, just send back a message.
  sendBack.send('worker: hello from isolate');
}

Future main() async {
  final receivePort = ReceivePort();
  await Isolate.spawn(worker, receivePort.sendPort);

  // listen for a single message and then close
  final msg = await receivePort.first;
  print(msg); // worker: hello from isolate

  receivePort.close();
}
Step-by-step
  1. Create a ReceivePort in the main isolate to receive messages.
  2. Call Isolate.spawn(worker, receivePort.sendPort); the worker receives the send port.
  3. Worker can use that port to send messages back to main.
  4. Close your ports when done (receivePort.close()).
Warning: Don't send complex objects with methods across isolates. Send Maps/Lists/primitives or use TransferableTypedData for binary large objects.

Lesson 2 — Message Passing: SendPort & ReceivePort

Understanding how two-way communication works and building a small echo worker that replies to messages.

Tip: Use a fresh reply port for each request if you need distinct responses. This avoids message collision.
// message_passing.dart
import 'dart:isolate';

void echoWorker(SendPort initialReplyTo) {
  // create a port to receive messages from main
  final port = ReceivePort();
  // send the worker's SendPort back to the main isolate
  initialReplyTo.send(port.sendPort);

  port.listen((message) {
    // message is expected: [SendPort replyTo, data]
    if (message is List && message.length == 2) {
      final SendPort replyTo = message[0];
      final data = message[1];
      // perform work (echo)
      replyTo.send('echo: $data');
    }
  });
}

Future main() async {
  final ready = ReceivePort();
  await Isolate.spawn(echoWorker, ready.sendPort);

  final SendPort workerSendPort = await ready.first as SendPort;

  final responsePort = ReceivePort();
  // send [responsePort.sendPort, data]
  workerSendPort.send([responsePort.sendPort, 'hello worker']);

  final resp = await responsePort.first;
  print(resp); // echo: hello worker

  responsePort.close();
  ready.close();
}
Key points

Lesson 3 — compute(): Convenient one-shot background work

Flutter's compute() is a helper for running a single function in a new isolate and getting the result back. Good for one-off heavy jobs.

Good for: JSON parsing of a moderately large payload once, expensive pure functions, CPU-heavy helper tasks.
// compute_example.dart (Flutter)
import 'package:flutter/foundation.dart';

int heavySum(int n) {
  int s = 0;
  for (int i = 0; i < n; i++) s += i;
  return s;
}

// somewhere in your widget code or init
Future runHeavy() async {
  final result = await compute(heavySum, 100000000);
  print('sum = $result');
}
Notes

Lesson 4 — Parsing large JSON in background

A practical pattern: load a big JSON string on main, convert it to a light-weight structure (List<Map>), then process chunks in isolates.

Tip: Parse into Map/List once, then send Maps to workers. Don't send object instances with methods across isolates.
// item_model.dart
class Item {
  final int id;
  final String name;
  final double value;

  Item({required this.id, required this.name, required this.value});

  factory Item.fromJson(Map<String, dynamic> j) => Item(
    id: j['id'] as int,
    name: j['name'] as String,
    value: (j['value'] as num).toDouble(),
  );

  Map<String, dynamic> toJson() => {
    'id': id,
    'name': name,
    'value': value,
  };
}
// parse_worker.dart (worker entrypoint - receives List<Map> chunks)
import 'dart:isolate';
import 'item_model.dart';

void parseWorker(SendPort initialReplyTo) {
  final port = ReceivePort();
  initialReplyTo.send(port.sendPort);

  port.listen((message) {
    // message: [SendPort replyTo, List jsonChunk]
    if (message is List && message.length == 2) {
      final SendPort reply = message[0];
      final List<Map<String, dynamic>> jsonChunk = List<Map<String, dynamic>>.from(message[1]);
      final items = jsonChunk.map(Item.fromJson).toList();

      // Do filtering/processing as needed then reply
      reply.send(items.map((i) => i.toJson()).toList());
    }
  });
}
Pattern
  1. Main: load JSON string → decode → get List<Map>.
  2. Chunk that list (e.g., 1000 items per chunk).
  3. Send each chunk to a worker with a reply SendPort.
  4. Worker converts maps → domain objects, processes, sends result back.

Lesson 5 — Image processing off the main isolate

Examples: thumbnail generation, basic filters, or blur before upload. Heavy pixel ops should run off the main isolate.

Warning: Some image libraries already perform native asynchronous decoding (native side). Check library docs; still, CPU-heavy Dart-level loops should be moved to isolates.
// image_resize_worker.dart (outline)
import 'dart:isolate';
import 'dart:typed_data';
import 'package:image/image.dart' as img;

void imageWorker(SendPort initialReplyTo) {
  final port = ReceivePort();
  initialReplyTo.send(port.sendPort);

  port.listen((message) {
    // message: [SendPort reply, Uint8List bytes, int targetWidth]
    if (message is List && message.length >= 3) {
      final SendPort reply = message[0];
      final Uint8List bytes = message[1];
      final int width = message[2];

      final img.Image? image = img.decodeImage(bytes);
      if (image != null) {
        final resized = img.copyResize(image, width: width);
        final png = img.encodePng(resized);
        reply.send(Uint8List.fromList(png));
      } else {
        reply.send(null);
      }
    }
  });
}
Notes

Lesson 6 — Worker Pool Pattern (Recommended)

For many repeated tasks, use a long-lived pool of isolates rather than spawn/kill each time.

Why: Spawning isolates has overhead. A pool allows reusing isolates to get better throughput and lower latency.
// worker_pool.dart (simplified)
import 'dart:isolate';
import 'worker_entry.dart'; // workerEntryPoint that sends its SendPort back
import 'domain.dart'; // ProcessingRequest, ProcessingChunkResult

class WorkerPool {
  final int size;
  final List<Isolate> _isolates = [];
  final List<SendPort> _sendPorts = [];
  bool _started = false;

  WorkerPool({int? size}) : size = size ?? 4;

  Future<void> start() async {
    if (_started) return;
    for (var i = 0; i < size; i++) {
      final readyPort = ReceivePort();
      final iso = await Isolate.spawn(workerEntryPoint, readyPort.sendPort);
      final sendPort = await readyPort.first as SendPort;
      _isolates.add(iso);
      _sendPorts.add(sendPort);
    }
    _started = true;
  }

  Future<void> dispose() async {
    for (final iso in _isolates) iso.kill(priority:Isolate.immediate);
    _isolates.clear();
    _sendPorts.clear();
    _started = false;
  }

  Future<void> process({
    required List<Map<String,dynamic>> json,
    required ProcessingRequest request,
    required void Function(ProcessingChunkResult) onChunk,
    int chunkSize = 1000,
  }) async {
    if (!_started) await start();
    final receivePort = ReceivePort();
    int idx = 0;
    int nextWorker = 0;
    int inFlight = 0;

    void sendChunk(List<Map<String,dynamic>> chunk) {
      final msg = _WorkMessage(chunk, request, receivePort.sendPort);
      _sendPorts[nextWorker].send(msg);
      nextWorker = (nextWorker + 1) % _sendPorts.length;
      inFlight++;
    }

    // initial wave
    while (idx < json.length && inFlight < size) {
      final end = (idx + chunkSize).clamp(0, json.length);
      sendChunk(json.sublist(idx, end));
      idx = end;
    }

    await for (final res in receivePort) {
      if (res is ProcessingChunkResult) {
        onChunk(res);
        inFlight--;
        if (idx < json.length) {
          final end = (idx + chunkSize).clamp(0, json.length);
          sendChunk(json.sublist(idx, end));
          idx = end;
        } else if (inFlight == 0) {
          receivePort.close();
          break;
        }
      }
    }
  }
}
Tuning

Lesson 7 — Streaming Partial Results to UI

Use a StreamController in your service layer to convert worker callbacks into a stream the UI can subscribe to.

// processor_service.dart (excerpt)
import 'dart:async';
import 'item_local_source.dart';
import 'worker_pool.dart';
import 'domain.dart';

class ProcessorService {
  final ItemLocalSource source;
  final WorkerPool pool;

  ProcessorService(this.source, this.pool);

  Stream<ProcessingChunkResult> processStream(ProcessingRequest request) async* {
    final items = await source.load();
    final jsonList = items.map((e) => e.toJson()).toList();

    final controller = StreamController<ProcessingChunkResult>();
    pool.process(
      json: jsonList,
      request: request,
      onChunk: controller.add,
      chunkSize: 1500,
    );
    yield* controller.stream;
    await controller.close();
  }
}
UI subscription pattern

In the widget, call service.processStream(request) and listen(). Update state per chunk to avoid excessive rebuilds.

Lesson 8 — Best Practices & Pitfalls

Checklist
  • Close ReceivePorts & kill isolates on dispose.
  • Measure and record timings (single-thread vs pool) for your portfolio.
  • Use TransferableTypedData for very large byte transfers.
  • Throttle UI updates if chunks arrive too frequently.
Common mistakes
Warning: Don’t over-parallelize. More isolates than CPU cores can cause context switching overhead and reduce performance.
Portfolio suggestions
  1. Show benchmarks (ms) for single-thread vs pool.
  2. Provide screenshots/GIF of progress bar updating while processing.
  3. Explain design decisions (chunk size choice, number of workers).

References & Further Reading