devShakib

Getting Work Off the Main Thread: Isolates Without the Hand-Waving

Dart isolates for Flutter concurrency done right: async vs isolates, SendPort copy cost, Isolate.run vs compute, TransferableTypedData, worker pools, and FFI memory.

A designer once handed me an 18 MB JSON export we had to parse, normalize, and diff against our local copy before we could render a preview. On a mid-range Android phone that parse froze the UI for about 900ms. That's not a spinner pause. That's a "did the app just die?" pause, the kind where the user taps three more times and now you have a rage-tap in your logs.

The fix everyone reaches for is "throw it on an isolate," and that's usually correct. The problem is that almost every Dart isolate tutorial stops exactly where it gets interesting, right before message-passing costs bite you and you end up slower than when you started. This is the post I wish I'd had: not "isolates are like threads" hand-waving, but a working model of what an isolate is, what crossing between them actually costs, and how to decide whether spawning one helps or hurts. I've shipped this in production at Shpper and gotten it wrong enough times to have opinions worth the read.

The single-thread myth: what actually blocks a Flutter frame

Dart runs your code on a single thread of execution per isolate. That's the part people repeat. The part they skip: "single-threaded" doesn't mean "does one thing." The event loop juggles thousands of async operations happily, because await yields control back to the loop while I/O happens elsewhere (the OS, the network stack, the disk). Async is about waiting efficiently.

What async/await does nothing for is work. A tight loop parsing 18 MB of JSON doesn't await anything. It just runs, synchronously, holding the thread. And the UI thread has a hard deadline: at 60fps you have roughly 16ms to produce a frame, at 120fps roughly 8ms. Anything synchronous that runs longer than that budget between two frames drops a frame. Run 900ms of parsing and you've dropped dozens of frames in a row. That's the jank.

The mental split I use:

If you can't point at a hot synchronous loop, you don't have an isolate problem. You have a "put an await in the right place" problem, and reaching for an isolate will just add complexity and latency. A surprising number of "we need concurrency" tickets are actually a blocking jsonDecode or a synchronous file read that should have been the async variant in the first place.

A quick way to tell them apart

Ask one question: does this code ever wait on something outside the CPU? If it waits on a socket, a disk, or a platform channel, it's I/O-bound and await handles it. If it just churns through data in memory, it's CPU-bound and no amount of async will unblock the frame, because there's nothing to yield on. Future(() => heavyWork()) does not help here either: it defers the work to a later microtask, but that work still runs on the same thread and still blocks the same UI. Deferring is not offloading.

Isolate.run vs compute vs a long-lived spawned isolate

There are three tools here and they're not interchangeable. Choosing the wrong one is the difference between a clean win and a regression.

compute is Flutter's original one-shot helper. You hand it a top-level (or static) function and one argument, it spins up an isolate, runs the function, sends the result back, and tears the isolate down.

final parsed = await compute(parseDesignExport, rawJsonString);

Isolate.run (Dart 2.19 and later) is the modern, framework-agnostic version of the same idea. It's what I default to now. It takes a closure instead of a named function, which is a real ergonomic win because you can capture local variables directly instead of packing them into a single argument object.

final parsed = await Isolate.run(() => parseDesignExport(rawJsonString));

Both of these do the same thing conceptually: spawn, run once, return, die. The isolate startup cost is real but modest, on the order of a couple hundred microseconds to low milliseconds depending on platform. For a 900ms parse, that overhead is noise. For a 2ms task, that overhead is the whole point and you shouldn't be spawning at all.

A long-lived spawned isolate (Isolate.spawn plus your own SendPort/ReceivePort plumbing) is what you want when the same worker handles many messages over time. Spawning once and reusing it amortizes the startup cost and, more importantly, lets the worker keep warm state: a loaded ML model, an open database handle, a parser with a primed cache.

The decision rule I use:

One gotcha that trips people up on all three: the function or closure you hand to an isolate must be able to run in a fresh context. That means no capturing of things tied to the spawning isolate that can't cross the boundary, and any closure captures get copied by the same rules as messages (more on that next). If you see a hard to serialize or Illegal argument in isolate message error, you almost always captured something you shouldn't have, like a BuildContext, a stream controller, or a plugin instance.

The copy tax: what SendPort actually serializes

Here's the cost nobody puts on the label. Isolates don't share memory. When you send an object through a SendPort, Dart doesn't hand the other isolate a reference. It copies the entire object graph, deep, into the receiving isolate's heap. Send a 40 MB parsed tree back and you pay for allocating and copying 40 MB, plus the garbage you just created on both sides.

I learned this the annoying way. I moved my 18 MB parse onto an isolate, the parse itself flew, and then I still had jank. The parse produced a large nested Map/List structure, and copying that back to the main isolate was itself a multi-hundred-millisecond synchronous operation on the receiving side. I'd moved the compute off the main thread but left the copy on it. The profiler showed a fat synchronous block right after the "await" returned, which is the deserialization landing on the UI thread.

Two things fixed it:

import 'dart:isolate';import 'dart:typed_data';// On the sending side:final bytes = Uint8List(20 * 1024 * 1024); // 20 MBfinal transferable = TransferableTypedData.fromList([bytes]);sendPort.send(transferable); // ownership moves; near-zero copy// On the receiving side:void handle(TransferableTypedData t) {  final ByteBuffer buffer = t.materialize();  final data = buffer.asUint8List();  // ...use data}

TransferableTypedData only works for typed data (byte buffers), not arbitrary Dart objects. But a huge amount of heavy work — images, audio, compressed payloads, protobufs — is byte buffers at the boundary anyway. Design your isolate interface so what crosses the wire is bytes or a small summary, never a giant object graph. The rule I keep coming back to: cross the isolate boundary with as little as possible. The message-passing model is the whole cost structure of Dart concurrency, and every megabyte you send is a megabyte you allocate, copy, and later collect.

A worker-pool pattern for streaming and back-pressure

Once you go long-lived, you need real plumbing. A single spawned isolate that you talk to over SendPort/ReceivePort looks like this:

class Worker {  final SendPort _commands;  final ReceivePort _responses;  final _pending = <int, Completer<Object?>>{};  int _nextId = 0;  Worker._(this._commands, this._responses) {    _responses.listen(_onResponse);  }  static Future<Worker> spawn() async {    final init = ReceivePort();    await Isolate.spawn(_entry, init.sendPort);    final commands = await init.first as SendPort;    final responses = ReceivePort();    commands.send(responses.sendPort);    return Worker._(commands, responses);  }  Future<Object?> run(Object? job) {    final id = _nextId++;    final c = Completer<Object?>();    _pending[id] = c;    _commands.send((id, job));    return c.future;  }  void _onResponse(dynamic msg) {    final (int id, Object? result) = msg as (int, Object?);    _pending.remove(id)!.complete(result);  }  static void _entry(SendPort initPort) {    final commandPort = ReceivePort();    initPort.send(commandPort.sendPort);    late SendPort responses;    commandPort.listen((msg) {      if (msg is SendPort) {        responses = msg;        return;      }      final (int id, Object? job) = msg as (int, Object?);      final result = doWork(job); // your CPU-bound function      responses.send((id, result));    });  }}

The id correlation is the important detail. Because responses come back over a single port, you tag each job so you can match results to the right Completer. Without it you can't have more than one in-flight request, since you'd have no way to know which response belongs to which caller.

Now the two things people forget:

Back-pressure. A single worker processes jobs serially in its own event loop. If your producer fires jobs faster than the worker drains them, the pending queue grows without bound and you eat memory until you OOM. Cap it. I keep a semaphore on the producer side: don't dispatch job N+K until job N has come back. For genuinely parallel throughput, spawn a pool of N workers (N roughly the number of physical cores) and round-robin or least-loaded dispatch across them. More than that and you're just context-switching for no gain, since the OS still only has so many cores. A pool of 3 to 4 workers is my usual starting point on mobile; measure before going wider.

Streaming. If a job produces incremental output (say, decoding frames or lines of a CSV), have the worker send multiple (id, chunk) messages and a final (id, done) sentinel, and expose it on the main side as a Stream instead of a Future. But remember every chunk is a copy, so chunk coarsely. Sending 10,000 tiny messages will cost you more in message overhead than the work you saved offloading. Batch chunks into reasonably sized payloads and the streaming stays cheap.

Sharing memory the legal way: TransferableTypedData and FFI

"Isolates don't share memory" is true for Dart objects. It is not the whole truth. There are legitimate ways to share a region of memory, and for the heaviest workloads they're the only thing that performs.

import 'dart:ffi';import 'package:ffi/ffi.dart';// Allocate a shared buffer in native memory:final Pointer<Uint8> buf = malloc.allocate<Uint8>(1024 * 1024);// Send just the address across:sendPort.send(buf.address);// In the other isolate, reconstruct the pointer:final Pointer<Uint8> shared = Pointer.fromAddress(addressInt);final view = shared.asTypedList(1024 * 1024); // reads/writes the same bytes

The catch is that you've now opted out of Dart's memory safety. No GC over that region, manual malloc/free, and if two isolates write the same bytes concurrently you have a genuine data race with no lock to save you. This is C-level power with C-level footguns. I reach for it only when profiling proves the copy is the bottleneck and the buffer is large and long-lived: image pipelines, audio processing, an mmap'd file. For everything else, copy semantics are a feature, not a limitation. They make isolates deadlock-free and race-free by construction, which is most of why they're pleasant to use compared to threads-and-locks concurrency.

Isolates on Flutter web: where the story changes

If you ship Flutter web, unlearn some of the above. On the web, Dart isolates map onto Web Workers, and Web Workers are genuinely separate contexts with hard limits:

The practical consequence: don't design a single concurrency layer and assume it behaves identically across native and web. I gate the heavy path behind a capability check and fall back to chunked, yielding work on web — process a batch, await Future.delayed(Duration.zero) to let a frame render, then process the next batch:

Future<void> processInChunks(List<Item> items) async {  const chunk = 200;  for (var i = 0; i < items.length; i += chunk) {    for (var j = i; j < i + chunk && j < items.length; j++) {      process(items[j]);    }    await Future.delayed(Duration.zero); // yield so a frame can render  }}

It's cooperative multitasking instead of real parallelism, but it keeps the UI breathing, which is the actual goal.

Cancellation, timeouts, and cleaning up a dead worker

Isolates have no built-in cancel(). You can't reach into a running isolate and stop a synchronous loop mid-flight, because it never yields to check for a cancel signal. Three tactics I use, in order of bluntness:

void cancel(Worker w) {  w._isolate.kill(priority: Isolate.immediate);  w._responses.close();  for (final c in w._pending.values) {    c.completeError(StateError('worker cancelled'));  }  w._pending.clear();}

The recurring lesson: an isolate is a resource, like a socket or a file handle. If you spawn it, you own closing it. I once shipped a bug where a screen spawned a worker per navigation and never killed the old ones, and after twenty navigations the app was carrying twenty warm isolates and their heaps. It didn't crash. It just got mysteriously slower and hungrier over time, which is worse, because nobody files a bug for "slightly worse the longer you use it."

Measuring the break-even point before you reach for an isolate

Isolates are not free, so treat "should this be on an isolate?" as a measurement, not a vibe. My checklist:

A concrete before/after from a real screen: an image-tiling feature was dropping frames on scroll. The naive fix, one Isolate.run per tile, was actually slower: startup plus a full-bitmap copy each way dwarfed the tiling work itself. The fix that stuck was a persistent worker pool of 3 isolates, tiles handed over as TransferableTypedData, results streamed back as bytes. Frame time on the main thread dropped dramatically, and the workers stayed warm across scrolls so we paid startup exactly once instead of per tile.

Key takeaways