Async, Isolates & Platform Channels in Flutter
By the end of this lesson you will be able to explain exactly why a Flutter app freezes even though "everything is async", draw a frame timeline that shows which frames get dropped, use FutureBuilder and StreamBuilder without the classic "it keeps reloading" bug, move heavy work to another isolate with compute or Isolate.run (and know what is allowed to cross), and walk an interviewer through a MethodChannel call to Android or iOS and back — including both kinds of errors. Every printed value on this page comes from a real Flutter test.
verify_flutter/test/f04_test.dart. A widget test builds widgets on a pretend screen with no window, and runs on a fake clock: tester.pump(const Duration(seconds: 1)) moves time forward one second instantly, so a one-second network call takes no real time. Real isolates and real timers need real time, so those boxes run in a plain test() or inside tester.runAsync(...). The // => lines under each box are what the test really printed. This lesson builds on the Dart side track: Step S.1 (the event loop, Future, Stream, microtasks) and Step S.2 (isolates in plain Dart). Here we look at the same ideas through Flutter's eyes: frames, widgets and native code.1. One isolate draws every frame
async) does not help: later, it is still the same cook doing a minute of chopping. The fix is a second cook in the back kitchen (another isolate) who chops while the first one keeps the window busy.An isolate is Dart's unit of running code: its own memory and one thread of execution that runs one thing at a time, driven by its event loop (a queue of jobs — taps, timers, finished network calls — handled one after another, see Step S.1). Your Flutter app starts in one isolate, the main isolate (also called the UI isolate). It runs all of your Dart code: event handlers, setState, and the build, layout and paint steps of every frame from Step 12.3. Since Flutter 3.29, on Android and iOS this isolate runs on the platform's own main thread; on other platforms it has its own "UI thread". Either way: one thread for all your Dart UI work.
The screen asks for a new picture on every vsync (the display's "ready for the next picture" signal): 60 times a second on a 60 Hz screen, so each frame has a budget of 1000 ÷ 60 ≈ 16.7 ms; on a 120 Hz screen it is about 8.3 ms (question q2 prints the table). If the main isolate is busy when a vsync arrives, no new frame is produced and the screen shows the old picture again. A missed or late frame is called jank: scrolling stutters, an animation jumps.
The test below uses a 16 ms repeating timer as a pretend vsync and measures the longest pause between its ticks while 120 ms of pure CPU work runs — plainly, after an await, and inside Isolate.run:
async and await only decide when a piece of code runs on the same isolate; they never move it to another thread. An async function even runs its first part immediately, inside the call, until its first await (question q6 measures it). Waiting for a network reply or a file is fine with await — the isolate is idle and draws frames while it waits. Computing something for a long time (parsing a big JSON, resizing an image, sorting 100 000 items) is the problem: it holds the one thread.async; waiting (network, files, timers) does not.2. FutureBuilder done right
A Future<T> is a value that will be ready later (or an error). FutureBuilder<T> is a widget that listens to one Future and rebuilds when it finishes. Its builder gets an AsyncSnapshot<T>, a small read-only report with:
connectionState, one of four values:none(there is no Future, it isnull),waiting(subscribed, no result yet),active(only streams use it, section 3) anddone(the Future finished).dataandhasData. Careful:hasDatasimply meansdata != null, so a Future that finishes withnullgivesdonewithhasData false(question q4).errorandhasError: set when the Future failed.
The test below builds a screen that loads an order count. The Future is created once, when the State object is created (late final runs its right side the first time the field is read, here in the first build) — then the builder only reads the snapshot:
The classic bug: a new Future on every build
Remember from Step 12.2 that build can run very often: a parent's setState, the keyboard opening, a theme change, an animation above you. If build creates the Future (future: fetchOrderCount()), every build starts a new network call, and FutureBuilder — seeing a different Future object — drops the old one and starts waiting again. Below, a parent rebuilds every 400 ms while the fetch needs 1000 ms:
future: http.get(...) or future: repo.load() written directly in build. (2) Creating the Future in didChangeDependencies without a guard (it runs again whenever an inherited widget it reads changes). (3) A Future that is created once, but the builder shows a spinner whenever connectionState == waiting: when the Future object does change on purpose (pull to refresh), FutureBuilder goes back to waiting but keeps the old data (question q8 prints done 1 → waiting 1 → done 2), so check hasData first if you want to keep showing the old list while refreshing. The fix for (1) and (2): create the Future in initState (or a late final field), or receive it from a parent or a state-management object, and only read it in build.StatefulWidget. In initState it calls future.then(...) and remembers a fresh "callback identity" object. When the widget is updated with a different Future (!=), it forgets the old identity, sets the snapshot to waiting (keeping old data and error) and subscribes to the new one; a late result from an old Future arrives with a stale identity and is ignored. That is why the buggy screen above never shows data while the parent keeps rebuilding — five network calls are made, four results are thrown away. A Future cannot be cancelled in Dart; the work still happens, only the result is ignored.late final, or from outside), read it in build. Check hasError, then hasData, then show loading. FutureBuilder goes none → waiting → done (never active).3. StreamBuilder and subscriptions
A Stream<T> delivers many values over time, then maybe an error or two, then maybe "done". StreamBuilder<T> subscribes (calls stream.listen) when it is inserted into the tree, rebuilds on every event, and cancels the subscription when it is removed. Its snapshot goes waiting → active (after the first event) → done (when the stream closes). initialData is what the builder sees before the first event — handy for a cached value:
Two details from that output matter in real apps. Events 102 and 103 arrived before the next frame, and the builder only ever saw 103: StreamBuilder shows the latest value, it is not an event log — if you need every event (a chat history), collect them into a list yourself. And an error event replaces the data with null. When the StreamBuilder leaves the screen while the stream is still open, it cancels its subscription on its own; the stream's owner hears about it through onCancel:
stream: controller.stream.map(...) or stream: repo.watch() in build gives StreamBuilder a new Stream object on every rebuild. It cancels and re-subscribes each time — and a single-subscription stream can be listened to only once, ever, even after the first listener cancelled, so the second build crashes with Bad state: Stream has already been listened to. (question q15 reproduces it). Create the stream once, like the Future in section 2. Second trap: if you call stream.listen(...) in a State, keep the StreamSubscription and call cancel() in dispose() (question q18) — only StreamBuilder does that for you.StreamController() makes a single-subscription stream: it buffers events until someone listens, then delivers them to exactly that one listener. StreamController.broadcast() allows any number of listeners but drops events added while nobody listens (question q10). Platform event streams such as EventChannel.receiveBroadcastStream() (section 5) are broadcast streams: the native side starts producing when the first Dart listener arrives and is told to stop when the last one cancels.dispose.4. Heavy work: compute and Isolate.run
To keep frames smooth, CPU-heavy work must run on another isolate. Each isolate has its own memory, so nothing is shared by accident; values are sent as messages, and the receiving isolate gets a copy (see Step S.2 for the plain-Dart details). Flutter apps usually use one of two one-shot helpers:
Isolate.run(() => work())(plain Dart,dart:isolate): starts a new isolate, runs the function there, sends the result back and shuts the isolate down. The function's captured variables are copied over; the result comes back withIsolate.exit, which hands it over without a second copy. An error thrown inside is re-thrown in yourawait.compute(work, argument)(Flutter,package:flutter/foundation.dart): the same idea with one argument and one result. On Android, iOS and desktop it runs the work on another isolate; on the web, where Dart has no isolates, it runs it on the same thread, so it does not help there.
What can cross? Numbers, strings, booleans, null, lists, maps, sets, records, typed data and most of your own plain objects are copied. Objects that are tied to the isolate or to the operating system cannot cross: a ReceivePort, a socket, native pointers, and some objects inside the Flutter framework. The easy way to hit this: a closure inside a State that mentions a field (prices really means this.prices) captures this — the whole State, and through it the widget tree:
Try it: which frames get dropped?
Describe a burst of work and decide where each job runs: ui (the main isolate) or bg (its own background isolate, like Isolate.run). The player runs a JavaScript copy of the Dart model shown in its code panel (interview question q17); the test file runs the same model on every example on this page. The model's rules are deliberately simple: a vsync every budget ms; each frame needs frame ms of main-isolate time; when the main isolate is free it draws the current slot's frame first, then runs the next UI job; a job, once started, cannot be interrupted; a frame that ends after the next vsync is late; a slot that passes with no frame at all is dropped.
await between them so frames fit in the gaps (question q14).Isolate.spawn plus a pair of ports, Step S.2) avoids paying the start-up cost each time. For big byte buffers, TransferableTypedData moves the bytes to the other isolate instead of copying them (question q27). And a background isolate cannot touch widgets or call most plugins directly — it computes; the main isolate shows the result.Isolate.run or compute (not on the web). Inputs are copied in, the result comes back, errors are re-thrown. Copy what you need into local variables first so the closure does not capture this. Waiting needs no isolate.5. Platform channels: talking to native code
Some things only the operating system can do: read the battery, open the camera, use a payment SDK written in Kotlin or Swift. Flutter talks to that native (platform) code through platform channels. There are three kinds:
MethodChannel: calls in both directions. Dart callsinvokeMethod('name', arguments)and gets aFuturewith the reply; native code registers a handler. Native code can call Dart too: Dart registerssetMethodCallHandler(question q23).EventChannel: aStreamof events from native code to Dart (sensor readings, connectivity changes). Listening starts the native side; cancelling stops it (question q24).BasicMessageChannel: plain messages in both directions with a codec of your choice, no method names.
Every message is turned into bytes by a codec. The default, StandardMessageCodec (and StandardMethodCodec for method calls), carries null, bool, int, double, String, the typed lists Uint8List, Int32List, Int64List, Float32List, Float64List, and Lists and Maps of those. In a test there is no Android or iOS, so the test plays the native side with setMockMethodCallHandler:
The other two channel kinds, with the native side mocked the same way:
And what the codec does to each type — note that null is sent as no message at all, and a DateTime is refused (send millisecondsSinceEpoch instead; question q25 converts whole objects):
PlatformException means native code ran and reported a failure (result.error("UNAVAILABLE", ...) on Android, a FlutterError on iOS); it has a code, a message and optional details. A MissingPluginException means nobody answered: no handler is registered for that channel on this platform, the handler said "not implemented", the method name has a typo, or — very common — you added a plugin and only hot-restarted instead of fully rebuilding the app, so its native half is not in the build yet. Catch both, and test both (question q20).makeBackgroundTaskQueue) on Android and iOS so the handler itself runs in the background. On the Dart side nothing blocks: invokeMethod returns a Future at once (question q28). Pigeon is a code generator package: you describe the API once as a Dart interface, and it writes matching Dart, Kotlin/Java and Swift/Objective-C classes that talk over channels for you — so method names and argument types are checked by the compiler instead of failing at run time (question q29).6. How to answer the three classic interview questions
| Question | A strong answer, in order |
|---|---|
| "Why does my app freeze even though I used async?" | (1) All Dart UI code and every frame's build, layout and paint run on one main isolate. (2) async/await only schedules work on that same isolate; it never adds a thread, and an async function even runs synchronously until its first await. (3) So a long CPU task (parsing, image work) holds the thread and the vsyncs that arrive meanwhile get no frame: dropped frames, jank. (4) Fix: Isolate.run/compute for CPU work, or split it into chunks with an await between them. Proof: DevTools frame chart, or the timeline on this page (a 60 ms job drops 3 of 5 frames on the main isolate and none in the background). |
| "My FutureBuilder keeps reloading." | (1) The Future is created inside build. (2) build runs on every parent rebuild, keyboard change or animation, so every build starts a new request. (3) FutureBuilder sees a different Future, goes back to waiting and ignores the old result. (4) Fix: create it once in initState/a late final field or get it from a state object; only read it in build. Proof: count the requests — 5 rebuilds made 5 requests before the fix, 1 after. |
| "How do you call native code?" | (1) A MethodChannel with a unique name on both sides. (2) Dart calls invokeMethod, which returns a Future; arguments go through StandardMessageCodec (basic types, lists, maps). (3) Native code handles the call on the platform main thread and replies with success, error or not-implemented. (4) Dart gets the value, a PlatformException, or a MissingPluginException. Mention EventChannel for streams, Pigeon for type-safe generated code, and that you test it with setMockMethodCallHandler. |
Future.delayed to make it not block" (it only delays the same work), "wrap it in compute" for a network call (waiting does not block; you just paid for an isolate), "FutureBuilder is broken, use setState instead" (the bug was where the Future was created), and "channels are synchronous" (every call returns a Future).SendPort, Step S.2), "How do you test a plugin call?" (setMockMethodCallHandler, as in every channel box here), and "What is a microtask?" (a tiny job that runs before the next event, Step S.1).Quiz
Interview questions
Cheat sheet
| Concept | Fact |
|---|---|
| Main isolate | Runs all your Dart UI code plus build, layout and paint of every frame. One thing at a time. |
| Frame budget | 1000 ÷ refresh rate: 16.7 ms at 60 Hz, 11.1 ms at 90 Hz, 8.3 ms at 120 Hz. Missed or late frame = jank. |
async / await | Same isolate, later. Runs synchronously until the first await. Fine for waiting, useless for CPU work. |
| ConnectionState | none (no Future/Stream) · waiting · active (streams only) · done. |
| AsyncSnapshot | hasData = data != null; hasError; check error, then data, then loading. |
| FutureBuilder | Create the Future once (initState / late final / from outside). New Future object → back to waiting, old data kept, old result ignored. |
| StreamBuilder | Subscribes on insert, cancels on removal, shows the latest event per frame. initialData before the first event. Error → data becomes null. |
| Streams | Single-subscription: one listener ever, buffers. Broadcast: many listeners, drops events with no listener. Cancel your own subscriptions in dispose. |
Isolate.run / compute | One-shot background isolate; input copied in, result back without a second copy, errors re-thrown. compute on web = same thread. |
| Cannot cross | ReceivePort, sockets, native pointers, framework objects; a closure that mentions a field captures this. |
| Channels | MethodChannel (calls both ways), EventChannel (native → Dart stream), BasicMessageChannel (messages). All async. |
| Codec types | null, bool, int, double, String, Uint8List, Int32List, Int64List, Float32List, Float64List, List, Map. Not DateTime, Set or your classes. |
| Errors | PlatformException(code, message, details) = native failed. MissingPluginException = nobody answered (no handler, not implemented, typo, plugin not rebuilt). |
| Threads | Native handlers on the platform main thread; keep them short or use a background task queue. Pigeon = generated type-safe channel code. |
| Testing | Fake clock: pump(duration); deliver a finished Future/Stream event with pump(Duration.zero); real isolates in runAsync; native side with setMockMethodCallHandler. |