Mert Özen Further With Every Line
Frontend Mobile with Flutter — #05

Asynchronous Programming in Dart: Futures, async/await, Streams and Isolates

Mert Özen Nov 15, 2026 13 min 3 views
Asynchronous Programming in Dart: Futures, async/await, Streams and Isolates

Dart runs on a single-threaded event loop: async/await does not move work to the background, heavy work freezes the UI, and real parallelism needs isolates with separate memory. We compare Future with C#'s Task and Stream with IAsyncEnumerable, measure that parsing a 20 MB JSON on the main isolate stops frames while Isolate.run does not, and show how the error of an un-awaited Future gets lost.

Version note: The Dart examples in this post were run with Dart 3.13.7 (the SDK that ships with Flutter 3.47.8), and the Flutter tests with Flutter 3.47.8. The timings come from the machine this post was prepared on; your numbers will differ, the ratios stay similar.

Guess First

In the mobile app of Bookstore, the online bookstore used as the shared example across this site's series, everything is asynchronous: the catalog comes from the API, the cart is written to the device, the order is sent in the background. Writing correct Flutter code is hard without knowing how Dart queues this work. Guess the output order of this program:

// Order of execution: synchronous code, microtasks, then events (post 05).
import 'dart:async';

Future<String> loadTitle() async {
  print('loadTitle starts synchronously');
  await Future<void>.delayed(Duration.zero);
  print('loadTitle resumes after await');
  return 'Dune';
}

void main() {
  print('main starts');
  Timer.run(() => print('timer (event queue)'));
  unawaited(Future(() => print('Future() (event queue)')));
  scheduleMicrotask(() => print('microtask'));
  unawaited(loadTitle().then((title) => print('got $title')));
  print('main ends');
}

There are eight lines. Does "loadTitle starts synchronously" come before or after "main ends"? Which runs first, scheduleMicrotask or Timer.run? We give the answer with real output in the next section.

One Thread, Two Queues: the Event Loop

dart run bin/event_loop.dart
main starts
loadTitle starts synchronously
main ends
microtask
timer (event queue)
Future() (event queue)
loadTitle resumes after await
got Dune

A Dart program (and the UI of a Flutter app) runs on a single thread, on an event loop. The loop has two queues. The event queue takes work from the outside world: timers, I/O completions, touch events, and in Flutter the request to draw each frame. The microtask queue is for Dart's own internal work: code added with scheduleMicrotask and the continuation steps of completed Futures. The rule is simple: when the synchronous code finishes, the microtask queue is drained completely first, then one item is taken from the event queue, then the microtasks are drained again.

Read the output with this rule. main() runs synchronously to the end; when loadTitle() is called, its body runs right away up to the first await, so "loadTitle starts synchronously" comes before "main ends". Then the microtask runs. Then two items arrive from the event queue in order: Timer.run and Future(...) both add work to the event queue, and they run in the order they were added. Since Future.delayed(Duration.zero) is also a timer, the rest of loadTitle comes last.

This model is very close to JavaScript's event loop; if you have read "The JavaScript Event Loop in Depth: Tasks, Microtasks and Rendering Order" in the Advanced JavaScript series, Dart's microtask and event queues will feel familiar. For Flutter, the consequence is this: any synchronous work that keeps the event loop busy for long also holds up the drawing of the next frame. On a 60 Hz screen there are about 16 ms per frame.

Future and async/await: Differences From Task in C#

Future<T> is the counterpart of Task<T> in C#; async and await mean the same thing. There are many similarities: both start running the moment they are created, that is, they start "hot" (the C# series post "C# Task and Task<T>: A Promise That Starts Hot"). In both, an async method runs synchronously on the caller's thread up to its first await; the second line of the output proves it.

The differences come from the threading model. In C#, which thread the code continues on after await depends on the SynchronizationContext; that is why the ConfigureAwait(false) discussion exists in UI apps. In Dart an isolate has one thread; after await the code always continues on the same isolate. There is no ConfigureAwait, and none is needed. For the same reason there is no lock: code between two awaits is not interrupted by other code, and no other thread can step in while you read and write a field.

The second difference is cancellation. In C#, CancellationToken is a standard parameter; Dart's Future has no built-in cancellation mechanism. Cancelling an HTTP request depends on the library you use (we look at dio's CancelToken in the API post). In Dart, cancellation is cleanest with streams.

Stream: the Counterpart of IAsyncEnumerable

A Future is a single value; a Stream is a sequence of values arriving over time. The counterpart of C#'s IAsyncEnumerable<T> and await foreach is Stream<T> and await for; the counterpart of a C# async iterator with yield return is an async* function with yield:

// async*, await for, single-subscription vs broadcast, cancel (post 05).
import 'dart:async';

// Like IAsyncEnumerable<T> in C#: pages arrive one by one
Stream<List<String>> catalogPages() async* {
  const pages = [
    ['Dune', 'Emma'],
    ['Middlemarch', 'The Hobbit'],
    ['Persuasion'],
  ];
  try {
    for (final (index, page) in pages.indexed) {
      await Future<void>.delayed(const Duration(milliseconds: 10));
      print('  generator: yielding page ${index + 1}');
      yield page;
    }
  } finally {
    print('  generator: closed');
  }
}

Future<void> main() async {
  print('await for:');
  await for (final page in catalogPages()) {
    print('  received $page');
    if (page.contains('The Hobbit')) break; // break cancels the subscription
  }

  print('single subscription:');
  final pages = catalogPages();
  final first = pages.listen((_) {});
  try {
    pages.listen((_) {});
  } on StateError catch (e) {
    print('  $e');
  }
  await first.cancel();

  print('broadcast:');
  final cartCount = StreamController<int>.broadcast();
  final badge = cartCount.stream.listen((n) => print('  badge: $n'));
  cartCount.stream.listen((n) => print('  total bar: $n'));
  cartCount.add(1);
  await Future<void>.delayed(Duration.zero);
  await badge.cancel();
  cartCount.add(2);
  await cartCount.close();
}
await for:
  generator: yielding page 1
  received [Dune, Emma]
  generator: yielding page 2
  received [Middlemarch, The Hobbit]
  generator: closed
single subscription:
  Bad state: Stream has already been listened to.
  generator: yielding page 1
  generator: closed
broadcast:
  badge: 1
  total bar: 1
  total bar: 2

In the first block, break ended the loop before the third page was ever produced, and the generator's finally block ran. This is cancellation in Dart: leaving await for or calling StreamSubscription.cancel() tells the producer to stop. In C#, leaving await foreach disposes the enumerator to the same effect; a cancellation token is passed separately.

Single-subscription and broadcast

A stream produced with async* is single-subscription: it can have only one listener. The second listen call threw the Bad state: Stream has already been listened to. error in the output. The reason is that such streams represent a data source (a file, an HTTP response) and start producing only once someone listens. The "generator: yielding page 1, generator: closed" lines in the output are an example of this too: when we cancelled the first subscription, the generator finished the delayed it was waiting on, saw the cancellation at the next yield and closed. Cancellation takes effect at the next yield, not at the moment of cancelling.

A broadcast stream, on the other hand, distributes the same value to several listeners; both the badge and the total bar at the bottom can listen to the number of books in the cart. The value 2 that arrived after badge was cancelled reached only the second listener. A broadcast stream does not keep history: a late subscriber does not see earlier values. The difference between Subject and BehaviorSubject in RxJS applies here as well; in Flutter we will usually solve "keep the last value and give it to new listeners" with ChangeNotifier or ValueNotifier.

Isolates: Parallelism With Separate Memory

If the event loop is single-threaded, what about work that keeps the CPU busy for a long time? Downloading and parsing the whole catalog at once, for example. Dart's answer is the isolate: a separate unit of execution with its own memory and its own event loop. Unlike threads, isolates do not share memory; they talk to each other only by sending messages. That is why you do not get data races in Dart, but moving data from one isolate to another has a cost. Isolate.run reduces this to one line: it runs the function you give it in a new isolate and returns the result.

// Parsing a large JSON on the main isolate vs Isolate.run (post 05).
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';

String buildCatalogJson(int count) => jsonEncode([
  for (var i = 0; i < count; i++)
    {
      'isbn': '978${i.toString().padLeft(10, '0')}',
      'title': 'Book $i',
      'author': 'Author ${i % 500}',
      'price': 10000 + i % 9000,
      'tags': ['novel', 'classic'],
    },
]);

int parseAndCount(String json) => (jsonDecode(json) as List).length;

// A 16 ms timer stands in for frames: if it cannot tick, the UI would freeze
Future<void> measure(String label, Future<int> Function() parse) async {
  var ticks = 0;
  final frames = Timer.periodic(
    const Duration(milliseconds: 16),
    (_) => ticks++,
  );
  final watch = Stopwatch()..start();
  final count = await parse();
  watch.stop();
  frames.cancel();
  final expected = watch.elapsedMicroseconds ~/ 16000;
  print(
    '$label: $count books in ${(watch.elapsedMicroseconds / 1000).toStringAsFixed(1)} ms, frame ticks $ticks of ~$expected',
  );
}

Future<void> main() async {
  final json = buildCatalogJson(200000);
  print('JSON size: ${(json.length / 1024 / 1024).toStringAsFixed(1)} MB');

  await measure('main isolate', () async => parseAndCount(json));
  await measure('Isolate.run ', () => Isolate.run(() => parseAndCount(json)));

  // For a small page the isolate costs more than it saves
  final page = buildCatalogJson(20);
  await measure('small, main   ', () async => parseAndCount(page));
  await measure('small, isolate', () => Isolate.run(() => parseAndCount(page)));
}

Two of three runs:

JSON size: 20.8 MB
main isolate: 200000 books in 130.4 ms, frame ticks 0 of ~8
Isolate.run : 200000 books in 120.1 ms, frame ticks 7 of ~7
small, main   : 20 books in 0.1 ms, frame ticks 0 of ~0
small, isolate: 20 books in 0.2 ms, frame ticks 0 of ~0

JSON size: 20.8 MB
main isolate: 200000 books in 128.8 ms, frame ticks 0 of ~8
Isolate.run : 200000 books in 117.0 ms, frame ticks 7 of ~7
small, main   : 20 books in 0.1 ms, frame ticks 0 of ~0
small, isolate: 20 books in 0.3 ms, frame ticks 0 of ~0

Parsing the 20.8 MB JSON on the main isolate took about 130 ms, and during that time the 16 ms timer could not tick even once: in Flutter that means the screen freezing for about eight frames and scrolling stuttering. The same work finished in a similar time with Isolate.run, but the main isolate stayed free and the timer ran the expected number of times. The total time did not get shorter, because the goal is not speed but keeping the UI thread free. On the small page the picture flips: parsing 20 books takes 0.1 ms, and starting an isolate costs several times that. For a paged API response an isolate is unnecessary.

In Flutter you can also do this with compute(function, message); on mobile and desktop it uses Isolate.run underneath. On the web there is no isolate support, and compute runs the work on the same thread. In C# you would write Task.Run for this; the difference is that Task.Run uses a thread-pool thread sharing the same heap, while an isolate copies the data.

The Lost Error: a Future Nobody Awaits

The most common mistake in asynchronous code is starting a Future and forgetting to wait for it. A flow that saves the cart:

Future<void> saveCart() async {
  await Future<void>.delayed(const Duration(milliseconds: 10));
  throw StateError('API returned 500');
}

Future<void> checkout() async {
  saveCart(); // forgot await
  print('checkout: done');
}

void onBuyPressed() {
  checkout(); // a sync callback calling an async function
}

Future<void> main() async {
  onBuyPressed();
  await Future<void>.delayed(const Duration(milliseconds: 50));
  print('main: end');
}
checkout: done
Unhandled exception:
Bad state: API returned 500
#0      saveCart (file:///home/user/src/bookstore-flutter/scratch/bin/lost_error.dart:3:3)
<asynchronous suspension>

"checkout: done" was printed, so the user was shown a successful order; the save, however, failed and nobody caught the error. In a console program this uncaught error ended the process, and "main: end" was never printed. In Flutter the app does not crash; the error goes to PlatformDispatcher.onError and ends up in the log, and the user sees nothing. In C# the exception of a Task that is not awaited is also easy to miss, but the compiler warns about an un-awaited call inside an async method by default (CS4014); in Dart the lints that catch this are off by default. We add two rules to analysis_options.yaml:

include: package:flutter_lints/flutter.yaml

linter:
  rules:
    # A Future that nobody awaits loses its error (post 05)
    - unawaited_futures
    - discarded_futures
   info - bin/lost_error.dart:7:3 - Missing an 'await' for the 'Future' computed by this expression. Try adding an 'await' or wrapping the expression with 'unawaited'. - unawaited_futures
   info - bin/lost_error.dart:12:3 - 'Future'-returning calls in a non-'async' function. Try converting the enclosing function to be 'async' and then 'await' the future, or wrap the expression in 'unawaited'. - discarded_futures

unawaited_futures catches a Future that is not awaited inside an async function, and discarded_futures catches a Future thrown away in a synchronous function. If you really do not want to wait for a Future (sending a log, say), write that explicitly with unawaited(...) from dart:async; that is why the unawaited calls are in the event loop example. Both rules are now on in bookstore_app and in dart_samples.

In Flutter: Starting a Future Inside build

A Flutter-specific trap: creating a Future in a widget's build method. build is a function Flutter may call whenever and as often as it wants: when the parent rebuilds, when the theme changes, when the keyboard opens. We added this to bookstore_app as a test:

import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';

var apiCalls = 0;

Future<int> fetchBookCount() async {
  apiCalls++;
  return 42;
}

// Wrong: build() can run many times, every run starts a new request
class CountInBuild extends StatelessWidget {
  const CountInBuild({super.key, required this.label});
  final String label;

  @override
  Widget build(BuildContext context) => FutureBuilder<int>(
    future: fetchBookCount(),
    builder: (context, snapshot) => Text('$label: ${snapshot.data ?? '...'}'),
  );
}

// Right: start the request once and keep the Future in the State
class CountInState extends StatefulWidget {
  const CountInState({super.key, required this.label});
  final String label;

  @override
  State<CountInState> createState() => _CountInStateState();
}

class _CountInStateState extends State<CountInState> {
  late final Future<int> _count = fetchBookCount();

  @override
  Widget build(BuildContext context) => FutureBuilder<int>(
    future: _count,
    builder: (context, snapshot) =>
        Text('${widget.label}: ${snapshot.data ?? '...'}'),
  );
}

void main() {
  testWidgets('a Future created in build runs on every rebuild', (
    tester,
  ) async {
    apiCalls = 0;
    for (final label in ['Books', 'Books ', 'Books  ']) {
      await tester.pumpWidget(MaterialApp(home: CountInBuild(label: label)));
    }
    debugPrint('Future in build(): $apiCalls API calls for 3 builds');
    expect(apiCalls, 3);
  });

  testWidgets('a Future kept in State runs once', (tester) async {
    apiCalls = 0;
    for (final label in ['Books', 'Books ', 'Books  ']) {
      await tester.pumpWidget(MaterialApp(home: CountInState(label: label)));
    }
    debugPrint('Future in State: $apiCalls API call for 3 builds');
    expect(apiCalls, 1);
  });
}
flutter test test/pitfalls/future_in_build_test.dart
00:00 +0: loading test/pitfalls/future_in_build_test.dart
00:00 +0: a Future created in build runs on every rebuild
Future in build(): 3 API calls for 3 builds
00:00 +1: a Future kept in State runs once
Future in State: 1 API call for 3 builds
00:00 +2: All tests passed!

Three rebuilds, three API calls. In a real app this means downloading the catalog again whenever the screen is redrawn, and the FutureBuilder going back to its loading state each time and making the screen flicker. The right way is to start the request once and keep the Future in the State; a late final field is created once, when it is first read. We usually do this in initState, which we see in the next post when we open up the State lifecycle. From the architecture post onwards, fetching data moves out of the widget entirely and into the view model.

In Production

Measure long synchronous work. The performance view in Flutter DevTools marks frames that take longer than 16 ms in red; the reason for a long frame is often a heavy calculation in build or a large response parsed on the main isolate. Measure in profile mode, on a real device.

Collect uncaught asynchronous errors in one place. In Flutter, FlutterError.onError receives framework errors and PlatformDispatcher.instance.onError receives uncaught asynchronous errors. If you do not connect both to a logging or crash reporting service, you will not see a lost save error like the one above until a user complains.

Close stream subscriptions. If a screen starts listening to a stream and does not call cancel when it closes, the listener stays alive and keeps holding the screen's state. In the next post we see how to do this with dispose.

Common Pitfalls

The first pitfall is thinking that writing async moves code to the background. An async function runs on the main isolate; a heavy loop inside it freezes the UI just the same. Moving work to the background needs an isolate.

The second pitfall is assuming Future.wait is exactly the same as Task.WhenAll. Both wait for all the work in parallel, but when there are several errors Future.wait gives only the first and silently drops the others, while in C# the AggregateException in Task.Exception carries all of them. If you want to see every error, use Dart 3's [f1, f2].wait extension: on failure it throws a ParallelWaitError that contains every result and every error.

The third pitfall is subscribing late to a broadcast stream and expecting the last value. A broadcast stream does not keep history; if you need "give me the current state", use a ValueNotifier or a structure that keeps the last value.

The fourth pitfall is sending a large object graph to an isolate and back. Messages are copied; moving a 20 MB structure in both directions can cost more than the parse time it saves. If possible, send a string or bytes to the isolate and keep the result small and flat.

A Small Experiment

★☆☆ In event_loop.dart, move the scheduleMicrotask line before and after Timer.run. Does the output change? What happens if you use Future.microtask(...)?

★★☆ Write catalogPages as a C# IAsyncEnumerable<List<string>> and leave it with break inside await foreach. Does the finally block run in C# too? Add a CancellationToken with [EnumeratorCancellation] for cancellation.

★★★ Change isolate_parse.dart so that the isolate returns the whole parsed list, not just the count. How do the time and the tick count change? Show the effect of keeping the result small with your own measurement.

In the next post we leave Dart and return to the heart of Flutter: everything being a widget, StatelessWidget and StatefulWidget, BuildContext, the lifecycle of State, and the three trees Flutter keeps as widgets, elements and render objects.