NK

Search

Search pages, posts, and components

All posts
Mobile10 min read

Flutter Isolates Explained Through a Real Example

A 12 MB JSON import that jank the UI for two seconds - and what fixing it teaches about isolates, message passing, and when they are the wrong tool.

flutterdartisolatesperformanceconcurrency

Async in Dart does not mean parallel. That single sentence explains most of the confusion around isolates, and the fastest way to internalise it is to watch await fail to fix a frozen UI.

Here is a real case. An app imports a data file the user picks - around 12 MB of JSON, decoded and mapped into roughly forty thousand domain objects, then written to a local database. It worked correctly and froze the app for just over two seconds: no scroll, no spinner animation, no button feedback. On a mid-range Android phone it was closer to four.

The code was already async. That was the problem.

Why await did not help

The import looked reasonable:

Future<void> import(File file) async {
  final text = await file.readAsString();          // really async I/O
  final json = jsonDecode(text) as List;           // blocks
  final items = json.map(Item.fromJson).toList();  // blocks
  await _db.insertAll(items);                      // async, but batched work
}

Async is about waiting, not about parallelism

Dart runs your code on a single thread per isolate, with an event loop. await means "suspend this function and let the event loop run something else until this completes". It is excellent for waiting on I/O.

It does nothing for work that is not waiting. jsonDecode on 12 MB is pure computation - the CPU is busy for the whole duration, the event loop never gets control, and every frame in that window is missed. The UI thread cannot paint because it is decoding JSON.

Marking the function async changed where it sat in the queue and not how long it occupied the thread.

The event loop makes this visible

Flutter renders on the same isolate your app logic runs on. A frame must be produced roughly every 16 ms at 60 Hz. Any synchronous block longer than that is a dropped frame; a two-second block is a hundred and twenty of them.

Isolates exist because the only way to keep the UI isolate free is to run the work somewhere else entirely.

Moving the work off the UI isolate

Dart's concurrency model is isolates with no shared memory. Each has its own heap; they communicate by copying messages over ports. That is what makes them safe - you cannot race on state you cannot reach - and what makes them awkward, because data has to be moved.

compute for a one-shot job

For a single function call on a large input, compute is the whole API you need. It spawns an isolate, runs the function, returns the result, and shuts it down.

List<Item> _parseItems(String raw) {
  final json = jsonDecode(raw) as List;
  return json.map((e) => Item.fromJson(e as Map<String, dynamic>)).toList();
}
 
Future<void> import(File file) async {
  final text = await file.readAsString();
  final items = await compute(_parseItems, text);  // now off the UI isolate
  await _db.insertAll(items);
}

Two constraints catch people immediately.

The function must be a top-level or static function, not a closure or an instance method. It has to be reachable by name in a fresh isolate, which is impossible for something capturing surrounding scope.

Its argument and return value must be sendable. Primitives, lists, maps, and most plain data are fine. A BuildContext, a database handle, an open socket, or anything holding a platform resource is not. This is the reason you cannot simply "do the database write in the isolate too" without more work.

In the real case, this took the freeze from 2.1 s to about 40 ms of UI-isolate time. The parsing still takes two seconds - it just happens somewhere the frames do not care about.

The copy is not free

Messages between isolates are copied, and that copy happens on the sending isolate. Sending a 12 MB string in and a large object list back has a real cost, and for smaller payloads it can exceed the work you were trying to move.

Two mitigations matter in practice.

TransferableTypedData moves bytes with no copy, by transferring ownership. If you are shipping raw file contents, send bytes rather than a decoded string:

final bytes = await file.readAsBytes();
final transferable = TransferableTypedData.fromList([bytes]);
final items = await compute(_parseBytes, transferable);

Returning less also helps. Rather than forty thousand objects, have the isolate return a compact intermediate form - or, better, do the database write inside the isolate and return a count.

A long-lived isolate for repeated work

compute spawns and tears down an isolate per call, which costs a few milliseconds. That is irrelevant once; it is wasteful in a loop.

For repeated jobs - image processing per frame, continuous parsing, a sync engine

  • spawn one isolate and keep it, communicating over ports:
final receive = ReceivePort();
await Isolate.spawn(_worker, receive.sendPort);
final sendPort = await receive.first as SendPort;
 
// then, per job:
final response = ReceivePort();
sendPort.send([payload, response.sendPort]);
final result = await response.first;

Isolate.run (Dart 2.19+) is the modern one-shot equivalent of compute with a cleaner signature, and IsolateNameServer helps when a plugin needs to reach your isolate from a background entry point.

Streaming results back instead of waiting

The import above returns everything at once, which means the UI shows nothing for two seconds and then everything. A progress bar is usually worth more than the raw speed.

Isolate.spawn with a SendPort lets the worker report as it goes:

Future<void> _parseStreaming(({SendPort port, String raw}) args) async {
  final json = jsonDecode(args.raw) as List;
  final items = <Item>[];
 
  for (var i = 0; i < json.length; i++) {
    items.add(Item.fromJson(json[i] as Map<String, dynamic>));
    // Report every 1000 rows - one message per row would cost more than
    // the parsing does.
    if (i % 1000 == 0) args.port.send(i / json.length);
  }
 
  args.port.send(items);
}

The batching matters. Every send copies its payload and wakes the receiving isolate's event loop, so a message per row turns a parsing problem into a messaging problem. Reporting a hundred times over a two-second job is invisible overhead; reporting forty thousand times is slower than not moving the work at all.

Measuring, rather than guessing

Every decision above rests on knowing where the frame time goes. The DevTools timeline in profile mode answers it directly:

  • UI thread bar over budget - your Dart is too slow for the frame. An isolate may help, if the work is computational rather than I/O.
  • Raster thread bar over budget - the GPU work is too heavy. No isolate will touch this; look at saveLayer, blurs, and overdraw instead.
  • Both fine but it still feels bad - you are probably looking at a jank spike outside the recorded window, or at input latency rather than frame time.

Two habits are worth building. Always profile in profile mode - a debug build is JIT-compiled and unoptimised, often several times slower, so debug timings mean nothing. And measure the same interaction before and after, because "it feels smoother" after moving work to an isolate is exactly the kind of claim that survives without being true.

// A crude but effective check when DevTools is overkill.
final sw = Stopwatch()..start();
final items = await compute(_parseItems, text);
debugPrint("parsed ${items.length} in ${sw.elapsedMilliseconds}ms");

If that number is under about 16 ms, an isolate is not your fix.

When isolates are the wrong answer

The reflex after learning about isolates is to move everything into them, which usually makes things slower.

If the work is I/O, you do not need one. Network requests, file reads, and sqflite queries already yield to the event loop. Wrapping them in an isolate adds a copy and buys nothing.

If the payload is large and the work is small, the copy dominates. Measure before assuming. A 50 ms computation on a 30 MB payload is often better left on the UI isolate.

If the work can be chunked, chunking may be enough. Processing in slices with an await Future.delayed(Duration.zero) between them lets frames through without any isolate at all. Not as smooth as true parallelism, but far simpler and with no sendability constraints.

If it is a database write, check what your package already does. Drift can run on a background isolate natively, which is better than hand-rolling one around it.

The right question is not "is this slow?" but "is this blocking the event loop for more than a frame?" DevTools' timeline answers it directly, and the answer is often not where you guessed.

Key takeaways

  • async is about waiting, not parallelism. Computation blocks the isolate no matter how many awaits surround it.
  • Anything over ~16 ms of synchronous work drops frames. That is the real threshold, not "feels slow".
  • compute / Isolate.run handles one-shot jobs - top-level or static function, sendable arguments and results.
  • Messages are copied, and the copy costs. TransferableTypedData avoids it for bytes; returning less avoids it in general.
  • Spawn a long-lived isolate for repeated work, not one per call.
  • Isolates are wrong for I/O, wrong when the payload dwarfs the work, and often unnecessary when chunking would do.
  • Profile before and after. The DevTools timeline tells you whether you moved the problem or just moved the code.

FAQ

What is the difference between an isolate and a thread?

An isolate is a thread plus its own heap and its own event loop, with no shared memory. That is what eliminates data races: there is no shared state to guard, so no locks and no synchronized.

Can I access SharedPreferences or a plugin from an isolate?

Not by default. Platform channels are bound to the root isolate, so most plugins fail in a spawned one. Send the data back and do the plugin call on the main isolate, or use a background isolate registrant if the plugin supports it.

Why does compute reject my method?

Because it is a closure or an instance method, and the new isolate cannot reach it. Make it top-level or static, and pass everything it needs as arguments.

Is Isolate.run better than compute?

Slightly - cleaner signature and no Flutter dependency, so it works in pure Dart too. compute remains fine, and both spawn and tear down per call.

How many isolates should I spawn?

Roughly the number of CPU cores for parallel work, and one for a dedicated worker. Spawning dozens does not help - you are competing for the same cores while paying memory for each heap.

How do I know if this is my problem at all?

Run in profile mode and open the DevTools timeline. UI-thread spikes mean Dart work - an isolate may help. Raster-thread spikes mean GPU work, and no isolate will touch it.

Conclusion

The 12 MB import is a small example with a general shape: the UI freezes, async does not help, and the fix is not to make the work faster but to move it somewhere the frame budget does not care about.

Dart's model asks a real price for that - copying instead of sharing, and sendability constraints on what can cross - but it buys the absence of an entire category of concurrency bug. On balance, for app code, that is a trade worth having.

Read more

For where the frame budget goes and how to read the UI-versus-raster distinction in DevTools, see How Flutter Rendering Actually Works. For the architectural reasons behind Dart's concurrency model, see Flutter Is Not Just a UI Framework.