Async/Await in Depth
From Callbacks to async/await
In the previous lesson, you learned to chain Futures using .then(), .catchError(), and .whenComplete(). While powerful, this callback-based approach can become difficult to read when you have many dependent operations. Dart provides async and await keywords that let you write asynchronous code that looks and reads like synchronous code.
Callbacks vs async/await -- Side by Side
// Callback style (from previous lesson)
void loadDataCallbacks() {
fetchUserId()
.then((userId) => fetchProfile(userId))
.then((profile) => fetchPosts(profile.id))
.then((posts) => print('Got ${posts.length} posts'))
.catchError((e) => print('Error: $e'));
}
// async/await style (cleaner!)
Future<void> loadDataAsync() async {
try {
final userId = await fetchUserId();
final profile = await fetchProfile(userId);
final posts = await fetchPosts(profile.id);
print('Got ${posts.length} posts');
} catch (e) {
print('Error: $e');
}
}
async/await version is syntactic sugar that the Dart compiler transforms into callback-based code under the hood. Choose async/await for readability, especially when operations depend on each other.The async Keyword
The async keyword is placed before a function body to mark it as asynchronous. An async function always returns a Future, even if you return a plain value.
Declaring async Functions
// Returns Future<String> automatically
Future<String> greet(String name) async {
return 'Hello, $name!';
}
// Even without explicit return type, it returns a Future
Future<int> computeScore() async {
return 42 * 3;
}
// void async functions return Future<void>
Future<void> logMessage(String msg) async {
print('[LOG] $msg');
}
void main() async {
final greeting = await greet('Ahmed');
print(greeting); // Hello, Ahmed!
final score = await computeScore();
print(score); // 126
}
async function that returns a plain value still wraps it in a Future. If you write return 'hello'; inside an async function, the caller receives Future<String>, not String. You must await it or use .then() to get the actual value.The await Keyword
The await keyword pauses the execution of the current async function until the Future completes, then returns the result. It can only be used inside async functions.
Using await
Future<String> fetchUserName() {
return Future.delayed(Duration(seconds: 1), () => 'Sara');
}
Future<int> fetchUserAge() {
return Future.delayed(Duration(seconds: 1), () => 28);
}
Future<void> displayUser() async {
print('Loading user...');
// Execution pauses here until fetchUserName completes
final name = await fetchUserName();
print('Name: $name');
// Then pauses here until fetchUserAge completes
final age = await fetchUserAge();
print('Age: $age');
print('User loaded!');
}
void main() {
displayUser();
print('Main continues while displayUser runs...');
}
// Output:
// Loading user...
// Main continues while displayUser runs...
// (1 second later)
// Name: Sara
// (1 more second)
// Age: 28
// User loaded!
await keyword only pauses the current async function, not the entire program. Other code, event handlers, and UI updates continue to run while waiting. This is why Flutter apps remain responsive even when awaiting network calls.Error Handling with try-catch-finally
One of the biggest advantages of async/await is that you can use standard try-catch-finally blocks for error handling, just like with synchronous code.
try-catch with async/await
Future<String> fetchData() {
return Future.delayed(Duration(seconds: 1), () {
throw Exception('Server returned 500');
});
}
Future<void> loadData() async {
try {
final data = await fetchData();
print('Data: $data');
} on FormatException catch (e) {
print('Format error: $e');
} on Exception catch (e) {
print('General error: $e');
} catch (e) {
print('Unknown error: $e');
} finally {
print('Cleanup complete');
}
}
void main() async {
await loadData();
// Output:
// General error: Exception: Server returned 500
// Cleanup complete
}
Multiple Error Types
class NetworkException implements Exception {
final String message;
final int statusCode;
NetworkException(this.message, this.statusCode);
@override
String toString() => 'NetworkException($statusCode): $message';
}
class AuthException implements Exception {
final String message;
AuthException(this.message);
@override
String toString() => 'AuthException: $message';
}
Future<Map<String, dynamic>> fetchProtectedData(String token) async {
if (token.isEmpty) {
throw AuthException('No authentication token provided');
}
if (token == 'expired') {
throw AuthException('Token has expired');
}
return Future.delayed(Duration(seconds: 1), () {
return {'data': 'Secret information', 'timestamp': DateTime.now().toString()};
});
}
Future<void> loadProtectedData() async {
try {
final data = await fetchProtectedData('expired');
print('Got data: $data');
} on AuthException catch (e) {
print('Auth failed: $e');
// Redirect to login screen
} on NetworkException catch (e) {
print('Network issue ($e.statusCode): ${e.message}');
// Show retry button
} catch (e) {
print('Unexpected error: $e');
}
}
Sequential vs Parallel Execution
A critical skill with async/await is knowing when to run operations sequentially versus in parallel. Sequential is needed when operations depend on each other. Parallel is better when operations are independent.
Sequential Execution (Slow)
Future<String> fetchUser() =>
Future.delayed(Duration(seconds: 2), () => 'Ahmed');
Future<List<String>> fetchPosts() =>
Future.delayed(Duration(seconds: 3), () => ['Post 1', 'Post 2']);
Future<int> fetchFollowers() =>
Future.delayed(Duration(seconds: 1), () => 150);
// SLOW: 2 + 3 + 1 = 6 seconds total
Future<void> loadSequential() async {
final stopwatch = Stopwatch()..start();
final user = await fetchUser(); // Waits 2s
final posts = await fetchPosts(); // Then waits 3s
final followers = await fetchFollowers(); // Then waits 1s
print('User: $user, Posts: ${posts.length}, Followers: $followers');
print('Time: ${stopwatch.elapsedMilliseconds}ms'); // ~6000ms
}
Parallel Execution (Fast)
// FAST: max(2, 3, 1) = 3 seconds total
Future<void> loadParallel() async {
final stopwatch = Stopwatch()..start();
// Start all three operations at the same time
final results = await Future.wait([
fetchUser(),
fetchPosts(),
fetchFollowers(),
]);
print('User: ${results[0]}');
print('Posts: ${(results[1] as List).length}');
print('Followers: ${results[2]}');
print('Time: ${stopwatch.elapsedMilliseconds}ms'); // ~3000ms
}
// EVEN BETTER: Named parallel results using records (Dart 3)
Future<void> loadParallelNamed() async {
final (user, posts, followers) = await (
fetchUser(),
fetchPosts(),
fetchFollowers(),
).wait;
print('User: $user, Posts: ${posts.length}, Followers: $followers');
}
await calls do not depend on each other, consider using Future.wait or Dart 3 record destructuring to run them in parallel. This can cut loading times dramatically.Async Generators with async*
Just as async makes a function return a Future, the async* keyword makes a function return a Stream. Inside an async* function, you use yield to emit values one at a time.
Basic async* Generator
Stream<int> countDown(int from) async* {
for (int i = from; i >= 0; i--) {
await Future.delayed(Duration(seconds: 1));
yield i;
}
}
void main() async {
print('Countdown starting...');
await for (final number in countDown(5)) {
print(number);
}
print('Liftoff!');
}
// Output (one per second):
// Countdown starting...
// 5
// 4
// 3
// 2
// 1
// 0
// Liftoff!
yield* -- Delegating to Another Stream
Stream<String> fetchPages(String query) async* {
int page = 1;
while (page <= 3) {
await Future.delayed(Duration(milliseconds: 500));
yield 'Page $page results for "$query"';
page++;
}
}
Stream<String> searchAll(List<String> queries) async* {
for (final query in queries) {
yield '--- Searching: $query ---';
yield* fetchPages(query); // Delegates all values from fetchPages
}
}
void main() async {
await for (final result in searchAll(['dart', 'flutter'])) {
print(result);
}
}
yield emits a single value. yield* (yield-star) delegates to another Stream or Iterable, forwarding all of its values. The async* function pauses at each yield point until the listener is ready for the next value.Converting Callbacks to Futures
Many older APIs and third-party libraries use callback-based patterns. You can wrap them in Futures using Completer to make them compatible with async/await.
Using Completer to Wrap Callbacks
import 'dart:async';
// Imagine this is an old callback-based API
void oldApiCall(String url, Function(String) onSuccess, Function(String) onError) {
Future.delayed(Duration(seconds: 1), () {
if (url.startsWith('https')) {
onSuccess('Data from $url');
} else {
onError('Insecure URL: $url');
}
});
}
// Wrap it in a Future using Completer
Future<String> modernApiCall(String url) {
final completer = Completer<String>();
oldApiCall(
url,
(data) => completer.complete(data),
(error) => completer.completeError(Exception(error)),
);
return completer.future;
}
void main() async {
try {
final data = await modernApiCall('https://api.example.com/users');
print(data); // Data from https://api.example.com/users
} catch (e) {
print('Error: $e');
}
try {
final data = await modernApiCall('http://insecure.example.com');
print(data);
} catch (e) {
print('Error: $e'); // Error: Exception: Insecure URL: http://insecure.example.com
}
}
Completer can only be completed once. Calling complete() or completeError() a second time throws a StateError. Always ensure your callback logic calls exactly one of these methods exactly once.Error Handling Patterns
Real-world applications need robust error handling. Here are some common patterns used in production Dart and Flutter code.
Retry Pattern
Future<T> retry<T>(
Future<T> Function() operation, {
int maxAttempts = 3,
Duration delay = const Duration(seconds: 1),
}) async {
int attempt = 0;
while (true) {
attempt++;
try {
return await operation();
} catch (e) {
if (attempt >= maxAttempts) {
print('All $maxAttempts attempts failed. Giving up.');
rethrow; // Throw the last error
}
print('Attempt $attempt failed: $e. Retrying in ${delay.inSeconds}s...');
await Future.delayed(delay * attempt); // Exponential backoff
}
}
}
// Usage
int callCount = 0;
Future<String> unreliableApi() async {
callCount++;
if (callCount < 3) {
throw Exception('Server busy');
}
return 'Success on attempt $callCount!';
}
void main() async {
try {
final result = await retry(() => unreliableApi());
print(result); // Success on attempt 3!
} catch (e) {
print('Failed: $e');
}
}
Data Fetching Pipeline with Fallback
Future<String> fetchFromPrimary() async {
await Future.delayed(Duration(seconds: 1));
throw Exception('Primary server down');
}
Future<String> fetchFromBackup() async {
await Future.delayed(Duration(seconds: 2));
return 'Data from backup server';
}
Future<String> fetchFromCache() async {
return 'Stale data from cache';
}
Future<String> fetchWithFallback() async {
try {
return await fetchFromPrimary();
} catch (e) {
print('Primary failed: $e');
}
try {
return await fetchFromBackup();
} catch (e) {
print('Backup failed: $e');
}
// Last resort
print('Using cached data');
return await fetchFromCache();
}
void main() async {
final data = await fetchWithFallback();
print('Got: $data');
// Output:
// Primary failed: Exception: Primary server down
// Got: Data from backup server
}
Practical Example: Data Fetching Pipeline
Let’s build a complete data pipeline that demonstrates sequential operations, parallel loading, error handling, and retry logic working together.
Complete Data Pipeline
import 'dart:async';
// Models
class User {
final String id;
final String name;
User(this.id, this.name);
}
class Post {
final String title;
final int likes;
Post(this.title, this.likes);
}
// API simulation
Future<String> authenticate(String email, String password) async {
await Future.delayed(Duration(milliseconds: 500));
if (email.isEmpty || password.isEmpty) {
throw AuthException('Credentials required');
}
return 'token_abc123';
}
Future<User> fetchUser(String token) async {
await Future.delayed(Duration(milliseconds: 800));
return User('u1', 'Omar Al-Rashid');
}
Future<List<Post>> fetchUserPosts(String userId) async {
await Future.delayed(Duration(milliseconds: 1000));
return [
Post('Getting Started with Dart', 42),
Post('Flutter State Management', 88),
Post('Async Programming Guide', 156),
];
}
Future<Map<String, int>> fetchAnalytics(String userId) async {
await Future.delayed(Duration(milliseconds: 700));
return {'views': 12500, 'followers': 340, 'engagement': 78};
}
class AuthException implements Exception {
final String message;
AuthException(this.message);
@override
String toString() => message;
}
// Main pipeline
Future<void> loadDashboard(String email, String password) async {
final stopwatch = Stopwatch()..start();
print('=== Dashboard Loading ===\n');
try {
// Step 1: Authenticate (must complete first)
print('Authenticating...');
final token = await authenticate(email, password);
print('Authenticated! (${stopwatch.elapsedMilliseconds}ms)\n');
// Step 2: Fetch user (depends on token)
final user = await fetchUser(token);
print('Welcome, ${user.name}! (${stopwatch.elapsedMilliseconds}ms)\n');
// Step 3: Fetch posts and analytics in parallel (both depend on user)
print('Loading dashboard data...');
final (posts, analytics) = await (
fetchUserPosts(user.id),
fetchAnalytics(user.id),
).wait;
// Display results
print('\nYour Posts:');
for (final post in posts) {
print(' - ${post.title} (${post.likes} likes)');
}
print('\nAnalytics:');
analytics.forEach((key, value) {
print(' $key: $value');
});
print('\nDashboard loaded in ${stopwatch.elapsedMilliseconds}ms');
} on AuthException catch (e) {
print('Authentication failed: $e');
} catch (e) {
print('Dashboard error: $e');
}
}
void main() async {
await loadDashboard('omar@example.com', 'password123');
}
await for dependent operations (authenticate before fetching user) and parallel execution with record destructuring for independent operations (posts and analytics). This is the optimal pattern for real-world data loading.Summary
In this lesson, you mastered async/await in Dart:
- async -- marks a function as asynchronous, returns a Future
- await -- pauses execution until a Future completes
- try-catch-finally -- standard error handling with async code
- Sequential vs Parallel -- use await for dependent operations, Future.wait for independent ones
- async* / yield -- async generators that produce Streams
- Completer -- wraps callback-based APIs into Futures
- Retry and fallback patterns -- production-grade error handling
In the next lesson, you’ll dive into Streams -- Dart’s powerful abstraction for handling sequences of asynchronous events.