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.
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
asyncis about waiting, not parallelism. Computation blocks the isolate no matter how manyawaits surround it.- Anything over ~16 ms of synchronous work drops frames. That is the real threshold, not "feels slow".
compute/Isolate.runhandles one-shot jobs - top-level or static function, sendable arguments and results.- Messages are copied, and the copy costs.
TransferableTypedDataavoids 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.