Flutter Isolates: When to Move Heavy Work Off the UI Thread Without Breaking Shared State
Flutter renders everything — the widget tree, layout, paint — on a single thread: the main isolate. Any function that runs on that isolate and takes longer than a frame competes directly with the work of drawing the next screen. It doesn''t matter whether that function is "just" parsing a large JSON payload, decoding an image, compressing a file, or filtering a long list: if it runs synchronously on the main isolate, the user pays for it as a dropped frame, an unresponsive gesture, a scroll that stutters.
The known fix is to move that work to a separate isolate. What doesn''t always get explained with the same care is that a Dart isolate isn''t a thread with shared memory: it''s an isolated process with its own heap that cannot directly touch any variable owned by the isolate that spawned it. All communication happens through message passing over ports (SendPort / ReceivePort), and any message that isn''t a primitive type gets copied — not referenced — when it crosses that boundary. That detail, which sounds like a low-level curiosity, is exactly what decides whether moving work to an isolate improves performance or makes it worse.
When an isolate is worth it
For one-off, bounded calculations, compute() is enough: it takes a top-level or static function and an argument, spins up a short-lived isolate, runs the function, and returns the result.
Future<List<Product>> parseCatalog(String json) {
return compute(_parseCatalogSync, json);
}
List<Product> _parseCatalogSync(String json) {
final data = jsonDecode(json) as List;
return data.map((e) => Product.fromJson(e)).toList();
}
This works well when the work is sporadic: the user opens a screen, a large catalog gets parsed once, and the isolate''s lifecycle ends right there. Spawning a new isolate isn''t free — it means bootstrapping a full Dart runtime — but that cost is amortized because it only happens once per screen.
The problem shows up when the heavy work isn''t a one-off but recurring: a live data stream that needs processing several times a second, a filter recalculated on every keystroke in a search box, a transformation repeated hundreds of times while the app stays open. There, spawning a new isolate per calculation costs more than the calculation itself, and the right answer is a long-lived isolate, created once with Isolate.spawn, that keeps listening on a ReceivePort for as long as the session lasts.
What can cross between isolates and what can''t
Only values Dart knows how to serialize for message passing can travel between isolates: primitive types, String, lists and maps of those types, and a handful of special types like TransferableTypedData, which moves ownership of a byte buffer without copying it. A BuildContext instance doesn''t travel, nor does any object holding a reference to a widget, nor an in-flight Stream or Future, nor a closure that captures variables from the originating isolate unless those variables are themselves serializable. This isn''t an arbitrary restriction: it''s a direct consequence of there being no shared memory, so any object with internal references has to be rebuilt on the other side, field by field, instead of simply being handed over.
In practice, this means the app''s domain model — the classes representing orders, users, configuration — needs to be reconstructible from flat data on the receiving isolate. If those classes already have a fromMap or fromJson constructor built for persistence or for an API, that same constructor doubles as the way to pass instances between isolates: what crosses the boundary is the map, not the object.
The bottleneck we didn''t see coming
In one case, we needed to recalculate a set of derived metrics every time a sensor event arrived — several times a second, over sessions that could run for minutes. The first version of the long-lived isolate received, on every message, a full copy of the session''s accumulated state: every event received so far, not just the new one. The reasoning behind that choice was easy to justify at the time: if the isolate held the entire state, computing any derived metric — averages, maxima, trends — could happen self-contained on the isolate''s side, without the main isolate having to send anything extra.
That design worked while the session was short. As the session got longer, the accumulated state grew, and each new message had to copy that entire state — serialize it on the main isolate''s side, copy it across the port, deserialize it on the computing isolate''s side — before it could even process a single new event. The work of copying the full history on every message ended up costing more than the actual calculation the isolate existed to do, and the latency between an event arriving and the UI showing the updated metric grew visibly worse as the session went on — the exact opposite of what moving the work off the main thread was supposed to achieve.
The redesign: state that lives in one place
The fix wasn''t to optimize serialization — it was to stop sending the full state at all. We redesigned the contract between isolates so that the accumulated state — the responsibility of holding the event history and the metrics already computed — lived entirely inside the computing isolate, not the main one. The main isolate stopped sending "everything that happened so far" and started sending only the new event; the computing isolate keeps its own internal state, updates it incrementally with each message, and returns only the delta of metrics that changed.
class _IsolateInput {
final SendPort reply;
final SensorEvent event;
_IsolateInput(this.reply, this.event);
}
void _computeLoop(SendPort initialPort) {
final receiver = ReceivePort();
initialPort.send(receiver.sendPort);
final history = <SensorEvent>[];
receiver.listen((message) {
final input = message as _IsolateInput;
history.add(input.event);
final metrics = _recalculateIncremental(history, input.event);
input.reply.send(metrics);
});
}
With state living on just one side of the boundary, each message becomes proportional to the new event, not to the session''s accumulated size. The main isolate never holds a copy of the full history again — it doesn''t need to, since its only job is to display the metrics the computing isolate hands back — and that design choice rules out, by construction, the two state copies ever drifting apart, a risk that was latent in the first version even though it never actually surfaced as a bug.
What we check before calling an isolate design done
Before we consider a design that moves work to isolates finished, we check that the isolate is created once for recurring work, not per calculation; that every message between isolates carries only the new data needed for that calculation, never a copy of the full accumulated state; that mutable state has one clear owner — one isolate, not both — so it can''t drift out of sync; and that types crossing the boundary are flat and reconstructible, with a fromMap or equivalent, instead of relying on Dart to guess how to copy an object full of internal references. When those conditions hold, the isolate stops being a place where state gets duplicated on every message, and goes back to being what it should have been from the start: a way to get heavy work out of the UI''s path without paying for that gain with a full copy of the application traveling back and forth.