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?
answer
- profile mode on a real device
- UI bars long, raster bars short
- CPU profiler, Bottom up view
- I/O versus decode versus mapping
- one compute() with a debugLabel
basics
~20 sProfile 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.
solid answer
~40 sReproduce in **profile mode** on a real device and record the load in the DevTools Performance view: a run of frames with very long **UI** bars and short **raster** bars, or a gap where no frames arrive, points at Dart work on the main isolate rather than rendering. Record the same window in the **CPU profiler** and read the **Bottom up** view: if `jsonDecode`, your `fromJson` constructors and any sorting or indexing dominate, that is the freeze. Reading the file itself is asynchronous I/O and rarely the culprit. The fix is to keep `rootBundle.loadString` (or the file read) on the main isolate and move every CPU step into one top-level function run with `compute(..., debugLabel: 'parseSchedule')`. Re-profile: UI bars should stay under budget during the load, and the spinner should keep animating.
code
dart · 25 linesimport 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
typedef StopIndex = Map<String, List<String>>;
// All CPU work in one top-level function: decode, map, group.
StopIndex buildStopIndex(String body) {
final List<Object?> raw = jsonDecode(body) as List<Object?>;
final StopIndex byStop = <String, List<String>>{};
for (final Map<String, Object?> row in raw.cast<Map<String, Object?>>()) {
final String stop = row['stopId']! as String;
(byStop[stop] ??= <String>[]).add(row['time']! as String);
}
for (final List<String> times in byStop.values) {
times.sort();
}
return byStop;
}
Future<StopIndex> loadStopIndex() async {
final String body = await rootBundle.loadString('assets/schedule.json');
return compute(buildStopIndex, body, debugLabel: 'parseSchedule');
}go deeper
Recognize that a frozen spinner during a big parse means the main isolate is busy, and that compute() is the usual fix.
Read UI versus raster bars, find the heavy functions in a CPU profile, and separate I/O time from decode time.
Run the full loop, profile, move all CPU steps into one call, shrink the result, re-profile, and know when caching or a worker isolate is better.
Question the payload: whether the device should parse 20 MB at all, or whether the backend or the build step should ship data already shaped for the screen.
## The symptom After the app loads a 20 MB transit-schedule file, the screen stops responding for roughly a second: the loading spinner freezes, taps are ignored, then everything jumps to the finished state. That pattern, where **animation stops rather than stutters**, is typical of one long synchronous job on the **main isolate**, the isolate that runs all widget building and app Dart code. ## Step 1: measure in the right mode 1. **Use profile mode on a physical device.** Debug builds run unoptimized code and exaggerate Dart costs, so the numbers there mislead. 2. **Open the DevTools Performance view** and trigger the load. 3. **Read the frame chart.** Each frame has a UI bar (Dart work on the main isolate) and a raster bar (rendering). Long UI bars with short raster bars, or a stretch with no frames at all, say the main isolate was busy. Long raster bars would point at rendering cost instead. ## Step 2: find the functions 1. **Record a CPU profile** in the DevTools CPU profiler across the same load. 2. **Open the Bottom up view**, which lists the functions where samples landed most, with their callers underneath. 3. **Look for the parse chain:** `jsonDecode` and the JSON decoder, your `fromJson` factories, `map`/`toList`, and any sort or grouping that builds lookup tables. 4. **Check the I/O share.** Reading the file with `rootBundle.loadString` or `File.readAsString` is asynchronous; time spent waiting on disk does not block frames. Typical outcome: most of the second is decoding plus object construction, all on the main isolate. ## Step 3: move the CPU work | Keep on the main isolate | Move into the compute() callback | |---|---| | Reading the asset or file (async I/O) | `jsonDecode` of the whole string | | Showing and animating a progress indicator | Mapping to `Departure` objects | | `setState` with the final result | Sorting, grouping, building indexes | Implementation rules: - **One call, not many.** Put decode, mapping and indexing in a single top-level function and call `compute` once. - **Send the raw `String`.** It is immutable and cheap to hand over; decoded maps would have to be copied. - **Return only what the screen needs**, such as today's departures grouped by stop, rather than every raw record. - **Name it.** `debugLabel: 'parseSchedule'` makes the background isolate recognizable in timelines. ## Step 4: verify Re-run the same profile. The main isolate's UI bars should now stay within the frame budget while the file loads, the spinner should animate continuously, and the CPU work should appear under the background isolate instead. Total time to data may be about the same or slightly longer because of the spawn; the win is that the app stays responsive. ## If it is still slow - **The result is enormous:** return a smaller, pre-grouped structure. - **The same parse happens on every launch:** cache the processed result so later launches skip the parse. - **It must run repeatedly:** a long-lived worker isolate beats spawning with `compute` each time. - **The app also ships to the web:** `compute` runs on the same event loop there, so chunk the work or shrink the payload on the server.
- Why can the total time until the schedule appears get slightly longer after moving the parse to compute()?Spawning an isolate and handing over the input add a fixed cost on top of the same parse work. The goal is not a faster parse but a responsive UI while it runs. If time to data matters too, shrink the payload or cache the processed result.
- The UI bars are fine but the app still freezes during the load; what else could it be?Check the raster bars and the moment the result is shown: building thousands of widgets at once in a non-lazy list, or a huge first layout, can stall the frame after the data arrives. That is rebuild or layout cost, fixed with a lazy list builder or paging, not with another isolate.
saying these in an interview costs you the question
- Debug-mode timings are good enough to size this problem.
- Reading the 20 MB file from disk is what blocks the frames.
- Making loadSchedule async already moved the parse to the background.
- Long raster bars during the load prove the parse is the problem.
- One compute() per JSON record spreads the load best.