Flutter Isolates for JSON Parsing: What compute() Really Does
Our catalog screen stuttered at the worst possible moment. The spinner would turn, the response would land, and the app would lock for a beat right before the grid appeared. Taps queued up, a scroll started during the freeze landed somewhere else, and on an older phone the loading animation stopped mid-rotation.
In Flutter that pause is nearly always JSON parsing. jsonDecode is synchronous CPU work on the same isolate that paints your frames, so I did what every guide recommends and reached for compute().
The stutter went away on Android and iOS. Then the web build shipped and the same screen was exactly as bad as before. Finding out why took longer than it should have.
await does not move the work anywhere
The version I started with looked harmless enough.
Future<List<Product>> fetchProducts() async {
final response = await http.get(Uri.parse('$baseUrl/products'));
final decoded = jsonDecode(response.body) as List<dynamic>;
return decoded
.map((json) => Product.fromJson(json as Map<String, dynamic>))
.toList();
}
Every line of that is asynchronous and none of it is concurrent. The await on the HTTP call hands control back to the event loop while the socket works. jsonDecode runs to completion on the current isolate. While it runs, no frame gets built. A few thousand products is already past the 16ms budget.
compute is Isolate.run, except on the web
The native implementation of compute lives in flutter/foundation, and it is not a mechanism of its own.
// _isolates_io.dart
Future<R> compute<M, R>(
ComputeCallback<M, R> callback,
M message, {
String? debugLabel,
}) async {
debugLabel ??= kReleaseMode ? 'compute' : callback.toString();
return Isolate.run<R>(() {
return callback(message);
}, debugName: debugLabel);
}
So compute is a naming wrapper over Isolate.run, and in a release build every isolate it spawns carries the same name.
Then there is the version that runs in a browser.
// _isolates_web.dart
Future<R> compute<M, R>(
ComputeCallback<M, R> callback,
M message, {
String? debugLabel,
}) async {
await null;
return callback(message);
}
No isolate and no second thread. One microtask of breathing room, and then the callback runs inline on the event loop your widgets are already using. The API docs say it plainly: on web platforms the callback runs on the current event loop. Web was the one platform I had not checked.
What crosses the boundary and what stays put
How much that jump costs depends on what you send. Per the SendPort.send docs, objects identified as immutable, strings included, are shared rather than copied, while everything else is copied into the receiving isolate. Your body is a string, so it goes over for free. Coming back is cheaper still. Isolate.run returns through Isolate.exit, which sends the result without copying it.
Those two rules point at a specific shape: send the raw body, do the decode and the model building inside the isolate, and return finished objects.
// Top-level functions are sendable as they are.
List<Product> _parseProducts(String body) {
final decoded = jsonDecode(body) as List<dynamic>;
return decoded
.map((json) => Product.fromJson(json as Map<String, dynamic>))
.toList();
}
Future<List<Product>> fetchProducts() async {
final response = await http.get(Uri.parse('$baseUrl/products'));
return compute(_parseProducts, response.body);
}
The shape that wastes the exercise is decoding in the isolate and returning the raw list of maps to build models on the UI thread. The decode moved, the tens of thousands of allocations did not.
The callback has to be sendable too, and a top-level function always is. A method tear-off drags its instance along, and if that instance holds a plugin handle, the spawn fails with an illegal argument error instead of running.
If you work from response.bodyBytes, that Uint8List is mutable and gets copied in full. TransferableTypedData.fromList moves the bytes in constant time instead, with the catch that materialize() works once and the sender cannot touch the original afterwards.
Typed errors survive the trip, uncaught ones do not
Inside Isolate.run a small wrapper catches errors synchronously and hands the error and its stack back through the same exit call it uses for results, which is why typed catches still behave.
try {
products = await compute(_parseProducts, body);
} on FormatException catch (e) {
// e.offset is still there.
log('bad payload at ${e.offset}');
}
An asynchronous error that nobody awaits takes a different path. It reaches the caller through the uncaught error handler, which can only send strings, so Isolate.run wraps it in a RemoteError. Per the Dart docs, that type has the same toString and stackTrace as the original and no other features of it. Your catch clause cannot match. No FormatException is left, only a message that reads like one.
Keeping a worker alive is a different job
Every compute call spawns a fresh isolate, which is fine for one parse per screen and wasteful if you are parsing chunks on a timer. The usual upgrade is Isolate.spawn with a pair of ports and one long-lived worker.
What breaks there is the wiring Isolate.run does quietly for you. It routes both onError and onExit to the same port and sets errorsAreFatal, so its future always completes, even when the isolate dies without sending anything. Roll your own spawn without those handlers and a crashed worker stops existing. Your Completer stays pending, the spinner spins forever, and nothing in the log says why.
Plugins add a second constraint: a worker calling into one needs BackgroundIsolateBinaryMessenger.ensureInitialized with a token from the root isolate, and setMessageHandler throws an UnsupportedError there, since platform messages go to the root. Read the file on the main isolate, pass a string in, get data back out.
Measure before you move anything
I added an isolate because a guide told me to, and it fixed two platforms out of three. The frame chart in DevTools would have told me what those dropped frames were made of before I changed a line: CPU in the parse, the one thing this trick actually fixes. When the drop is layout or image decoding, moving JSON around buys you a spawn and nothing else.
If the other half of your problem is a list rebuilding while you scroll, I wrote up the fixes for ListView jank separately. My parse functions live in Snippet Ark, because I reach for the same four in every app.