HomeDocumentationThe async nature of Dart
The async nature of Dart
14

Isolates: Real Parallelism

Dart Isolates Explained — Isolate.run, Message Passing, and When to Use Them

April 2, 2026

Your app fetches a 2MB JSON payload from the server. You parse it:

dart
Future<void> loadCatalog() async {
  final response = await http.get(Uri.parse('$baseUrl/catalog'));
  final catalog = jsonDecode(response.body) as List; // ← This line
  final products = catalog.map((j) => Product.fromJson(j)).toList();
  setState(() => _products = products);
}

The network call is async — the event loop keeps running while the HTTP request is in flight. No jank. But jsonDecode on a 2MB string takes 60-80ms on a mid-range phone. That's synchronous CPU work, running on the main isolate's thread. For those 60-80ms, the event loop is frozen. No VSync events processed. No frame painted. No tap handled. Four or five dropped frames at 60fps. The user sees a visible stutter in whatever animation was running.

You marked the function async. You used await. You did everything "right." The UI still janked.

This is the boundary of concurrency. async/await gives you concurrency — multiple operations in flight, interleaved on one thread. But concurrency is not parallelism. When the work is CPU-bound — parsing JSON, compressing images, running ML inference, encrypting data — the single thread has to do the work, and everything else waits.

Isolates give you actual parallelism: a second thread, running your Dart code, at the same time as the main thread.

What an Isolate actually is

An Isolate is Dart's unit of concurrent execution. Each Isolate has:

  1. Its own OS thread. A real thread, scheduled by the kernel alongside your main thread. On a multi-core device, the main isolate and a spawned isolate can execute simultaneously on different CPU cores.
  1. Its own heap. A separate memory space with its own garbage collector. Objects in one Isolate's heap are not accessible from another Isolate. No shared mutable state. No locks. No data races.
  1. Its own event loop. The spawned Isolate runs its own event loop with its own microtask and event queues (Post 0). Timers, Futures, Streams — all work independently.

The "isolated" in Isolate is literal. Two Isolates share nothing. They can't read each other's variables. They can't call each other's functions. The only way they communicate is by sending messages through ports — serialized copies of data that cross the boundary between heaps.

This is a fundamentally different model from Java threads (shared heap, synchronized access), Go goroutines (shared memory with channels), or C++ threads (shared everything, mutex-guarded). Dart chose isolation to eliminate an entire category of bugs — data races, deadlocks, torn reads — at the cost of requiring explicit message passing for communication.

`Isolate.run` — the simple API

For the common case — "run this function on a background thread and give me the result" — Isolate.run is all you need:

dart
Future<void> loadCatalog() async {
  final response = await http.get(Uri.parse('$baseUrl/catalog'));

  // Parse on a separate isolate
  final products = await Isolate.run(() {
    final catalog = jsonDecode(response.body) as List;
    return catalog.map((j) => Product.fromJson(j)).toList();
  });

  setState(() => _products = products);
}

Isolate.run does four things:

  1. Spawns a new Isolate (new thread, new heap)
  2. Sends the closure to the new Isolate (serialized across the heap boundary)
  3. Runs the closure on the new thread
  4. Sends the result back to the calling Isolate and shuts down the spawned Isolate

The JSON parsing now happens on a different thread. The main isolate's event loop is free. Animations keep running. Taps keep being processed. The 60-80ms parse happens in the background, and the result arrives as a Future that completes when the work is done.

The fix was one line: wrap the CPU-bound work in Isolate.run.

What can cross the boundary

When you pass data to an Isolate or receive data back, it has to cross the heap boundary. Dart handles this through message passing — the data is serialized from one heap and deserialized into the other.

What can be sent:

  • Primitive types: int, double, bool, String, null
  • Lists and Maps containing sendable types
  • SendPort and ReceivePort (for ongoing communication)
  • TransferableTypedData (for large byte buffers — transferred, not copied)
  • Objects composed of the above (Dart handles deep serialization for common structures)
  • Capability objects

What cannot be sent:

  • Closures that capture non-sendable state (the closure you pass to Isolate.run must be a top-level function or a static method, or a closure that only captures sendable values)
  • Objects with native resources (file handles, sockets, database connections — these are OS-level resources tied to specific file descriptors in the process, but each Isolate manages its own Dart-side representations)
  • ReceivePort (you can send a SendPort, but not the receiving end)

The serialization has a cost. Sending a 2MB string across the Isolate boundary means copying 2MB. For the JSON example, you're copying the response body to the new Isolate, parsing it there, and copying the result back. If the result is a list of 10,000 Product objects, all of those objects are serialized and deserialized.

For large data transfers, TransferableTypedData avoids the copy — it transfers the underlying memory from one Isolate to the other. After the transfer, the source Isolate can no longer access the data. This is zero-copy for typed data (byte buffers, Uint8List).

dart
// In the spawning isolate:
final bytes = response.bodyBytes; // Uint8List, potentially large
final transferable = TransferableTypedData.fromList([bytes]);

// Send to the worker isolate — zero copy, ownership moves
sendPort.send(transferable);

`compute` — Flutter's wrapper

Flutter provides compute() as a convenience wrapper around Isolate.run:

dart
final products = await compute(parseCatalog, response.body);

// The function must be top-level or static
List<Product> parseCatalog(String body) {
  final catalog = jsonDecode(body) as List;
  return catalog.map((j) => Product.fromJson(j)).toList();
}

compute takes a top-level function and a single argument, runs the function on a spawned Isolate, and returns the result. It predates Isolate.run (which was added in Dart 2.19) and has a more restrictive API — the function must be top-level or static, and you can only pass one argument (use a record or a class to bundle multiple values).

`Isolate.run` is preferred in new code. It accepts closures (not just top-level functions), supports capturing local variables (as long as they're sendable), and has clearer semantics. compute still works but is essentially a legacy API.

SendPort and ReceivePort — ongoing communication

Isolate.run is fire-and-forget: spawn, compute, return, die. For long-lived workers that process multiple requests, you need direct port communication.

dart
class ImageProcessor {
  late final Isolate _isolate;
  late final SendPort _sendPort;
  final _receivePort = ReceivePort();

  Future<void> start() async {
    _isolate = await Isolate.spawn(
      _workerEntryPoint,
      _receivePort.sendPort,
    );

    // The worker sends back its SendPort as the first message
    _sendPort = await _receivePort.first as SendPort;
  }

  Future<Uint8List> compress(Uint8List imageBytes) async {
    final responsePort = ReceivePort();
    _sendPort.send(_CompressRequest(imageBytes, responsePort.sendPort));
    final result = await responsePort.first as Uint8List;
    responsePort.close();
    return result;
  }

  void dispose() {
    _isolate.kill(priority: Isolate.immediate);
    _receivePort.close();
  }

  static void _workerEntryPoint(SendPort mainSendPort) {
    final workerReceivePort = ReceivePort();
    mainSendPort.send(workerReceivePort.sendPort);

    workerReceivePort.listen((message) {
      if (message is _CompressRequest) {
        final compressed = _compressImage(message.bytes);
        message.replyPort.send(compressed);
      }
    });
  }

  static Uint8List _compressImage(Uint8List bytes) {
    // CPU-intensive compression work
    // ...
    return bytes; // placeholder
  }
}

class _CompressRequest {
  final Uint8List bytes;
  final SendPort replyPort;
  _CompressRequest(this.bytes, this.replyPort);
}

The pattern:

  1. Main isolate creates a ReceivePort and passes its SendPort to the new Isolate.
  2. Worker isolate creates its own ReceivePort, sends its SendPort back to the main isolate.
  3. Now both sides have a SendPort to the other. They can exchange messages indefinitely.
  4. Each request includes a SendPort for the reply, so the main isolate can match responses to requests.

This is more complex than Isolate.run, but it avoids the startup cost of spawning a new Isolate for each operation. Isolate creation takes 5-50ms depending on the device and the amount of code that needs to be loaded. For frequent operations (processing camera frames, handling real-time data), a persistent worker Isolate avoids that overhead.

Isolate groups and memory sharing

Since Dart 2.15, Isolates spawned with Isolate.spawn share the same Isolate group as the parent. This means they share the same compiled code (the AOT-compiled instructions from libapp.so) and the same type metadata. They do not share heap objects — each Isolate still has its own heap.

The practical impact: spawning an Isolate within the same group is much faster (~100-500 microseconds) than creating a completely new Isolate from a separate code bundle. The code is already loaded and compiled; the new Isolate just needs a fresh heap and thread.

This also affects message passing. Within the same Isolate group, Dart can optimize the serialization: for deeply immutable objects (strings, unmodifiable lists of primitives), the runtime can share the underlying memory rather than copying it. This is transparent — you don't need to change your code.

Isolates and native code

If you're using FFI (Dart FFI series), there's an important interaction between Isolates and native libraries.

Each Isolate has its own Dart heap, but all Isolates in a process share the same native address space. A DynamicLibrary.open('libfoo.so') in one Isolate opens the same shared library that's mapped into the process. Global state in the native library is shared across all Isolates — because the native code doesn't know about Dart's Isolate boundaries.

dart
// This runs in a spawned Isolate
static void _workerEntryPoint(SendPort port) {
  // DynamicLibrary.open loads the library into the process address space.
  // If it's already loaded (by the main isolate), this just returns a handle.
  final lib = DynamicLibrary.open('libimage_processor.so');
  final compress = lib.lookupFunction<NativeCompress, DartCompress>('compress');
  // ...
}

The static final pattern for caching DynamicLibrary instances needs care: static final fields are per-Isolate (each Isolate has its own copy of Dart static state). So DynamicLibrary.open runs independently in each Isolate, but since the OS deduplicates shared library mappings, the actual native code in memory is shared.

The danger: if the native library uses global mutable state (a global counter, a shared cache, a static buffer), multiple Isolates calling into it simultaneously will race on that state — just like multiple threads in C. Dart's Isolate model protects Dart-side state. It doesn't protect native-side state. If you're calling into native code from multiple Isolates, the native code needs its own thread safety (mutexes, atomic operations).

When to use Isolates

Use an Isolate when:

  • JSON parsing of large payloads (>100KB). jsonDecode is pure CPU work.
  • Image processing — compression, resizing, applying filters. Even with native libraries, the Dart-side orchestration and byte shuffling can be significant.
  • Encryption/hashing — computing a SHA-256 hash of a large file, encrypting data before upload.
  • Data transformation — converting between formats, sorting large datasets, running search/filter on thousands of items.
  • Database serialization — mapping hundreds of database rows to Dart objects (common with SQLite in offline-first apps).
  • Any computation over ~16ms — if it takes longer than one frame budget, it will cause jank.

Don't use an Isolate when:

  • I/O operations — network requests, file reads, database queries. These are already async; the OS handles the waiting. The Dart thread does minimal work (scheduling the I/O and processing the callback). `Isolate.run(() =
  • http.get(...))` doesn't help — the network stack is the same regardless of which thread schedules the call.
  • Very short computations — if the work takes <5ms, the Isolate spawn overhead (~0.5ms + message passing) isn't worth it.
  • Work that needs UI access — Isolates can't call setState, access BuildContext, or interact with the widget tree. Only the main Isolate runs the Flutter framework. Compute in the Isolate, then send the result back and update the UI on the main thread.
  • Work that needs platform channels — platform channel calls (MethodChannel, EventChannel) only work on the main Isolate's platform thread. A spawned Isolate can't call platform plugins directly. (There's an experimental BackgroundIsolateBinaryMessenger API, but it's not widely supported by plugins yet.)

The cost of Isolates

Isolates aren't free. The costs:

Spawn time. Even within the same Isolate group, creating a new Isolate takes ~0.5-5ms on modern devices. For Isolate.run, this happens every call. For persistent workers, it's a one-time cost.

Message passing overhead. Serializing and deserializing data between heaps takes time proportional to the data size. Sending a 2MB string costs ~1-3ms each way. Sending 10,000 objects costs more than sending 100.

Memory. Each Isolate has its own heap, its own GC, its own stack. A freshly spawned Isolate adds ~2-5MB of memory overhead, even before your code allocates anything.

Complexity. Two Isolates means two execution contexts, message passing protocols, error handling across boundaries, and lifecycle management. The code is harder to read and debug than single-threaded code.

The rule of thumb: Isolate.run for CPU-bound work over 16ms. Persistent worker for repeated CPU-bound operations. Everything else stays on the main Isolate.

Practical pattern: the worker pool

For apps that frequently process heavy data (chat apps decoding messages, e-commerce apps parsing catalogs, media apps processing images), a single persistent worker Isolate handles most cases. For truly parallel processing, you can spawn multiple workers:

dart
class WorkerPool {
  final List<SendPort> _workers = [];
  int _nextWorker = 0;

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

  SendPort get nextWorker {
    final worker = _workers[_nextWorker];
    _nextWorker = (_nextWorker + 1) % _workers.length;
    return worker;
  }

  static void _worker(SendPort mainPort) {
    final port = ReceivePort();
    mainPort.send(port.sendPort);

    port.listen((message) {
      // Process work, send results back
    });
  }
}

Two or three workers is usually sufficient for a mobile app. More workers means more memory and more CPU contention — mobile devices have 4-8 cores, and the OS, the Flutter engine, and the GPU driver are also competing for them.

The mental model

Dart's concurrency story has two layers:

  1. Concurrency (single thread): async/await, Futures, Streams, the event loop. Multiple operations in flight, interleaved on one thread. Good for I/O-bound work (network, files, timers). Covered in Post 0, Post 1, and Post 2.
  1. Parallelism (multiple threads): Isolates. Real simultaneous execution on separate CPU cores. Good for CPU-bound work (parsing, compression, encryption). Each Isolate is fully isolated — separate heap, separate GC, communication only through message passing.

Most Flutter code lives in layer 1. You reach for layer 2 when computation is heavy enough to drop frames. The threshold is roughly 16ms — one frame budget at 60fps. Under that, the event loop handles it. Over that, move it to an Isolate.

The final post in this series looks at the bugs — the async patterns that look right but break in subtle ways: fire-and-forget Futures, setState-after-dispose, race conditions between sequential awaits, and infinite stream listeners.

This is Post 3 of the Async Dart series. Previous: Streams: The Async Iterator. Next: Common Async Bugs.*

Related Topics

dart isolate explainedflutter isolateisolate.run dartflutter compute functiondart sendport receiveportdart message passingflutter background processingdart parallel execution

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.