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.

How this lesson proves things. Every code box lives in a real test file, 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

A street-food stall with one cook. The cook takes orders, cooks, and also has to hand a fresh plate to the window every 16 seconds so the queue sees something happening. If one customer orders a dish that takes a full minute of chopping, the cook cannot stop halfway — for that minute no plate reaches the window, and the people outside think the stall has frozen. Saying "I'll do it later" (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:

"I made it async, so it runs in the background." No. 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.
The raster thread from Step 12.3 turns each finished frame into pixels in parallel, so a frame can be late for two reasons: too much work on the main isolate (build, layout, paint and your code), or too much drawing work on the raster thread. Flutter DevTools shows both bars for every frame. This lesson is about the first kind: anything you run on the main isolate competes with frames for the same budget.
One main isolate runs your code and builds every frame. Budget ≈ 16.7 ms at 60 Hz, 8.3 ms at 120 Hz. Long CPU work on it drops frames, with or without async; waiting (network, files, timers) does not.

2. FutureBuilder done right

A restaurant buzzer. When you order, you get a buzzer (the Future): a promise that a meal will come. A waiter (FutureBuilder) watches your buzzer and changes the sign on your table: "cooking…" while it is silent, "ready: 3 plates" when it buzzes, "sorry, kitchen closed" if it buzzes with an error. If someone hands you a new buzzer every few minutes, the waiter forgets the old one and starts watching the new one — and your meal never seems to arrive.

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:

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:

Three versions of the same bug. (1) 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.
Inside, FutureBuilder is a 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.
Create the Future once (initState, 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 live cricket score on the radio. You switch the radio on (subscribe) and hear each new score as it happens. If two goals happen while you were looking away, you only see the newest score on the board. When you leave the room you switch the radio off (cancel), or it keeps playing to an empty room and wastes the battery. Some broadcasts allow many radios at once (broadcast streams); a private phone call allows only one listener (single-subscription streams).

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:

Creating the stream inside build. 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.
A 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.
StreamBuilder: subscribe on insert, rebuild per event (latest value only), cancel on removal. States waiting → active → done. Create the stream once; single-subscription streams allow one listener ever; cancel your own subscriptions in dispose.

4. Heavy work: compute and Isolate.run

Sending a task to the back kitchen. You write the recipe and the ingredients on a slip (the message) and pass it through a hatch. The back cook works with copies of what was on the slip — they cannot reach into your fridge. When the dish is done it comes back through the hatch. Passing the slip takes a moment, so it is only worth it for big jobs, and some things cannot go through the hatch at all (your phone line, your keys to the front door).

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:

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.

Not every slow thing belongs in an isolate. Starting an isolate and copying the input and output costs time and memory; for a job of a millisecond or two it costs more than it saves, and for waiting (network, disk) it saves nothing at all, because waiting does not block the main isolate. Use an isolate for CPU work that would take a large part of a frame (parsing a few megabytes of JSON, image processing, encryption, big sorts). If the work can be split, the player's third suggestion shows another option: small chunks on the main isolate, with an await between them so frames fit in the gaps (question q14).
Isolates started from the same program form an isolate group: they share the compiled code, which makes starting one much cheaper than starting a whole new program, but each still has its own heap and garbage collector. For many small jobs, a long-lived worker isolate (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.
CPU-heavy work → 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

Two offices that speak different languages, connected by a pneumatic tube. You write a request on a standard form ("method: getBatteryLevel"), roll it into a capsule (encode it to bytes) and send it down the tube with a channel name written on it, so the right desk receives it. The other office unrolls it, does the job, and sends back a reply capsule: a result, an error slip, or — if no desk has that name — nothing at all. You do not stand at the tube waiting; you carry on working and the reply arrives later.

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:

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):

Two different errors. A 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).
Threads. On the native side, channel handlers run on the platform's main thread, and calls into Flutter must also be made from the main thread. A handler that does slow work there freezes the native UI — and, since Flutter 3.29 on Android and iOS, Flutter's frames too, because Dart now shares that thread. Do slow native work on a background thread and send the result back on the main thread, or use the task queue option of the channel API (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).
MethodChannel = request/reply by method name; EventChannel = native → Dart stream; BasicMessageChannel = raw messages. Values are encoded by a codec (null, bool, int, double, String, typed lists, List, Map). PlatformException = native failed; MissingPluginException = nobody answered. Native handlers run on the platform main thread: keep them short.

6. How to answer the three classic interview questions

A good answer is a short story with a cause, a mechanism and a fix: "the patient has a fever (symptom) because of an infection (cause); here is how the infection spreads (mechanism); here is the medicine and how we check it worked (fix and proof)".
QuestionA 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.
Weak answers interviewers hear every day: "use 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).
Follow-ups to expect: "How would you show progress from an isolate?" (a long-lived isolate sending messages through a 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).
Symptom → mechanism (one isolate, frames, rebuilds, codec) → fix → a concrete number or error message that proves it.

Quiz

Interview questions

Cheat sheet

ConceptFact
Main isolateRuns all your Dart UI code plus build, layout and paint of every frame. One thing at a time.
Frame budget1000 ÷ 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 / awaitSame isolate, later. Runs synchronously until the first await. Fine for waiting, useless for CPU work.
ConnectionStatenone (no Future/Stream) · waiting · active (streams only) · done.
AsyncSnapshothasData = data != null; hasError; check error, then data, then loading.
FutureBuilderCreate the Future once (initState / late final / from outside). New Future object → back to waiting, old data kept, old result ignored.
StreamBuilderSubscribes on insert, cancels on removal, shows the latest event per frame. initialData before the first event. Error → data becomes null.
StreamsSingle-subscription: one listener ever, buffers. Broadcast: many listeners, drops events with no listener. Cancel your own subscriptions in dispose.
Isolate.run / computeOne-shot background isolate; input copied in, result back without a second copy, errors re-thrown. compute on web = same thread.
Cannot crossReceivePort, sockets, native pointers, framework objects; a closure that mentions a field captures this.
ChannelsMethodChannel (calls both ways), EventChannel (native → Dart stream), BasicMessageChannel (messages). All async.
Codec typesnull, bool, int, double, String, Uint8List, Int32List, Int64List, Float32List, Float64List, List, Map. Not DateTime, Set or your classes.
ErrorsPlatformException(code, message, details) = native failed. MissingPluginException = nobody answered (no handler, not implemented, typo, plugin not rebuilt).
ThreadsNative handlers on the platform main thread; keep them short or use a background task queue. Pigeon = generated type-safe channel code.
TestingFake clock: pump(duration); deliver a finished Future/Stream event with pump(Duration.zero); real isolates in runAsync; native side with setMockMethodCallHandler.