Moving JSON Decoding Off Flutter's UI Isolate: When It Helps and What It Costs
Flutter's UI isolate blocks frames when decoding large JSON synchronously. This post shows how to move that work to a background isolate with compute() or Isolate.run(), the sendable-type constraints, when the overhead isn't worth it, and how to verify the improvement on your own device.
07 Sept 2026, 08:45 UTC

The frame budget problem
Flutter renders every frame on a single UI isolate. When your build method or an event handler does synchronous work—parsing a 5 MB JSON response, for example—the UI isolate cannot produce frames. The symptom is not a crash; it's dropped frames and visible stutter during scrolling or animation. Moving that work to a background isolate keeps the UI isolate free to hit its 16 ms (or 8 ms on 120 Hz displays) frame budget.
How Dart isolates differ from threads
Unlike OS threads, Dart isolates have separate memory heaps and communicate only by message passing. No shared mutable state means no data races, but it also means you cannot pass closures, plugin handles, or BuildContext objects across the boundary. Flutter provides two convenient entry points: compute() (a helper that spawns a short-lived isolate) and Isolate.run() (the lower-level API introduced in Dart 2.17). Both accept a top-level or static function and return a Future with the result.
A reproducible worked example
Suppose your app fetches a large product catalog as JSON and converts it to typed model objects. The decode step is CPU-bound and blocks the UI isolate if run inline.
// lib/models/product.dart
class Product {
final int id;
final String name;
final double price;
Product({required this.id, required this.name, required this.price});
factory Product.fromJson(Map<String, dynamic> json) => Product(
id: json['id'] as int,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
);
}
// lib/services/catalog_parser.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
// Must be top-level or static so it can be sent across isolates.
List<Product> _parseCatalog(String jsonString) {
final List<dynamic> decoded = jsonDecode(jsonString) as List<dynamic>;
return decoded.map((e) => Product.fromJson(e as Map<String, dynamic>)).toList();
}
Future<List<Product>> parseCatalogOffUi(String jsonString) =
compute(_parseCatalog, jsonString);
Call it from the UI isolate:
final catalog = await parseCatalogOffUi(responseBody);
setState(() => _products = catalog);
Key constraints in the snippet:
- The callback
_parseCatalogis a top-level function (not a method on a class instance). - Its argument (
String) and return value (List<Product>) are sendable—primitives, lists, maps, and objects that implementSendable(Dart 3+) or are otherwise serializable by the isolate message mechanism. - The JSON string is fetched on the UI isolate, then handed off; asset loading (
rootBundle) or plugin calls inside the callback will fail.
When offloading hurts more than it helps
Spawning an isolate and copying the message payload has measurable overhead. For a 50 KB JSON payload, compute() can be slower than inline jsonDecode because the spawn-and-copy cost dominates. A practical rule of thumb: profile on your target hardware, but many teams start offloading at roughly 200–500 KB of input or when the decode takes more than 2–3 ms on a low-end device. For repeated jobs (e.g., paginated feeds), keep a single long-lived background isolate alive and send it work via a SendPort/ReceivePort pair instead of paying spawn cost every time.
Zero-copy option for large byte buffers
If you receive the response as Uint8List (e.g., from http or dio with responseType: ResponseType.bytes), you can avoid copying the buffer by wrapping it in TransferableTypedData and sending that to the isolate. The background isolate then decodes from the transferred bytes without an additional heap copy. This matters most for multi-megabyte payloads on memory-constrained devices.
Platform caveat: Flutter web
On Flutter web, compute() and Isolate.run() may execute on the main JavaScript thread depending on the browser's Web Worker support and the Flutter engine version. The API calls succeed, but the UI-blocking benefit you see on Android/iOS may not materialize. Test on each target platform in profile mode before assuming the offload helps.
How to verify the improvement on your device
- Run the app in profile mode (
flutter run --profile) on a representative low-end device. - Open DevTools > Performance / Timeline. Record a session that triggers the JSON load and decode.
- Note the UI thread frame build and raster times before the change. Look for frames exceeding the budget (red bars).
- Apply the offload, rebuild in profile mode, and record again under the same conditions.
- Compare frame-time distributions across multiple runs (not a single trace). A
Stopwatcharound the decode call can supplement the timeline view.
Checklist before shipping
- Callback is top-level or static; arguments and return values are sendable.
- No plugin calls,
rootBundleaccess, orBuildContextusage inside the isolate. pubspec.yamlSDK constraint matches the isolate API you use (e.g., Dart >=2.17 forIsolate.run()).- Tested on every target platform in scope, including web if you claim a benefit there.
Offloading JSON decoding is a well-understood pattern in Flutter, but the win is real only when the work is large enough to outweigh the isolate overhead. Measure on your hardware, respect the sendable-type boundary, and verify per platform.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.