NK

Search

Search pages, projects, posts, components, and icons

All articles
Mobile14 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.

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

TermMeaning
IsolateDart's unit of parallelism: its own memory and its own event loop, talking to others only by copied messages.
ThreadOne line of execution. Dart gives each isolate exactly one.
Event loopThe loop that picks the next pending piece of work and runs it to completion.
async and awaitSyntax for pausing a function while it waits for something, without stopping the thread.
BlockingOccupying the thread so completely that nothing else, including drawing the screen, can run.
FrameOne picture on screen. At 60 Hz you get about 16 milliseconds to produce each one.
JankA frame that missed its deadline, seen as a visible stutter.
Frame budgetThe time available to produce one frame before the user sees a dropped one.
ParseTurning text, here JSON, into objects your code can use.
HeapThe memory an isolate allocates objects in. Each isolate has its own.
PortThe one-way channel isolates send messages over, a SendPort at one end and a ReceivePort at the other.
SendableData simple enough to be copied to another isolate. Plain values yes, open sockets and BuildContext no.
computeFlutter's helper that spawns an isolate, runs one function in it, returns the result and shuts it down.
SpawnStarting a new isolate, which costs a few milliseconds and its own memory.
Top-level functionA function declared outside any class, reachable by name from a fresh isolate.
ClosureA function that captures variables from where it was written, which is why it cannot cross to a new isolate.
Data raceTwo threads touching the same memory at once and corrupting it. Isolates make this impossible.
ChunkingSplitting long work into slices, yielding between them so frames get through.
Profile modeThe build mode you must measure in. Optimised like release but still carrying timing instruments.
DevToolsFlutter's profiling and debugging suite, where the frame timeline lives.
saveLayerAn expensive drawing operation triggered by opacity and clipping, a common cause of raster jank.
OverdrawPainting the same pixel several times in one frame, wasting graphics work.

Abbreviations

ShortFull formIn plain words
UIUser InterfaceEverything the user sees and touches
JSONJavaScript Object NotationA common text format for data
CPUCentral Processing UnitThe chip that runs your Dart code
GPUGraphics Processing UnitThe chip that draws pixels
I/OInput and OutputReading and writing outside your program: network, disk, database
JITJust In TimeCompiling code as the program runs, which is why debug builds are slower
APIApplication Programming InterfaceThe set of calls one piece of code offers to another
MBmegabyteRoughly a million bytes. The file in this example is 12 of them
msmillisecondsA thousandth of a second. A 60fps frame budget is 16.6ms
ssecondsUsed here for the longer freeze times, as in 2.1 s
HzhertzTimes per second. A 60 Hz screen redraws sixty times a second
FAQFrequently Asked QuestionsThe 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

  • 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.

References

Official documentation for the topics covered here.

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.