Sitemap
raj kavadia

raj kavadia

Isolate.run vs compute in Flutter: Same Thing on Mobile, Not on the Web

5 min readOct 22, 2026

--

Press enter or click to view image in full size

What each one actually does, the closure trap that sends more than you meant, and when a one-shot isolate is the wrong tool.

Note: drafted with AI assistance and checked against the official documentation.

Every Flutter app runs its Dart code on one isolate by default. That’s fine until something heavy lands on it, like decoding a big JSON response or filtering a large list. On a 60Hz screen you get roughly 16ms per frame, and anything that holds the main isolate longer than that shows up as jank.

The usual advice is “move it to an isolate”. Flutter gives you two easy ways to do that, compute() and Isolate.run() , and people often treat them as interchangeable. On Android and iOS they basically are. On the web they’re not, and there are a couple of details that matter more than which one you pick.

The two APIs

compute lives in package:flutter/foundation.dart :

Future<R> compute<M, R>(
ComputeCallback<M, R> callback,
M message, {
String? debugLabel,
})

Isolate.run is part of dart:isolate and has been there since Dart 2.19:

static Future<R> run<R>(
FutureOr<R> computation(), {
String? debugName,
})

So compute takes a function plus one message. Isolate.run takes a function with no arguments, which in practice means a closure. The Flutter docs are direct about the relationship: on native platforms, await compute(fun, message) is equivalent to await Isolate.run(() => fun(message)) .

Here’s the same parse written both ways:

List<Product> parseProducts(String body) {
final list = jsonDecode(body) as List<Object?>;
return list
.cast<Map<String, Object?>>()
.map(Product.fromJson)
.toList();
}

// Flutter's helper
final a = await compute(parseProducts, body);

// Plain Dart
final b = await Isolate.run(() => parseProducts(body));

The web is where they differ

Dart on the web doesn’t support multiple isolates. If you import dart:isolate and call Isolate.run , that code isn’t going to work in a Flutter web build.

compute handles this for you. On the web it runs the callback on the current event loop. Your code compiles and gives the right result, but the work is back on the main thread, so it can still jank. If you ship to web as well as mobile, compute is the safer call. Just don’t read that as “compute makes it fast on the web”. It doesn’t.

If you’re only on Android, iOS and desktop, use whichever reads better. I lean toward Isolate.run because the closure makes multiple inputs easy, but that convenience is also where the trap is.

The closure trap

Isolate.run sends your closure to the new isolate. A closure carries everything it captured, and the Dart API docs warn that because of how the implementation works, it can capture more state than you’d expect. That can mean extra copying and memory use. If any of that captured state can’t be sent between isolates, like an open file or a socket, it fails at runtime with an error like Illegal argument in isolate message .

Get raj kavadia’s stories in your inbox

Join Medium for free to get updates from this writer.

The easy way to fall into this in Flutter is a closure written inside a State method:

class _CatalogState extends State<Catalog> {
String? _body;
List<Product> _products = [];

Future<void> _parse() async {
// Reads a field, so the closure needs `this`
final products = await Isolate.run(() => parseProducts(_body!));
setState(() => _products = products);
}
}

To read _body , that closure has to reach this , the State object, and what gets sent along can be a lot more than one string. Sometimes it just works and is slower than it should be. Sometimes it throws.

The fix the Dart docs show is to pass the state you need into a separate function, so the closure only captures that argument:

Future<List<Product>> parseInBackground(String body) =>
Isolate.run(() => parseProducts(body));

Future<void> _parse() async {
final products = await parseInBackground(_body!);
setState(() => _products = products);
}

A top level or static function is the cleanest option. compute(parseProducts, _body!) avoids the problem too, because you hand it a function and a value instead of a closure.

Getting the data back is cheap, sending it isn’t

Both APIs return the result with Isolate.exit . Since the worker is shutting down anyway, it hands the result over instead of copying it. So a large parsed list comes back without a second copy.

The input is a different story. Whatever you send in is generally copied, apart from immutable objects like strings, which can be passed by reference. That’s one more reason to send the raw response body rather than objects you’ve already built.

Error handling behaves the way you’d hope. If the computation throws, the isolate shuts down and Isolate.run throws the same error to your await . Uncaught async errors inside the worker are the exception. They come back as a RemoteError , so wrap the work in its own try/catch if you care about the exact type.

Name your isolates

Both APIs take a name, debugLabel for compute and debugName for Isolate.run . Per the compute docs, the timeline events from that isolate carry the name, which makes the CPU work much easier to pick out in DevTools when you profile.

final products = await compute(
parseProducts,
body,
debugLabel: 'parseProducts',
);

Plugins from a background isolate

You can’t use rootBundle or anything from dart:ui in a spawned isolate. Platform channels are possible, though. You register the isolate with a token from the root isolate first:

final token = RootIsolateToken.instance!;

final cached = await Isolate.run(() async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
final prefs = await SharedPreferences.getInstance();
return prefs.getString('catalog_cache');
});

Grab the token on the main isolate, outside the closure. The limitation is that the host side can’t push unsolicited messages to a background isolate. You can make a request and get a reply, but a long-lived listener that the platform pushes updates into won’t work there.

When a one-shot isolate is the wrong tool

Isolate.run and compute start a new isolate every call and tear it down after. That’s fine for a parse that happens once per screen. If you’re doing the same work over and over, say on every keystroke in a search box, the startup and copy cost adds up.

That’s when you want a long-lived worker built on Isolate.spawn , ReceivePort and SendPort . The isolates page on dart.dev walks through a full version with request ids, error handling and shutdown. It’s more code, but you pay the startup cost once.

Which one should you use

On mobile and desktop they cost the same, so pick on readability, and keep closures away from this . If web is a target, use compute so it compiles, and remember the work runs on the main thread there. If the work repeats a lot, skip both and keep a worker isolate around.

--

--