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.
Glossary
Everything this post uses, defined before it is used. Skip it if the terms are already familiar, or come back when one of them trips you up.
The one sentence the rest of this post expands: await lets your app wait
without freezing, but only a second isolate lets it work without freezing.
Terms
| Term | Meaning |
|---|---|
| Isolate | Dart's unit of parallelism: its own memory and its own event loop, talking to others only by copied messages. |
| Thread | One line of execution. Dart gives each isolate exactly one. |
| Event loop | The loop that picks the next pending piece of work and runs it to completion. |
async and await | Syntax for pausing a function while it waits for something, without stopping the thread. |
| Blocking | Occupying the thread so completely that nothing else, including drawing the screen, can run. |
| Frame | One picture on screen. At 60 Hz you get about 16 milliseconds to produce each one. |
| Jank | A frame that missed its deadline, seen as a visible stutter. |
| Frame budget | The time available to produce one frame before the user sees a dropped one. |
| Parse | Turning text, here JSON, into objects your code can use. |
| Heap | The memory an isolate allocates objects in. Each isolate has its own. |
| Port | The one-way channel isolates send messages over, a SendPort at one end and a ReceivePort at the other. |
| Sendable | Data simple enough to be copied to another isolate. Plain values yes, open sockets and BuildContext no. |
compute | Flutter's helper that spawns an isolate, runs one function in it, returns the result and shuts it down. |
| Spawn | Starting a new isolate, which costs a few milliseconds and its own memory. |
| Top-level function | A function declared outside any class, reachable by name from a fresh isolate. |
| Closure | A function that captures variables from where it was written, which is why it cannot cross to a new isolate. |
| Data race | Two threads touching the same memory at once and corrupting it. Isolates make this impossible. |
| Chunking | Splitting long work into slices, yielding between them so frames get through. |
| Profile mode | The build mode you must measure in. Optimised like release but still carrying timing instruments. |
| DevTools | Flutter's profiling and debugging suite, where the frame timeline lives. |
saveLayer | An expensive drawing operation triggered by opacity and clipping, a common cause of raster jank. |
| Overdraw | Painting the same pixel several times in one frame, wasting graphics work. |
Abbreviations
| Short | Full form | In plain words |
|---|---|---|
| UI | User Interface | Everything the user sees and touches |
| JSON | JavaScript Object Notation | A common text format for data |
| CPU | Central Processing Unit | The chip that runs your Dart code |
| GPU | Graphics Processing Unit | The chip that draws pixels |
| I/O | Input and Output | Reading and writing outside your program: network, disk, database |
| JIT | Just In Time | Compiling code as the program runs, which is why debug builds are slower |
| API | Application Programming Interface | The set of calls one piece of code offers to another |
| MB | megabyte | Roughly a million bytes. The file in this example is 12 of them |
| ms | milliseconds | A thousandth of a second. A 60fps frame budget is 16.6ms |
| s | seconds | Used here for the longer freeze times, as in 2.1 s |
| Hz | hertz | Times per second. A 60 Hz screen redraws sixty times a second |
| FAQ | Frequently Asked Questions | The question section near the end |
Why await did not help
The import looked reasonable. Four lines, three of them with await on the
front, which is usually the sign that a function is being polite about not
hogging the app. Read the comments, because only two of those lines actually
step aside.
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.
The change below is two lines. Pull the parsing out into its own named function,
then call it through compute instead of directly. The app now hands that work
to a second worker and waits for the answer, and waiting is something await is
genuinely good at.
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.
Going deeper: the copy is not free
Isolates cannot share memory, so anything you send is photocopied first. For small messages that is nothing. For a 12 MB string it is a real cost, paid by the isolate doing the sending.
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.
Going deeper: a long-lived isolate for repeated work
compute hires a temp for one job and lets them go. That is the right shape for
a file import that happens once. It is the wrong shape when the same job runs
over and over, and the fix is to hire someone permanent and keep a phone line
open to them.
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 such as image processing per frame, continuous parsing or 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.
Going deeper: 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.
References
Official documentation for the topics covered here.
- Dart: Concurrency - the single event loop, and why
awaitdoes not create parallelism - Dart: Isolates - spawning isolates, ports, and what data is allowed to cross
- Flutter API: compute - the one-call helper that spawns an isolate, runs a function and shuts it down
- Flutter API: Isolate - the full isolate API when
computeis too coarse, includingIsolate.run - Flutter: Performance best practices - what to measure first, and the usual causes of a blown frame budget
- Flutter: DevTools performance view - reading the frame timeline and telling UI thread jank from raster jank
- Flutter: Build modes - why profile mode is the only honest place to take these measurements
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.