Async Dart
Future, async/await, Stream, and error handling in asynchronous Dart code.
Future — a value that isn't ready yet
A Future<T> represents a value of type T that will be available at some point — the result of an operation that takes time, like a network request or reading a file, without blocking the program while it waits.
Future<String> fetchUserName() {
return Future.delayed(const Duration(seconds: 2), () => 'Ali');
}
void main() {
print('Fetching...');
fetchUserName().then((name) {
print('Got: $name'); // printed ~2 seconds later
});
print('This prints immediately, before the name arrives');
}
Fetching...
This prints immediately, before the name arrives
Got: Ali
async / await
Chaining many .then() calls gets unwieldy fast. async/await lets you write asynchronous code that reads like ordinary sequential code, while still not blocking the underlying thread:
Future<String> fetchUserName() {
return Future.delayed(const Duration(seconds: 1), () => 'Ali');
}
Future<int> fetchUserAge() {
return Future.delayed(const Duration(seconds: 1), () => 22);
}
Future<void> printProfile() async {
final name = await fetchUserName(); // pauses here until the Future completes, without blocking the app
final age = await fetchUserAge();
print('$name is $age years old');
}
void main() async {
await printProfile();
print('Done');
}
A function marked async always returns a Future, even if the body has no explicit Future in its return type — Future<void> above, or a bare non-Future return type gets automatically wrapped.
Running independent Futures concurrently
Awaiting two independent operations one after another needlessly serializes them. Future.wait runs them concurrently and resolves once all of them finish:
Future<void> printProfileFast() async {
final results = await Future.wait([
fetchUserName(),
fetchUserAge(),
]);
print('${results[0]} is ${results[1]} years old');
// completes in ~1 second total, not ~2 — both requests ran concurrently
}
Error handling with async code
Future<String> fetchData() async {
throw Exception('Network error');
}
Future<void> main() async {
try {
final data = await fetchData();
print(data);
} catch (e) {
print('Caught: $e'); // Caught: Exception: Network error
}
}
Ordinary try/catch works around await exactly as it would around synchronous code that might throw — this is one of async/await's biggest advantages over manually chaining .then()/.catchError().
Stream — a sequence of async values over time
Where a Future represents one value that arrives once, a Stream<T> represents a sequence of values delivered over time — think of it as an asynchronous, push-based iterable. Streams are the foundation of Flutter's StreamBuilder widget, which rebuilds part of a UI automatically each time a new value arrives.
Stream<int> countStream(int max) async* {
for (var i = 1; i <= max; i++) {
await Future.delayed(const Duration(seconds: 1));
yield i; // emits one value into the stream and continues
}
}
void main() async {
await for (final value in countStream(3)) {
print(value); // 1, then 2, then 3 — one per second
}
print('Stream complete');
}
async* marks a function as a generator that produces a Stream, and yield emits one value into it at a time — the streaming counterpart to async/await and return.
Listening to a stream without await for
await for works well inside another async function, but you can also subscribe imperatively with .listen(), which is what a widget like StreamBuilder does internally to rebuild the UI on each new event:
void main() {
final stream = countStream(3);
final subscription = stream.listen(
(value) => print('Got: $value'),
onError: (error) => print('Error: $error'),
onDone: () => print('Stream closed'),
);
}
Single-subscription vs. broadcast streams
By default, a Stream can only be listened to once — a good fit for a one-time data load. A broadcast stream (StreamController.broadcast()) allows multiple listeners, which matters for something like a stream of button-tap or scroll events that several widgets might all want to react to.
Common mistakes
- Awaiting two independent
Futures sequentially (await a(); await b();) when they don't depend on each other's result — useFuture.wait([a(), b()])to run them concurrently instead. - Forgetting a
Streamis single-subscription by default and trying to listen to it twice, which throws aStateError— use a broadcast stream if multiple listeners are genuinely needed. - Not wrapping
awaitcalls intry/catch(or attaching.catchError()to a rawFuture), leaving an unhandled exception to crash the async operation silently.
Interview questions
Q: What's the difference between a Future and a Stream?
A Future<T> represents a single value that will become available at some point in the future (or an error). A Stream<T> represents a sequence of zero or more values delivered asynchronously over time — you can think of a Future as at most one event, and a Stream as many events, arriving whenever they're ready.
Q: Why does async/await make error handling easier compared to chaining .then()/.catchError()?
Because you can wrap await calls in an ordinary try/catch block exactly like synchronous code, instead of remembering to attach a separate .catchError() to every link in a .then() chain — a single try/catch naturally covers every await inside it, reducing the chance of an unhandled error slipping through.
Q: What does marking a function async* do, and what keyword does it use to emit values?
It turns the function into a generator that returns a Stream instead of a Future. Inside the function body, yield emits one value into the stream at a time (analogous to how a plain async function uses return to complete its single Future), and the function can keep running and yielding more values afterward.