Async and isolates
How Dart's event loop schedules work, how to use futures and streams without leaking, and when moving work to another isolate actually helps.
Contents
- The event loop
- Futures
- Streams
- Isolates
- Choosing between async and isolates
- Plugins from background isolates
- Pitfalls
The event loop
A Dart isolate runs a single thread with two queues. After the current synchronous block finishes, the loop drains all microtasks, then takes one event from the event queue, and repeats.
run sync code -> drain microtask queue -> one event -> drain microtasks -> ...
- Microtasks come from
scheduleMicrotaskand from continuations of already completed futures. They run before any event, so a microtask that schedules another microtask can starve the event queue — including rendering. - Events come from timers, I/O completion, gestures, and platform channels.
The critical consequence: await never blocks a thread, but a long
synchronous computation does. If a function loops for 40 ms without yielding,
that frame is late no matter how many async keywords surround it.
Futures
Future<User> loadUser(String id) async {
final response = await api.get('/users/$id');
return User.fromJson(response.data);
}
Run independent work concurrently rather than sequentially:
// Sequential — total time is the sum
final user = await loadUser(id);
final orders = await loadOrders(id);
// Concurrent — total time is the slowest
final (user, orders) = await (loadUser(id), loadOrders(id)).wait;
// Same idea with a list
final results = await Future.wait([loadUser(id), loadOrders(id)]);
The record-based .wait returns a typed tuple, which avoids the casting that
Future.wait on mixed types requires. Both fail as soon as one future throws;
pass eagerError: false to Future.wait if you want the rest to complete.
Other useful constructors:
await future.timeout(const Duration(seconds: 10));
await Future.any([primary, fallback]);
await Future<void>.delayed(Duration.zero); // yield to the event loop
unawaited(analytics.log('opened')); // intentionally not awaited
unawaited (from dart:async) documents that you are deliberately not waiting
and satisfies the unawaited_futures lint. Any future you drop silently swallows
its errors — attach a catchError if it can fail.
Streams
A stream delivers zero or more values over time.
Stream<int> ticks() async* {
var i = 0;
while (true) {
await Future<void>.delayed(const Duration(seconds: 1));
yield i++;
}
}
Single-subscription streams (the default) allow exactly one listener. A broadcast stream allows many, but drops events that arrive with no listener attached.
final controller = StreamController<Message>.broadcast();
In widgets, prefer StreamBuilder so subscription and disposal are handled:
StreamBuilder<int>(
stream: _ticks,
initialData: 0,
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(hasError: true, :final error) => Text('Failed: $error'),
AsyncSnapshot(connectionState: ConnectionState.waiting) =>
const CircularProgressIndicator(),
AsyncSnapshot(:final data?) => Text('$data'),
_ => const SizedBox.shrink(),
},
)
When you subscribe manually, cancel in dispose:
StreamSubscription<Message>? _sub;
@override
void initState() {
super.initState();
_sub = messages.listen(_onMessage, onError: _onError);
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
Useful transforms: map, where, asyncMap, distinct, take, debounce
(via stream_transform), handleError. Remember that most transforms return a
new stream that must itself be listened to.
Isolates
Isolates have separate memory and communicate by message passing, so there are no shared-memory data races and no locks. They are for CPU-bound work.
The simplest form runs a function on a new isolate and returns its result:
final decoded = await Isolate.run(() => _parseLargePayload(jsonText));
compute from package:flutter/foundation.dart is the Flutter-flavoured
equivalent, and takes a top-level or static function plus one argument:
final products = await compute(_parseProducts, jsonText);
List<Product> _parseProducts(String source) {
final list = jsonDecode(source) as List<Object?>;
return list.map((e) => Product.fromJson(e! as Map<String, Object?>)).toList();
}
For a long-lived worker that receives many messages, spawn it and keep the ports:
final response = ReceivePort();
await Isolate.spawn(_worker, response.sendPort);
final sendPort = await response.first as SendPort;
final reply = ReceivePort();
sendPort.send((job: payload, reply: reply.sendPort));
final result = await reply.first;
// Later
isolate.kill(priority: Isolate.immediate);
response.close();
void _worker(SendPort initial) {
final receive = ReceivePort();
initial.send(receive.sendPort);
receive.listen((message) {
final (:job, :reply) = message as ({String job, SendPort reply});
reply.send(_expensive(job));
});
}
Only sendable values cross the boundary: primitives, strings, lists and maps of
sendable values, records, SendPort, TransferableTypedData, and instances of
classes that contain only sendable fields. Closures that capture non-sendable
state, and anything holding a BuildContext or a platform handle, will throw.
Choosing between async and isolates
| Work | Approach |
|---|---|
| Network request, file read, database query | Plain async/await — the platform already does this off-thread |
| Parsing a small JSON payload | Main isolate; the overhead of spawning exceeds the win |
| Parsing a large payload, image processing, crypto, compression | Isolate.run or compute |
| Continuous background processing | Long-lived isolate with ports |
| Work that must survive the app being backgrounded | Not an isolate — use platform background execution APIs |
Measure before moving work. Spawning an isolate costs time and memory, and copying a large object into it is not free. The gain appears when the computation is long enough that the copy is noise.
Plugins from background isolates
Platform channels are bound to the root isolate. Calling a plugin from an isolate you spawned fails unless you initialise the messenger there:
// On the main isolate
final token = RootIsolateToken.instance!;
await Isolate.run(() {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
// plugin calls are now possible in this isolate
});
Verify current behaviour against the plugin's documentation — support varies by plugin, and some explicitly require the main isolate.
Pitfalls
- Blocking the UI thread. Any synchronous loop over a large collection,
jsonDecodeof a multi-megabyte string, or image decoding on the main isolate drops frames. Profile with the timeline in DevTools and performance. setStateafterdispose. Guard withif (!mounted) return;after everyawaitin aState.- Using
BuildContextacross an await. Checkcontext.mountedfirst. - Leaked subscriptions and controllers. Cancel subscriptions, close controllers, kill isolates. See Lifecycle.
- Unhandled errors in dropped futures. A future nobody awaits swallows its exception; it will surface as a silent failure much later.
- Assuming
awaityields once. An already-completed future still schedules a microtask, so ordering betweenawaitandscheduleMicrotaskis subtle — do not depend on it for correctness. - Starving the loop with microtasks. Long chains of synchronous
continuations delay rendering; break them with
await Future<void>.delayed(Duration.zero).
Next: Networking and serialization.