skip to content

In a Flutter app, the order-history screen skips frames while a multi-megabyte JSON response is decoded into models; how do you move that work off the UI isolate?

level: seniorimportance: should knowfreq 46%

answer

  1. decode plus fromJson both block
  2. one top-level parse function
  3. compute or Isolate.run with the String
  4. dio decodes off-isolate from 50 KB
  5. web runs it on the same loop

basics

~20 s

Put jsonDecode and the fromJson mapping into one top-level function that takes the response body String, and run it with compute() or Isolate.run. Decoding alone off-isolate is not enough if fromJson still runs on the UI isolate.

solid answer

~40 s

Both steps are synchronous CPU work on the root isolate: `jsonDecode` over megabytes of text, then thousands of `Order.fromJson` calls. I move them together into a top-level function such as `List<Order> parseOrders(String body)` and call it with `compute(parseOrders, body)` or `Isolate.run(() => parseOrders(body))`, passing only the String so no client or context is captured. The resulting models come back to the UI isolate. With dio, the default `Dio` transformer is a `FusedTransformer` with `contentLengthIsolateThreshold: 50 * 1024`, so large bodies are already decoded in a background isolate, but only into maps; mapping `response.data` still runs `fromJson` on the UI isolate. On the web, `compute` runs the callback on the same event loop, so there the fix is smaller pages.

code

dart · 14 lines
dart
List<Order> parseOrders(String body) {
  final json = jsonDecode(body) as Map<String, dynamic>;
  return (json['orders'] as List<dynamic>)
      .map((e) => Order.fromJson(e as Map<String, dynamic>))
      .toList();
}

Future<List<Order>> fetchOrderHistory(Dio dio) async {
  final response = await dio.get<String>(
    '/orders/history',
    options: Options(responseType: ResponseType.plain),
  );
  return compute(parseOrders, response.data!, debugLabel: 'parseOrders');
}

go deeper

for a junior

Recall that decoding and mapping large JSON is synchronous work on the UI isolate and that compute() or Isolate.run moves it elsewhere.

for a middle

Explain why the parse function must be top-level, why only the String should be passed, and what dio's default transformer does and does not cover.

for a senior

Show how you confirm the jank in a profile, choose between dio's threshold and your own parse function, and handle the web, where compute does not help.

for a principal

Weigh client-side isolates against changing the API: paging, field selection and payload size budgets agreed with the backend.

## Where the frames go A Flutter app's Dart code, widgets and your networking callbacks included, runs on the **root isolate**, the one that also builds each frame. Awaiting an HTTP request does not block it, but what happens after the bytes arrive is ordinary synchronous work: 1. **Decoding** the text into maps and lists (`jsonDecode`). 2. **Mapping** those maps into models: thousands of `fromJson` calls, casts, `DateTime.parse`, list allocations. For a few kilobytes this is negligible. For a multi-megabyte order history it can take far longer than a frame's budget, and every frame that should have been drawn meanwhile is dropped, which the user sees as a frozen spinner or a stuttering scroll. The work has to run somewhere else. ## Moving both steps together The common mistake is to move only the decode. The pattern that works is **one function that does both**: - It is **top-level or static**, takes the raw **`String`** body, and returns the finished `List<Order>`. - It is run with Flutter's **`compute(parseOrders, body)`** or Dart's **`Isolate.run(() => parseOrders(body))`**; on native platforms the two are equivalent. - Only the String goes in and only models come out. A closure that captures a `BuildContext`, an HTTP client or an open socket either fails ("Illegal argument in isolate message") or drags unexpected state along. - The models return to the root isolate; `Isolate.run` hands the result over as the helper isolate exits, which in most cases avoids a deep copy. Plain model classes, lists and `DateTime`s are all sendable. How isolates, ports and `compute` work internally belongs to the concurrency topics; here the point is *what* to put inside the call. ## What dio already does | Setup | Where `jsonDecode` runs | Where `fromJson` runs | |---|---|---| | `http` + manual `jsonDecode` | root isolate | root isolate | | default `Dio()` | background isolate for bodies of at least 50 KB | root isolate | | `FusedTransformer.sync()` or threshold `-1` | root isolate | root isolate | | `responseType: ResponseType.plain` + `compute(parseOrders, ...)` | background isolate | background isolate | The default `Dio` instance uses `FusedTransformer(contentLengthIsolateThreshold: 50 * 1024)`. It measures the body (from `Content-Length`, or by collecting the bytes when that header is absent) and decodes large JSON in a background isolate. That removes the decode cost, but `response.data` arrives as maps, and your `.map(Order.fromJson)` then runs on the root isolate. For truly large payloads, request the body as a String with `ResponseType.plain` and run your own combined parse function, or keep dio's decode and move only the mapping step. ## The web is different On the web, `compute` runs the callback **on the current event loop**: it yields once, then does the work on the same thread, so it prevents nothing. Real parallelism there needs web workers, which Flutter does not wire up for you. The practical fixes on the web are the ones that help everywhere: - **Page the endpoint** (`limit` and a cursor) so no single response is huge. - **Trim the payload**: request only the fields the list shows, load details on tap. - **Parse incrementally** only if the backend can stream records. ## A checklist for a slow list screen When a screen that loads a large list janks, walk through it in order: 1. **Confirm** the long slice is decode or mapping, not layout or image decoding. 2. **Check the client**: with dio, is the default transformer still in place, and does the mapping run on `response.data` in the root isolate? 3. **Move the combined parse** into one top-level function run with `compute` or `Isolate.run`. 4. **Keep the result small**: map to the models the list needs, not every field in the payload. 5. **Ask for less data**: paging and field selection fix the web as well. 6. **Re-measure** in profile mode on a low-end device, where the gap is largest. ## Measuring before and after - Profile in **profile mode** with DevTools' performance view: the jank frames line up with a long `jsonDecode`/`fromJson` slice on the UI thread. - Name the helper isolate with `debugLabel` (for `compute`) or `debugName` (for `Isolate.run`) so its timeline events are recognisable. - Spawning an isolate has a cost too; for small responses staying on the root isolate is faster, which is exactly why dio uses a size threshold.

  • Why pass response.data rather than the whole Response object into compute?
    Everything the callback and its message reference is sent to the helper isolate. The String is cheap and always sendable; a `Response` drags its request options, headers and possibly interceptors or non-sendable objects along. Passing just the body keeps the message small and avoids 'Illegal argument in isolate message' failures.
  • When is moving the parse to another isolate not worth it?
    For small responses: starting an isolate and transferring data costs more than decoding a few kilobytes on the root isolate. That is why dio only switches to an isolate above a threshold, 50 KB by default. On the web it never helps, because `compute` runs on the same event loop there.

saying these in an interview costs you the question

  • Awaiting the HTTP call means parsing already happens on another thread
  • Dio decodes large responses off-isolate, so fromJson costs nothing
  • compute() gives the same speedup on Flutter web
  • Passing a closure that captures the BuildContext into Isolate.run is fine
  • Async code in Dart runs on a background thread automatically