CoderBlog
Flutter

Dart Isolates in Production: Killing Jank the Right Way

Every Flutter app runs on one UI isolate. How Dart isolates actually work, where they duplicate your memory, and the three APIs worth using.

Dart Isolates in Production: Killing Jank the Right Way

Every Flutter app has exactly one UI isolate, and it does far more than you think. Gesture hit-testing, layout, every build() method, JSON decoding, plugin method-channel traffic, and the Future completions from your network layer all run on that one thread. Dart isolates are the only escape hatch the language gives you, and most teams reach for them at exactly the wrong moment — usually to move a 2-millisecond JSON parse that never needed to move anywhere. I've been running Dart isolates in production on a 50K-user app for two years, and the honest version of this story is messier than the conference talks make it sound.

This is not a "what are isolates" post. By the end you'll know when spawning one is a 10x win, when it makes your app slower, and which of the three APIs you should actually be reaching for.

Dart Isolates: What Actually Happens When You Spawn One

An isolate is an independent heap with its own event loop. That's the whole definition, and every practical consequence falls out of it.

The own-heap part is enforced by the VM, not by convention. Two isolates cannot share a mutable object. When you send a message across the boundary, the runtime deep-copies the object graph. There is no way to hand another isolate a reference you keep writing to, and that is a genuine safety win — you will never spend a night hunting a race condition that a Dart isolate made structurally impossible.

The cost of that safety is the copy. What crosses the boundary gets cloned, and what comes back gets cloned again. We will get into exactly how expensive that is, because it is the thing that turns a jank fix into an OOM.

On mobile, each isolate is backed by a real OS thread. They compete for cores. Spawning eight of them on a device with four big cores does not create eight cores of throughput, it creates eight threads fighting over four cores plus a lot of scheduler overhead.

One more thing people get wrong: the UI isolate is not the only thread Flutter gives you. There is the platform thread, which talks to Android and iOS and delivers raw input. There is the raster thread, which does the actual GPU submission work. The UI isolate owns build, layout, and the Dart half of your application. When your build() takes 30ms, the frame is late, and the user sees exactly one stutter at 60Hz. That is the whole symptom.

Why a blocked UI isolate is worse than a slow one

Slow work and blocked work feel identical for about 200 milliseconds, and then they diverge in ways that matter.

Slow work drops frames. The queue backs up briefly, the compositor catches up, the scroll settles. The user notices a stutter and moves on. This is the normal cost of doing real work on a phone, and 16.67ms per frame at 60Hz means you can be meaningfully over budget roughly 3 times before it becomes obvious.

Blocked work does something worse. Because the UI isolate is also the thread delivering gesture callbacks, a long synchronous block means touch input stops being acknowledged. Then the system gets involved: on Android, roughly half a second of unresponsiveness is enough for the OS to consider showing an ANR dialog, which is a genuinely terrible experience and the kind of thing that generates one-star reviews with the word "freeze" in them.

Then there is garbage collection. Dart's GC is per-isolate. A large allocation burst on the UI isolate can trigger a major collection that stops the world for that isolate. Offloading work does not delete the garbage, but it moves the collection to a thread where nobody is watching for dropped frames. That is a real win and it is underappreciated.

The budget itself deserves a reality check, because "keep it under 16ms" is advice that quietly fails on real devices. ProMotion iPhones give you 8.33ms. And on a low-end Android — think a 2019 Snapdragon 665 — raster work alone is already eating 7 to 9ms of that frame before your Dart code gets a turn. Your honest budget for Dart on that hardware is 4 to 6ms, not 16. The only way to know your number is the frame chart in the DevTools Performance view or the flutter run overlay, not a stopwatch around a function.

Abstract illustration of a single congested channel being starved while a wide parallel channel flows freely, warm earth tones on dark charcoal

Three APIs and three different jobs

Isolate.run — the one you want 90% of the time

Added in Dart 2.19. Spawns an isolate, runs a closure on it, hands you back a Future, and terminates the isolate when the closure returns. No ports, no boilerplate, no lifecycle to manage.

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

class RunSummary {
  const RunSummary(this.mean, this.dropped);
  final double mean;
  final int dropped;
}

// Parsing 40MB of telemetry on a throwaway isolate.
Future<RunSummary> buildRunSummary(List<int> rawBytes) {
  return Isolate.run(() {
    final buffer = Uint8List.fromList(rawBytes);
    final records = jsonDecode(utf8.decode(buffer)) as List<dynamic>;

    var total = 0.0;
    var dropped = 0;
    for (final r in records) {
      if (r is! Map<String, dynamic>) continue;
      final v = r['durationMs'];
      if (v is num) {
        total += v.toDouble();
      } else {
        dropped++;
      }
    }

    return RunSummary(total / records.length, dropped);
  }, debugName: 'run-summary');
}

One thing that will bite you: the return value has to be sendable. Return a record, a small class instance, a Map of primitives, a TypedData — not a closure, not a ReceivePort, not an open file handle. Isolate.run throws rather than silently doing nothing, which is the correct behaviour and the reason I prefer it over hand-rolled ports for one-shot work.

compute() — still fine, it just adds one check

Flutter ships its own wrapper and it still works fine. The only real difference today is a debug-mode assertion that your message is a primitive or immutable, which Isolate.run does not do. That catches "I passed a big object and wondered why the copy was slow" earlier, which is genuinely useful while you are learning. Beyond that, no advantage. If you want the extra assertion, use it. If not, Isolate.run is the cleaner read.

Isolate.spawn and ReceivePort — when the worker needs to live

Reach for this when the isolate should stay warm across many calls, when you want to stream partial results back, or when the setup cost of your worker is high enough that you want to pay it once.

class AggregationWorker {
  AggregationWorker() {
    _toWorker = _receive.sendPort;
  }

  final ReceivePort _receive = ReceivePort();
  late final SendPort _toWorker;

  Future<Map<String, double>> run(String csvChunk) {
    final done = Completer<Map<String, double>>();
    late final StreamSubscription<dynamic> sub;

    sub = _receive.listen((dynamic message) {
      if (message is Map<String, double>) {
        sub.cancel();
        done.complete(message);
      }
    });

    _toWorker.send(csvChunk);
    return done.future;
  }

  void dispose() {
    _receive.close();
  }
}

And the worker side, which is the part you should look at twice:

Future<void> workerMain(SendPort main) async {
  final ready = ReceivePort();
  main.send(ready.sendPort);

  await for (final chunk in ready) {
    if (chunk == 'stop') break;
    main.send(aggregate(chunk));
  }
}

Future<void> main() async {
  final receive = ReceivePort();
  await Isolate.spawn(
    workerMain,
    receive.sendPort,
    debugName: 'aggregator',
    onExit: receive.sendPort,
    errorsAreFatal: true,
  );
}

Do not measure isolate spawn cost on a hot path. On a Pixel 6 I see roughly 1.2ms to bring an isolate up and tear it down. Spawning one per visible list item inside a scroll listener is a worse bug than the jank you were trying to fix.

The memory copy nobody warns you about

This is the section that saves apps from running out of memory.

Dart deep-copies the object graph across an isolate boundary. Not a shallow reference, not a pointer. A full copy, recursively, of everything reachable from what you sent.

Do the arithmetic on a realistic example. You have a 40MB Uint8List of telemetry. You send it to a worker: now there are 80MB. The worker parses it into 300MB of Dart objects: now there is 380MB. It sends back a 200-byte summary: the UI isolate now has the original 40MB plus a copy of a 200-byte summary, while the worker's 300MB is still live until the isolate exits. If the worker is persistent, that 300MB sits there. Peak memory for a job that "only" needed 40MB: somewhere north of 400MB on a mid-range device.

There is a second trap that people hit constantly. Deep-copying a deeply nested parsed JSON object is frequently slower than the parse you were trying to move off the UI isolate. If you are going to move JSON work to an isolate, send bytes and parse on the other side. Do not send a Map<String, dynamic> that you already built on the UI isolate — you have already paid the cost you were trying to avoid.

Abstract illustration of data duplicated into a second identical glowing chamber, warm earth palette on deep slate

The fix is one idea, and it is the most useful thing in this article: use disposable isolates so memory dies with the work. If the worker holds the big payload and you only need a small summary back, make sure the worker terminates. Isolate.run does this for free.

// The 300MB lives in the worker, computes, sends 48 bytes, then dies.
Future<Map<String, double>> columnStats(String csvChunk) {
  return Isolate.run(() {
    final rows = const CsvToListConverter().convert(csvChunk);
    // ... heavy aggregation over ~400K rows ...
    return <String, double>{'mean': mean, 'p95': p95};
  }, debugName: 'column-stats');
}

Because Isolate.run terminates the worker when the closure returns, the whole heap goes with it. The OS reclaims it immediately. Your persistent worker pool approach needs an explicit policy to get the same effect, and if you do not have one, that pool is a slow leak.

The zero-copy path: transferables and shared buffers

TransferableTypedData moves ownership instead of copying

Future<Uint8List> compress(ByteBuffer input) {
  final handoff = TransferableTypedData.fromList([input.asUint8List()]);

  return Isolate.run(() {
    final bytes = handoff.materialize().asUint8List();
    return Uint8List.fromList(gzipEncode(bytes));  // still copies on return
  }, debugName: 'gzip');
}

The inbound copy is gone. TransferableTypedData.fromList takes ownership of the underlying buffer and hands that ownership over, rather than duplicating it. materialize() gives the receiving isolate a view onto the same memory. It can only be called once — the ownership moved, it is not shared.

The honest limitation: this only saves you the inbound copy. Whatever you return gets copied back to the UI isolate. For symmetric work, wrap the result too and materialize on your side. On the real app, this took a 9-second telemetry import down to 2.1 seconds, and peak RSS dropped from 610MB to 240MB. Both numbers, one change.

TransferableTypedData works on native — Android, iOS, desktop. It is not available on web.

Typed-data views share buffers for free

Int64List, Float64List, Int32List, and friends all have constructors that take another list's buffer. That is real shared memory, no copy in either direction.

// Producer and consumer share one Float64List. No copy either way.
final shared = Float64List(sampleCount);
final producer = TransferableTypedData.fromList(<TypedData>[shared.buffer.asFloat64List()]);

Both isolates must agree on offset and length, and you must not touch the buffer after transferring it away — that is undefined behaviour and it will corrupt data in ways that look like a math bug.

For larger shared regions, SharedArrayBuffer plus ByteData.view works the same way with more ceremony. On native it just works. On web it requires cross-origin isolation, meaning you must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp or the buffer is not available at all. If you already know you need COOP/COEP for dart2wasm, you have this. If not, do not start here.

Spawn discipline and the priority question

Abstract illustration of ordered work lanes with one burning bright and the others dimmed, burnt orange and sand on dark warm grey

A few rules that have saved me from myself more than once.

Never spawn from build(), didChangeDependencies, a LayoutBuilder, or a scroll listener. Those all run on the UI isolate during a frame. Spawn somewhere with a lifecycle, like a screen's initState or an explicit button press.

Bound your concurrency. If a list creates one isolate per item, cap it with a queue. Unbounded spawn is a memory bug wearing a performance costume.

Clean up on purpose. Isolate.kill(priority: Isolate.immediate) when the user navigates away. And in a long-running worker, Isolate.exit() to bail out of a long loop early without unwinding your whole stack — it has been in the SDK since 2.19 and almost nobody knows it exists.

Wire up your error handlers. This is the number one "my background job just silently stopped working" bug. A spawned isolate with errorsAreFatal: true (the default) terminates on the first uncaught error, and if you have no onError or onExit callback, the only symptom is that your results never arrive again. Always pass both.

And here is the thing I wish someone had told me two years ago: there is no Dart API to set isolate priority. The VM derives a spawned worker's priority from the isolate that spawned it, and on Android Flutter maps the UI isolate onto a display-priority OS thread. You cannot escalate above that, and you cannot demote your background work. So the popular advice about running heavy work at lower priority is not achievable from Dart. What you can do is stop competing — chunk the work, serialize it behind a single worker, and kill it early.

The honest recap

Here is the version I would give a friend debugging jank in a Flutter app this week.

  • Measure first. DevTools frame chart, not a stopwatch. Budget 4-6ms of Dart on a low-end device, not 16.
  • Reach for Isolate.run first. Persistent Isolate.spawn workers are for streaming or genuinely repeated work.
  • Send bytes, not parsed objects. Deep-copying a nested structure can cost more than the work you moved.
  • Wrap big payloads in TransferableTypedData. Inbound copy disappears; remember the return trip still copies.
  • Let the worker die. A disposable isolate takes its memory with it, and that is the cheapest fix in this whole article.
  • Wire onError and onExit or you will debug a ghost.
  • Skip isolates for work under a millisecond, for rebuild storms, and for image decode — Flutter already does that one off-thread.

And the four cases where isolates are the wrong tool, which I have personally shipped the mistake of reaching for: rebuild storms caused by missing selectors in your state management, which no isolate can fix; plugin method channels, whose Dart-side marshalling you cannot move; dart2js web builds, where isolates are not parallel at all and Isolate.run only defers; and any device already at its memory ceiling, where a 40MB copy is the last thing you want.

Isolates are a good tool. They are a narrow one. The winning move is usually not "add more concurrency" — it is deciding what should not be on the UI isolate in the first place.

Winson Yau

Engineer, writer, and founder of CoderBlog. Building tools and writing about the craft of software from Hong Kong.

Comments

Discuss the article below. Markdown is supported. Sign in with email or GitHub to leave a comment.