Skip to main content

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

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 scheduleMicrotask and 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

WorkApproach
Network request, file read, database queryPlain async/await — the platform already does this off-thread
Parsing a small JSON payloadMain isolate; the overhead of spawning exceeds the win
Parsing a large payload, image processing, crypto, compressionIsolate.run or compute
Continuous background processingLong-lived isolate with ports
Work that must survive the app being backgroundedNot 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, jsonDecode of a multi-megabyte string, or image decoding on the main isolate drops frames. Profile with the timeline in DevTools and performance.
  • setState after dispose. Guard with if (!mounted) return; after every await in a State.
  • Using BuildContext across an await. Check context.mounted first.
  • 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 await yields once. An already-completed future still schedules a microtask, so ordering between await and scheduleMicrotask is 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.