Async, Await & Streams
Some work takes time: downloading a list of trains, reading a file, waiting for a payment to be confirmed. If your app simply stopped and waited, the screen would freeze and users would think it had crashed. Asynchronous code lets the program start a slow task, carry on with other work, and come back when the result is ready.
Dart's tools for this are Futures (one value that arrives later), the async and await keywords, and Streams (many values that arrive over time). Every Flutter app that talks to the internet or a database uses them.
Futures and await
A Future<T> is a promise of a value of type T that will be ready later. Mark a function async and you can use await inside it to pause that function until a Future completes, without blocking the rest of the program.
Future<int> fetchSeatsLeft(String train) async {
await Future.delayed(const Duration(milliseconds: 300)); // pretend network call
return train == '12015' ? 42 : 0;
}
Future<void> main() async {
print('Checking seats...');
final seats = await fetchSeatsLeft('12015');
print('Shatabdi 12015 has $seats seats left');
final none = await fetchSeatsLeft('99999');
print('Train 99999 has $none seats left');
print('Done');
}Checking seats... Shatabdi 12015 has 42 seats left Train 99999 has 0 seats left Done
Future.delayed creates a Future that completes after a delay; here it stands in for a real network request. An async function always returns a Future, even if you write return 42, so the return type is Future<int>. A function that returns nothing useful returns Future<void>. main can be async too.
What runs first: the event loop
await does not stop the whole program. While one function waits, Dart's event loop runs other code that is ready. This example shows the order:
Future<void> sendOtp() async {
print('2. Sending OTP...');
await Future.delayed(const Duration(milliseconds: 100));
print('4. OTP delivered');
}
void main() {
print('1. Button tapped');
sendOtp();
print('3. Screen still responds while waiting');
}1. Button tapped 2. Sending OTP... 3. Screen still responds while waiting 4. OTP delivered
sendOtp() runs until its first await, then hands control back. main continues and prints line 3. When the delay ends, the event loop resumes sendOtp and prints line 4. This is why a Flutter screen stays smooth while data loads. Note that main did not await sendOtp(); leaving out await is sometimes intended, but usually it is a bug, and the analyzer can warn you about it.
Handling errors
A Future can fail. With await, you catch errors with the normal try, catch and finally blocks.
Future<int> fetchBalance(String upiId) async {
await Future.delayed(const Duration(milliseconds: 100));
if (!upiId.contains('@')) {
throw FormatException('Invalid UPI ID: $upiId');
}
return 1500;
}
Future<void> main() async {
for (final id in ['meera@okbank', 'meera-okbank']) {
try {
final balance = await fetchBalance(id);
print('Balance for $id: ₹$balance');
} on FormatException catch (e) {
print('Error: ${e.message}');
} finally {
print('Request for $id finished');
}
}
try {
await fetchBalance('kabir@okbank').timeout(const Duration(milliseconds: 10));
} catch (e) {
print('Gave up: ${e.runtimeType}');
}
}Balance for meera@okbank: ₹1500 Request for meera@okbank finished Error: Invalid UPI ID: meera-okbank Request for meera-okbank finished Gave up: TimeoutException
on FormatException catch (e) catches only that type of error; a plain catch (e) catches anything. finally always runs, which is useful for hiding a loading spinner. .timeout() makes a Future fail with a TimeoutException if it takes too long, an important safety net for network calls on slow mobile data.
Running tasks in parallel
If two tasks do not depend on each other, start both and wait for them together with Future.wait. The total time is the time of the slowest task, not the sum.
Future<String> fetchWeather(String city) async {
await Future.delayed(const Duration(milliseconds: 300));
return '$city: 31°C';
}
Future<String> fetchNews() async {
await Future.delayed(const Duration(milliseconds: 200));
return 'Top story: monsoon arrives in Kerala';
}
Future<void> main() async {
final watch = Stopwatch()..start();
final results = await Future.wait([fetchWeather('Kochi'), fetchNews()]);
watch.stop();
print(results);
print('Took less than 450 ms: ${watch.elapsedMilliseconds < 450}');
final (weather, news) = await (fetchWeather('Pune'), fetchNews()).wait;
print(weather);
print(news);
}[Kochi: 31°C, Top story: monsoon arrives in Kerala] Took less than 450 ms: true Pune: 31°C Top story: monsoon arrives in Kerala
Waiting one after the other would take about 500 ms; in parallel it takes about 300 ms. The second form puts two Futures in a record and calls .wait, which keeps each result's own type. Records are explained in Records & Patterns.
Streams: many values over time
A Stream<T> delivers a sequence of values, one after another, like live cricket scores or the position of a delivery rider. You can create one with an async* function, which uses yield to send each value, and read it with await for.
Stream<int> countdown(int from) async* {
for (var i = from; i > 0; i--) {
await Future.delayed(const Duration(milliseconds: 100));
yield i;
}
}
Stream<String> liveScore() async* {
final balls = [1, 4, 0, 6, 2];
var total = 0;
for (final runs in balls) {
await Future.delayed(const Duration(milliseconds: 50));
total += runs;
yield 'Ball: $runs run(s), total $total';
}
}
Future<void> main() async {
await for (final n in countdown(3)) {
print('Sale starts in $n...');
}
print('Sale is live!');
final boundaries = liveScore().where((s) => s.contains(' 4 ') || s.contains(' 6 '));
await for (final update in boundaries) {
print(update);
}
}Sale starts in 3... Sale starts in 2... Sale starts in 1... Sale is live! Ball: 4 run(s), total 5 Ball: 6 run(s), total 11
Streams have methods like lists: where, map, take and more, which create new streams. await for waits for each value and ends when the stream is done.
StreamController and listen
When values come from events, such as messages arriving or a button being pressed, create a stream with a StreamController and add values to it. Other code subscribes with listen.
import 'dart:async';
void main() async {
final orders = StreamController<String>();
final subscription = orders.stream.listen(
(order) => print('Kitchen received: $order'),
onDone: () => print('Kitchen closed for the night'),
);
orders.add('2 masala dosa');
orders.add('1 filter coffee');
await Future.delayed(Duration.zero);
await orders.close();
await subscription.asFuture<void>();
}Kitchen received: 2 masala dosa Kitchen received: 1 filter coffee Kitchen closed for the night
dart:async is a core library that you import for StreamController. Always close() a controller when no more values will come, and cancel subscriptions you no longer need, or they keep running and waste memory. A normal stream allows one listener; StreamController.broadcast() allows many.
Common mistakes
- Forgetting
await.final seats = fetchSeatsLeft('12015');gives you aFuture<int>, not anint, and printing it showsInstance of 'Future<int>'. - Using
awaitin a function that is notasync. Addasyncand change the return type toFuture<...>. - Awaiting independent tasks one by one. Use
Future.waitor a record's.waitto run them in parallel. - Never closing a
StreamController. Listeners wait forever andonDonenever runs.
Key points
- A
Future<T>is a value that arrives later;asyncfunctions return Futures. awaitpauses only the current function; the event loop keeps other code running.- Use
try,on ... catchandfinallyaroundawait;.timeout()limits waiting time. Future.waitand record.waitrun independent tasks in parallel.- A
Stream<T>delivers many values; create one withasync*andyield, read it withawait fororlisten. StreamControllerturns events into a stream; close it when done.
Exercise
Write an async function Future<int> fetchMarks(String subject) that waits 100 ms and returns 82 for 'Maths', 74 for 'Science' and 91 for 'English', and throws an ArgumentError for any other subject. In main, fetch all three marks in parallel and print the total. Then try fetching 'Drawing' and print a friendly error message using try and catch.
Show solution
Future<int> fetchMarks(String subject) async {
await Future.delayed(const Duration(milliseconds: 100));
return switch (subject) {
'Maths' => 82,
'Science' => 74,
'English' => 91,
_ => throw ArgumentError('No marks for $subject'),
};
}
Future<void> main() async {
final marks = await Future.wait([
fetchMarks('Maths'),
fetchMarks('Science'),
fetchMarks('English'),
]);
print('Marks: $marks, total ${marks.reduce((a, b) => a + b)}');
try {
await fetchMarks('Drawing');
} on ArgumentError catch (e) {
print('Could not load: ${e.message}');
}
}Marks: [82, 74, 91], total 247 Could not load: No marks for Drawing
Future.wait starts all three requests at once and gives back a list in the same order. throw can be used as an expression inside a switch expression, and on ArgumentError catches only that kind of error.
Quick Quiz
1. What does an async function always return?
Even return 5 inside an async function produces a Future<int>.
2. What happens while a function is paused at await?
await pauses only the current function.
3. Which function body uses yield to produce a stream?
async* functions return a Stream and send values with yield.
4. Two independent requests take 300 ms and 200 ms. About how long does Future.wait on both take?
In parallel, the total time is the time of the slowest task.
Learn Flutter & Dart with a mentor
Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
