skip to content

Isolate Offloading

Parsing a large payload or processing an image on the main isolate stalls frames, so Flutter code hands it to compute(). Interviewers ask when that pays off and what a background isolate can reach.

part ofFlutteroverview, primer and where to startread it →
on this pageshow

explore

questions

6

In Flutter, what does the compute() function do, and how would you use it to parse a large JSON file without freezing the UI?

level: juniorimportance: must knowfreq 66%

answer

  1. one isolate runs build, layout and your Dart
  2. package:flutter/foundation.dart
  3. a callback plus one message
  4. returns a Future of the result
  5. load the asset first, parse inside

basics

~20 s

compute(callback, message) runs callback(message) on a new background isolate and returns a Future with the result, so heavy CPU work such as jsonDecode plus model mapping no longer blocks the main isolate that builds and schedules frames.

solid answer

~40 s

All of a Flutter app's Dart code, including `build`, layout and your own logic, runs on the **main isolate** by default, so a long synchronous job like decoding a 20 MB JSON file stalls frames. `compute()` from `package:flutter/foundation.dart` takes a callback and a single message, runs `callback(message)` on a freshly spawned isolate and returns a `Future` that completes with the callback's result. You read the file on the main isolate (for example with `rootBundle.loadString`, which is not available in spawned isolates), then `await compute(parseDepartures, body)` where `parseDepartures` is a top-level function doing the decode and the mapping into models. On Android, iOS and desktop it is equivalent to `Isolate.run(() => callback(message))`; while it runs, the UI keeps animating.

code

dart · 29 lines
dart
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

class Departure {
  const Departure({required this.stopId, required this.time});

  factory Departure.fromJson(Map<String, Object?> json) => Departure(
        stopId: json['stopId']! as String,
        time: json['time']! as String,
      );

  final String stopId;
  final String time;
}

// Top-level: runs on the spawned isolate and captures nothing.
List<Departure> parseDepartures(String body) {
  final List<Object?> raw = jsonDecode(body) as List<Object?>;
  return raw.cast<Map<String, Object?>>().map(Departure.fromJson).toList();
}

Future<List<Departure>> loadSchedule() async {
  // Asset I/O stays on the main isolate.
  final String body = await rootBundle.loadString('assets/schedule.json');
  // The CPU-heavy decode and mapping run on a background isolate.
  return compute(parseDepartures, body, debugLabel: 'parseDepartures');
}

go deeper

for a junior

Know that long synchronous Dart work freezes the UI and that compute(callback, message) moves it to another isolate and returns a Future.

for a middle

Explain what stays on the main isolate (asset loading, setState) and what goes into the callback, and why the callback is top-level.

for a senior

Decide from profiling whether the work is long enough to justify an isolate, and structure the result so it is cheap to hand back.

for a principal

Set a team rule for where parsing and heavy transforms live so screens do not rediscover jank one by one.

## Why heavy parsing freezes a Flutter app A Flutter app runs its Dart code on one **isolate**, the **main isolate**, by default. An isolate is Dart's unit of concurrency: it has its own memory and its own event loop, and it runs one piece of Dart code at a time. On the main isolate that code includes the framework's work for every frame (building widgets, layout, painting instructions) as well as your app logic. If a single synchronous job, such as `jsonDecode` on a 20 MB transit-schedule file followed by turning every entry into a model object, takes several hundred milliseconds, nothing else on the main isolate can run in that time. No frame is produced, animations stop and taps queue up. Marking the function `async` does not change that: the decoding itself is still one long synchronous stretch on the same isolate. ## What compute() does `compute` lives in `package:flutter/foundation.dart`: - **Signature:** `Future<R> compute<M, R>(ComputeCallback<M, R> callback, M message, {String? debugLabel})`, where the callback has the shape `FutureOr<R> Function(M message)`. - **On Android, iOS and desktop** it spawns a new isolate, runs `callback(message)` there, sends the result back and shuts the isolate down. The docs state it is equivalent to `await Isolate.run(() => callback(message))`. - **On the web** it runs the callback on the same event loop, because Dart on the web has no isolates (a separate topic, but worth knowing before relying on it). - **`debugLabel`** names the spawned isolate, so its timeline events are easy to find while profiling. The call returns immediately with a `Future`; the main isolate keeps producing frames while the other isolate works, and the `await` resumes once the result arrives. ## Using it for a large JSON file 1. **Load on the main isolate.** Asset loading through `rootBundle` is tied to the main isolate, so read the file there. Reading is asynchronous I/O and does not block frames. 2. **Put all CPU-heavy steps in one callback.** Decoding, casting and mapping to model classes all belong inside the function you hand to `compute`. 3. **Make the callback a top-level or static function.** It then carries no hidden state to the new isolate, only the message. 4. **Return plain data.** A list of model objects is fine; objects such as a `Future` or an HTTP response object are not good return values. 5. **Update the UI after the await**, checking `mounted` first if you are inside a `State`. ## What stays on the main isolate | Step | Where it runs | |---|---| | `rootBundle.loadString(...)` | main isolate (async I/O) | | `jsonDecode` and `fromJson` mapping | the `compute` isolate | | Showing a progress indicator | main isolate, keeps animating | | `setState` with the parsed list | main isolate | ## Common mistakes - Wrapping the work in `Future(() => ...)` or `async` and expecting it to move off the main isolate. - Calling `compute` for tiny jobs where spawning costs more than the work. - Trying to touch widgets, `BuildContext` or `rootBundle` inside the callback: all UI work belongs to the main isolate.

  • In Flutter, what happens if the compute() callback throws?
    The exception is carried back and the `Future` returned by `compute` completes with that error, so you handle it with `try`/`catch` around the `await` on the main isolate like any other asynchronous failure. The spawned isolate is gone either way.
  • Why can't the compute() callback call rootBundle.loadString itself?
    Flutter's asset bundle and all UI services are tied to the main isolate; the Flutter docs state you cannot access assets through `rootBundle` in spawned isolates. Load the string first, then pass it as the message.

The main isolate is a chef who must also plate every dish on time; compute() hires a prep cook in a separate kitchen, hands over the raw ingredients through a hatch and gets back chopped vegetables, so plating never stops.

saying these in an interview costs you the question

  • Marking the parse function async moves it off the main isolate.
  • compute() runs the callback on a thread that shares the main isolate's memory.
  • You can call setState or read BuildContext inside the compute() callback.
  • compute() blocks the caller until the result is ready.
  • compute() creates a background isolate on the web too.
open as a page

In a Flutter web build, what does compute() actually do, and why can a heavy parse still freeze the page?

level: middleimportance: should knowfreq 30%

basics

~20 s

On the web, compute() yields once and then runs the callback directly on the same event loop, because Dart web apps have no isolates. The API stays portable, but a long parse still blocks the page.

open as a page

In Flutter, when does moving a task into compute() fail to pay off, given the cost of spawning an isolate and moving data?

level: middleimportance: should knowfreq 44%

basics

~20 s

compute() spawns and tears down a new isolate on every call and moves the message and result across, so it only pays off for CPU work longer than a few milliseconds. Tiny, per-item or I/O-bound tasks gain nothing or get slower.

open as a page

In Flutter, why is compute() usually handed a top-level or static function rather than a closure or an instance method of a State?

level: middleimportance: should knowfreq 42%

basics

~20 s

compute() sends the callback itself to the new isolate along with the message. A top-level or static function captures nothing, while a closure or instance-method tear-off drags its captured context, often the whole State, along, which is costly or fails to send.

open as a page

A Flutter app freezes for about a second after loading a 20 MB transit-schedule file; how do you confirm main-isolate CPU work is the cause and fix it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Profile on a device: long UI bars with short raster bars, plus a CPU profile dominated by jsonDecode and fromJson on the main isolate, confirm it. Move decode, mapping and indexing into one compute() call, keep the file read outside, and re-profile.

open as a page

In Flutter, why does a plugin call fail inside a background isolate by default, and how do RootIsolateToken and BackgroundIsolateBinaryMessenger fix it?

level: seniorimportance: nice to knowfreq 24%

basics

~10 s

Platform channels need a messenger tied to the root isolate, which a spawned isolate lacks. Pass RootIsolateToken.instance from the root isolate and call BackgroundIsolateBinaryMessenger.ensureInitialized(token) in the background isolate; channels then work for request/response calls.

open as a page