If your Flutter app's UI freezes for a moment while parsing a large JSON file or processing an image, the culprit is usually one thing: missing Dart isolate compute usage. Dart runs every app inside an isolate that drives an event loop on a single thread; this post walks through moving heavy work off the UI thread with compute(), Isolate.run(), and a manually set up long-lived isolate, when you actually need an isolate, and what can and can't be sent between isolates.
💡 Pro Tip:compute()andIsolate.run()run the exact same mechanism on native platforms — you're not choosing between them, you're just answering "Flutter API or plain Dart API?"
Table of Contents
- Dart's Single-Thread Model and the Event Loop
- Why is there no mutex/lock?
- Isolates: No Shared Memory, Only Messages
- One-Off Work with compute()
- Isolate.run() and Long-Lived Isolates
- When should you move to a persistent isolate?
- Two-Way Communication with SendPort/ReceivePort
- What Can and Can't Be Sent: TransferableTypedData
- Measuring It: Seeing Jank in the DevTools Timeline
- The Cost of an Isolate — When NOT to Use One
- Practical Examples: JSON Parsing, Image Processing, Encryption
- FAQ
- What is a Dart isolate, and how is it different from a thread?
- When should you use compute() in Flutter?
- How is data shared between isolates?
- Why does JSON parsing freeze the UI?
- Which should I choose between Isolate.run() and compute()?
- When is setting up a persistent worker isolate over-engineering?
- Update (September 2026)
- Conclusion
- Sources
Dart's Single-Thread Model and the Event Loop
Dart runs every app inside at least one "isolate." The official language guide defines an isolate as having its own memory and a single thread that runs an event loop. This resembles JavaScript's single-threaded event-loop model, except Dart can spawn additional isolates for parallel work — a model closer to Node.js worker_threads or a Web Worker.
In Flutter, the UI runs on a single "main isolate" (the UI thread), running both your app code and the Flutter framework code (widget build, layout, paint). The DevTools performance guide makes this explicit: the UI thread runs Dart code inside the Dart VM, and this thread must not be blocked. When a heavy synchronous operation (large JSON parsing, compression, encryption) runs on this thread, other events in the event loop — including frame production — queue up and get delayed. The visible result is "jank": the UI freezes or scrolling stutters.
The critical point: the event loop by itself isn't "slow" — asynchronous I/O (network requests, file reads, Future/Stream-based waits) never blocks it, because that work already frees the thread while it waits. The problem only shows up in SYNCHRONOUS, CPU-heavy code: a for loop, jsonDecode, a compression or encryption algorithm — work that genuinely consumes processor time and can't be split up with await. This is exactly where isolates come in — not to move async waiting off the main thread, but to move computation that truly keeps the CPU busy.
Why is there no mutex/lock?
Dart doesn't need classic concurrency primitives (mutex, lock, semaphore), because the model is designed to make them unnecessary — you'll see why in the next section.
Isolates: No Shared Memory, Only Messages
The critical difference is the degree of isolation: an isolate's global state can't be reached from any other isolate. This is deliberate — with no shared memory, Dart has no need for classic concurrency primitives like mutexes, locks, or semaphores, or the data-race bugs that come with them. The name "isolate" itself comes from this: they can't "see" each other's memory.
The ONLY way isolates communicate is message passing, via a ReceivePort/SendPort pair. A SendPort is always bound to exactly one ReceivePort, while a ReceivePort can have many SendPorts — a "many-to-one" channel structure. So a single worker isolate can receive messages from multiple sources but always sends its replies back down one specific line.
- Isolate: an isolated unit of work with its own memory and its own event loop.
- SendPort: an endpoint used to send messages to an isolate.
- ReceivePort: an endpoint that listens for messages, behaving like a
Stream.
In practice, the "many-to-one" structure means this: once you set up a worker isolate, its single ReceivePort can listen to commands from multiple screens or widgets at once. That makes it possible to use one persistent isolate as a simple "job queue": requests from different places go to different SendPorts bound to the same ReceivePort, and the worker processes them in turn.
One-Off Work with compute()
Instead of manually wiring up SendPort/ReceivePort for most one-off heavy-work scenarios, the Flutter/Dart team offers two higher-level APIs: Isolate.run() and Flutter's compute().
On the Flutter side, compute() wraps exactly this pattern: with the signature Future<R> compute<M, R>(ComputeCallback<M, R> callback, M message, {String? debugLabel}), it runs the callback in the background and returns a Future that completes with the result. On native platforms this is exactly equivalent to await Isolate.run(() => fun(message)); only on web (where isolates aren't supported) does the callback run on the current event loop. compute() also has a lower bound: it's meant for work over a few milliseconds; for anything a millisecond or less, SchedulerBinding.scheduleTask is recommended instead — spawning an isolate for every tiny computation can backfire, adding more latency via spawn overhead.
1// Decode a large JSON string without blocking the UI thread.2Future<List<Photo>> parsePhotosInBackground(String jsonBody) {3 return compute(_parsePhotos, jsonBody, debugLabel: 'parsePhotos');4}5 6// Must be a top-level or static function.7List<Photo> _parsePhotos(String jsonBody) {8 final List<dynamic> parsed = jsonDecode(jsonBody) as List<dynamic>;9 return parsed10 .map<Photo>((dynamic json) => Photo.fromJson(json as Map<String, dynamic>))11 .toList();12}Isolate.run() and Long-Lived Isolates
Isolate.run() collapses the steps of spawning and tearing down a worker isolate (spawn, wait for the result, shut the isolate down) into a single call. It always returns a Future because the main isolate keeps running while it waits — meaning the API is fully asynchronous and used with await, with no need to deal with the isolate itself at the SendPort level.
1Future<List<Photo>> parsePhotosWithIsolateRun(String jsonBody) async {2 return Isolate.run(() {3 final List<dynamic> parsed = jsonDecode(jsonBody) as List<dynamic>;4 return parsed5 .map<Photo>((dynamic j) => Photo.fromJson(j as Map<String, dynamic>))6 .toList();7 });8}Both compute() and Isolate.run() set up a "short-lived" isolate pattern: spawn, do the work, return the result, shut down. This is convenient but not free: spawning a new isolate and copying objects between isolates carries a cost. If the same computation repeats over and over with Isolate.run (say, a fresh short-lived isolate on every user interaction), a PERSISTENT isolate that takes work via messages instead of repeatedly spawning and closing can be more performant.
Approach | Lifetime | Setup | Best fit |
|---|---|---|---|
compute() | Short (one-off) | Automatic | Flutter code, cross-platform including web |
Isolate.run() | Short (one-off) | Automatic | Plain Dart code, a single call |
Persistent worker isolate | Long | Manual (SendPort/ReceivePort) | Frequently repeated, same-kind consecutive work |
When should you move to a persistent isolate?
The official guide gives a clear signal here: doing the same computation over and over with Isolate.run may perform better with isolates that don't exit right away. For example, if a search box re-filters a large list with compute() on every keystroke, a new isolate spawns and tears down for every character — moving that logic into a persistent worker isolate and just sending messages eliminates the repeated spawn cost. For a one-time file import, that complexity isn't needed; compute() is enough.
Two-Way Communication with SendPort/ReceivePort
The need for two-way communication — sending multiple commands to a worker and getting multiple replies back — is exactly what Isolate.run doesn't cover; this is where a persistent isolate with its own ReceivePort/SendPort pair comes in: the main isolate opens a ReceivePort, sends the worker that port's sendPort, and the worker opens its own ReceivePort and sends its SendPort back, establishing a two-way channel. Because a ReceivePort can receive from multiple SendPorts, one worker isolate can listen for commands from more than one "client."
1Future<SendPort> spawnWorker() async {2 final ReceivePort initPort = ReceivePort();3 await Isolate.spawn(_workerEntry, initPort.sendPort);4 // The worker sends its OWN SendPort as its first message.5 final SendPort workerPort = await initPort.first as SendPort;6 initPort.close();7 return workerPort;8}9 10void _workerEntry(SendPort mainSendPort) {11 final ReceivePort workerPort = ReceivePort();12 mainSendPort.send(workerPort.sendPort);13 workerPort.listen((dynamic message) {14 final int input = message as int;15 mainSendPort.send(input * input);16 });17}What Can and Can't Be Sent: TransferableTypedData
The most commonly overlooked detail of inter-isolate messaging is that NOT every Dart object can be sent. The official docs explicitly list objects holding native resources (e.g. Socket) along with ReceivePort, DynamicLibrary, Finalizable, Finalizer, NativeFinalizer, Pointer, UserTag instances, and classes marked @pragma('vm:isolate-unsendable') as unsendable. The technical reason is simple: these objects hold references to OS-level resources (a file descriptor, a native memory pointer, a native thread) that can't be copied or moved to another isolate, because the resource itself is tied to a specific OS/VM context. Isolate.spawn() and Isolate.exit() use the same SendPort mechanism, so they're subject to the same restrictions.
Sendable | Not sendable |
|---|---|
int, double, String, bool, null | Socket |
List, Map, Set (if made up of sendable elements) | ReceivePort |
SendPort | DynamicLibrary |
TransferableTypedData | Pointer, Finalizable, Finalizer, NativeFinalizer, UserTag |
The normal way to move large binary data (e.g. the raw bytes of an image) is to COPY it, which takes time proportional to the byte count. TransferableTypedData exists to eliminate that cost: it "moves" a byte array from one isolate to another in constant time, with no copy. The tradeoff is single use: after the transfer, the sending side can no longer materialize() the data — only the RECEIVING side can turn it into a concrete ByteBuffer from then on. This resembles C's "move semantics" — a transfer of ownership, not a share.
Concretely: sending a normal Uint8List copies bytes one by one from the sending isolate's memory into the receiving isolate's — that copy time grows with the data. TransferableTypedData doesn't copy memory, it transfers ownership; the SEND step is constant time, though packaging it with TransferableTypedData.fromList still takes time proportional to the byte count per the official docs — the win is that the copy at the isolate boundary disappears. Practical rule: skip TransferableTypedData for a small config object of a few kilobytes, but for the raw bytes of a photo or audio file, this wrapper makes a direct, measurable difference.
1void sendImageBytes(SendPort target, Uint8List rawBytes) {2 final TransferableTypedData packet = TransferableTypedData.fromList(<Uint8List>[rawBytes]);3 target.send(packet);4}5 6void receiveImageBytes(TransferableTypedData packet) {7 final Uint8List bytes = packet.materialize().asUint8List();8 // bytes is now owned by this isolate.9}Measuring It: Seeing Jank in the DevTools Timeline
The decision to use an isolate should come from measurement, not theory. Flutter's official performance guide gives a clear threshold: on a 60Hz device, a frame is "janky" if it takes more than ~16ms — a direct consequence of the 60-frames-per-second target (1000ms / 60 ≈ 16.6ms). DevTools' Performance/Timeline view marks janky frames with a red overlay on the frame rendering chart; selecting a janky frame opens the "Frame Analysis" tab, showing debugging hints about what was expensive in that frame.
One precondition: analysis must be done with a profile build; frame times measured in debug mode don't reflect release performance. A stutter seen in debug mode can be misleading when asking "should I move this to an isolate?" — the actual decision should rest on measurements from flutter run --profile (or DevTools connected to a profile build).
In practice: run the app in profile mode, open the DevTools Performance tab, repeat the interaction you suspect causes jank (scrolling a list, opening a file, running a search), then look for frames marked with a red overlay. Once you find one, read the Frame Analysis hints to confirm whether the bottleneck is synchronous Dart code on the UI thread — if so, that work is a concrete candidate for an isolate.
The Cost of an Isolate — When NOT to Use One
Flutter's one firm rule for isolate usage ties back to this measurement: move a computation to an isolate when it genuinely causes UI jank — beyond that, don't throw every task at an isolate as "premature optimization." Spawning an isolate for a short computation can INCREASE net latency from spawn and message-copying overhead; that's why compute() targets work "over a few milliseconds," while SchedulerBinding.scheduleTask covers a millisecond or less.
- Use it: a large, non-repeating, synchronous computation is causing UI jank.
- Don't use it: the work is already under a few milliseconds (spawn cost outweighs the work itself).
- Move to a persistent isolate: the same heavy work repeats frequently, at short intervals.
In one sentence: an isolate isn't free, so "wrap everything in an isolate just in case" gives a false sense of safety. Adding one without measuring can increase both code complexity (async flow, message serialization, error handling) and net latency for small tasks; the order should always be measure first, then move.
Practical Examples: JSON Parsing, Image Processing, Encryption
Work that typically causes jank and belongs on an isolate: reading from a local database, parsing/decoding large data files, processing or compressing photo/audio/video files, and audio/video conversion. JSON parsing, image processing, and encryption are the most common concrete examples:
- JSON parsing: process a large list response from an API inside
compute()withjsonDecodeplus the model conversion (as in theparsePhotosInBackgroundexample above). If the list has thousands of items, both the decode and the model conversion should stay inside the samecompute()call; moving onlyjsonDecodeto the isolate and doing the model conversion on the main isolate still leaves the heavy part of the work on the UI thread. - Image processing: move the raw bytes of an image to a worker isolate with
TransferableTypedData, apply resizing/filtering there, then send the result back the same way. If a gallery screen processes multiple images at once, sending work sequentially to a single persistent worker isolate — instead of opening a separatecompute()for each image — avoids the repeated spawn cost. - Encryption: encrypting a large file with an algorithm like AES is CPU-heavy and synchronous; use
compute()if it's one-off, and a persistent worker isolate if the user encrypts files often. Remember that sensitive data like an encryption key is subject to the same sendability rules when passed to a worker — pass the key as a sendable type such asString/Uint8List, and never try to send aPointerheld by a native cryptography library as a message.
I generally prefer starting with compute()/Isolate.run() and only moving to a persistent worker isolate once I actually see repeated spawn cost in DevTools — premature complexity is as risky as premature optimization. A persistent worker isolate also complicates debugging: since it runs in a separate memory space, propagating an error back to the main isolate also happens through message passing — the worker catches it in its own try/catch and sends the result back as an error message over the SendPort.
GOLDEN TIP
The most valuable insight in this article
This tip holds the article's most important takeaway.
Easter Egg
You found a hidden gem!
There's a hidden detail in this section. Want to uncover it?
Reader Reward
For reading this all the way to the end, here's a short decision checklist you can run through before moving to an isolate — copy it directly for the next piece of heavy work you run into.
FAQ
What is a Dart isolate, and how is it different from a thread?
An isolate is an isolated unit of work with its own memory and its own event loop running on a single thread. Unlike classic OS threads, it doesn't share memory: an isolate's global state can't be reached from any other isolate, so there's no need for shared-memory concurrency primitives like mutexes or locks.
When should you use compute() in Flutter?
compute() is used when you want to run a one-off, synchronous computation that takes more than a few milliseconds without blocking the UI thread — things like large JSON parsing, image processing, or encryption. On native platforms it's exactly equivalent to Isolate.run(); on web, it runs on the current event loop. For work taking a millisecond or less, SchedulerBinding.scheduleTask is preferred.
How is data shared between isolates?
Data isn't "shared," it's sent as a message; the only channel is the SendPort/ReceivePort pair. Most primitive types and collections made of sendable elements are sent by copying; large binary data can be moved copy-free with TransferableTypedData. Objects holding native resources (Socket, Pointer, ReceivePort, etc.) can never be sent at all.
Why does JSON parsing freeze the UI?
Because in Flutter, the UI thread runs both your app code and the framework's build/layout/paint work on that same single thread. When a large jsonDecode call occupies that thread synchronously, frame production has to wait in the same queue; the result is janky frames that cross the ~16ms threshold and show up with a red overlay in DevTools.
Which should I choose between Isolate.run() and compute()?
On native platforms, both run exactly the same mechanism — compute() is equivalent to await Isolate.run(() => fun(message)) on native. The difference is platform scope: compute() also compiles for web and, in that environment where isolates aren't supported, runs the callback on the current event loop, while Isolate.run() is a plain Dart API used in a package or command-line tool with no web target.
When is setting up a persistent worker isolate over-engineering?
If the work is one-off or rarely repeated (say, a user imports a large file a few times a month), the added complexity of a persistent worker isolate (lifecycle management, error propagation, channel setup) costs more than it gains. Flutter's one firm rule applies here too: move to an isolate — and therefore to a persistent worker — only when there's measurable jank.
Update (September 2026)
When this post was first published (November 2025), Dart 3.9 was current. One change since then is worth noting: Dart 3.13 (August 12, 2026) added advanced APIs to the dart:isolate library for synchronous/event-loop control — Isolate.runSync, Isolate.create, Isolate.pinToCurrentThread (source: dart-lang/sdk CHANGELOG, 3.13.0 section); these are low-level, isolate-group-scoped APIs corresponding to Dart VM C APIs (Dart_CreateIsolateInGroup, Dart_SetCurrentThreadOwnsIsolate) (source: api.dart.dev Isolate.create / Isolate.pinToCurrentThread), and they don't affect the compute()/Isolate.run() usage in this post — there's no breaking change in the API, compute() and Isolate.run() still work exactly the same way.
Conclusion
Always base the isolate decision on measurement: first confirm the jank in DevTools with a profile build, then choose between compute()/Isolate.run() and a persistent worker isolate depending on whether the work is one-off or frequently repeated. Remember TransferableTypedData for moving large binary data, and to never send objects holding native resources as messages.
These four steps — measure, classify (one-off or frequently repeated), pick the right API, follow the sendability rules — let you use Dart's isolated memory model with as little friction as possible. Because compute() and Isolate.run() shield you from the details of SendPort/ReceivePort, these two APIs alone are enough for most projects; a persistent worker isolate and TransferableTypedData only come into play when measurement genuinely calls for them.
If you want to go deeper: for state management alongside isolates, see the Flutter Riverpod State Management guide; for the general 60fps target, see Flutter Performance Optimization; for a strategy to test code that involves isolates, see the Flutter Testing Guide; for other modern Dart 3 features, see Dart 3 New Features; and for layering code that uses isolates, see the Flutter Clean Architecture guide.
Sources
- Concurrency in Dart — isolate memory isolation, message passing, and unsendable object types.
- Isolates — Isolate.run(), SendPort/ReceivePort mechanics, and the worker isolate pattern.
- Isolates | Flutter performance docs — the cost of short-lived isolates and the one firm rule for isolate usage.
- Performance view | DevTools — the jank threshold (~16ms), the Frame Analysis tab, and the profile-build requirement.
- compute function - foundation library — the compute() signature and the native/web behavior difference.
- TransferableTypedData class - dart:isolate library — copy-free, single-use data transfer.
- Dart SDK CHANGELOG (3.13.0) — Isolate.runSync and related synchronous API additions.
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

