Asynchronous Programming: Futures
What Is Asynchronous Programming?
In everyday programming, most code runs synchronously -- each line waits for the previous one to finish before executing. But what happens when your program needs to fetch data from a server, read a large file, or wait for a timer? If everything ran synchronously, your entire application would freeze while waiting.
Asynchronous programming allows your program to start a long-running operation and continue executing other code while waiting for the result. When the operation completes, your program is notified and can process the result. This is essential for building responsive apps, especially in Flutter where the UI must remain smooth at all times.
The Dart Event Loop
At the heart of Dart’s asynchronous system is the event loop. Understanding it is key to writing correct async code.
The event loop works like this:
- Dart executes all synchronous code in the
main()function first - It then checks the microtask queue (high-priority tasks like Future callbacks)
- If the microtask queue is empty, it checks the event queue (I/O events, timers, user input)
- It processes one event at a time, then checks the microtask queue again
- This cycle repeats until both queues are empty
Event Loop in Action
void main() {
print('1. Start of main');
Future(() => print('4. Event queue task'));
Future.microtask(() => print('3. Microtask queue'));
print('2. End of main');
}
// Output:
// 1. Start of main
// 2. End of main
// 3. Microtask queue
// 4. Event queue task
The Future Class
A Future<T> represents a value that will be available sometime in the future. It is Dart’s core class for handling single asynchronous operations. A Future can be in one of three states:
- Uncompleted -- the operation is still running
- Completed with a value -- the operation succeeded and returned a result of type
T - Completed with an error -- the operation failed
Creating a Basic Future
void main() {
// A Future that completes after the current event loop iteration
Future(() {
print('This runs asynchronously');
return 42;
});
print('This runs first (synchronous)');
}
// Output:
// This runs first (synchronous)
// This runs asynchronously
Future.delayed
The Future.delayed constructor creates a Future that completes after a specified duration. This is extremely useful for simulating network requests during development, implementing timeouts, and creating timed operations.
Using Future.delayed
void main() {
print('Fetching data...');
Future.delayed(Duration(seconds: 2), () {
return 'Data loaded successfully!';
}).then((value) {
print(value);
});
print('Doing other work while waiting...');
}
// Output:
// Fetching data...
// Doing other work while waiting...
// (2 seconds later)
// Data loaded successfully!
Simulating a Network Request
Future<Map<String, dynamic>> fetchUser(int id) {
// Simulates a 1.5 second API call
return Future.delayed(Duration(milliseconds: 1500), () {
return {
'id': id,
'name': 'Ahmed Al-Farsi',
'email': 'ahmed@example.com',
'role': 'developer',
};
});
}
void main() {
print('Requesting user data...');
fetchUser(101).then((user) {
print('User: ${user["name"]} (${user["email"]})');
});
print('Request sent, continuing...');
}
Future.value and Future.error
Future.value creates a Future that completes immediately with a given value. Future.error creates a Future that completes immediately with an error. These are useful for returning cached results or known errors from functions that must return a Future.
Future.value -- Immediate Completion
Future<String> getCachedData(String key) {
final cache = {
'user': 'Ahmed',
'theme': 'dark',
'lang': 'en',
};
if (cache.containsKey(key)) {
// Return immediately from cache
return Future.value(cache[key]!);
}
// Simulate network fetch for uncached keys
return Future.delayed(Duration(seconds: 1), () {
return 'fetched_$key';
});
}
void main() {
getCachedData('user').then((v) => print('Cached: $v'));
getCachedData('avatar').then((v) => print('Fetched: $v'));
}
Future.error -- Immediate Error
Future<int> divide(int a, int b) {
if (b == 0) {
return Future.error(ArgumentError('Cannot divide by zero'));
}
return Future.value(a ~/ b);
}
void main() {
divide(10, 2).then((result) {
print('Result: $result'); // Result: 5
});
divide(10, 0).catchError((error) {
print('Error: $error'); // Error: Invalid argument(s): Cannot divide by zero
});
}
Future.value completes "immediately", its .then() callback still runs asynchronously (in the next microtask). It does not execute synchronously inline. This is a common source of confusion for beginners.Chaining Futures: .then(), .catchError(), .whenComplete()
Futures support a powerful chaining API that lets you build pipelines of asynchronous operations:
.then()-- runs when the Future completes successfully; can return a new value or Future.catchError()-- runs when the Future completes with an error.whenComplete()-- runs regardless of success or failure (likefinallyin try-catch)
Chaining .then() Calls
Future<String> fetchUserId() {
return Future.delayed(Duration(seconds: 1), () => 'user_42');
}
Future<Map<String, String>> fetchProfile(String userId) {
return Future.delayed(Duration(milliseconds: 800), () => {
'id': userId,
'name': 'Sara Khan',
'city': 'Dubai',
});
}
Future<String> fetchAvatar(String userId) {
return Future.delayed(Duration(milliseconds: 500), () {
return 'https://avatars.example.com/$userId.png';
});
}
void main() {
fetchUserId()
.then((userId) {
print('Got user ID: $userId');
return fetchProfile(userId);
})
.then((profile) {
print('Got profile: ${profile["name"]} from ${profile["city"]}');
return fetchAvatar(profile['id']!);
})
.then((avatarUrl) {
print('Avatar URL: $avatarUrl');
})
.catchError((error) {
print('Something went wrong: $error');
})
.whenComplete(() {
print('All operations finished.');
});
}
.then() can return either a plain value or another Future. If it returns a Future, the next .then() in the chain waits for that Future to complete before running. This is how you sequence dependent async operations.Error Handling with .catchError()
The .catchError() method catches errors from any point earlier in the chain. You can also filter errors by type using the optional test parameter.
Selective Error Handling
Future<String> riskyOperation() {
return Future.delayed(Duration(seconds: 1), () {
throw FormatException('Invalid data format');
});
}
void main() {
riskyOperation()
.then((value) => print('Success: $value'))
.catchError(
(error) => print('Format error: $error'),
test: (error) => error is FormatException,
)
.catchError(
(error) => print('Unknown error: $error'),
);
}
Future.wait -- Parallel Execution
Future.wait takes a list of Futures and returns a single Future that completes when all of them have completed. The result is a list of all their values. If any Future in the list fails, the combined Future fails.
Running Multiple Futures in Parallel
Future<String> fetchUser() =>
Future.delayed(Duration(seconds: 2), () => 'Ahmed');
Future<List<String>> fetchPosts() =>
Future.delayed(Duration(seconds: 3), () => ['Post 1', 'Post 2']);
Future<int> fetchNotifications() =>
Future.delayed(Duration(seconds: 1), () => 5);
void main() {
final stopwatch = Stopwatch()..start();
Future.wait([
fetchUser(),
fetchPosts(),
fetchNotifications(),
]).then((results) {
print('User: ${results[0]}');
print('Posts: ${results[1]}');
print('Notifications: ${results[2]}');
print('Total time: ${stopwatch.elapsedMilliseconds}ms');
// ~3000ms, not 6000ms -- they ran in parallel!
}).catchError((error) {
print('One of the operations failed: $error');
});
}
Future.wait whenever you have multiple independent async operations. Running them in parallel instead of sequentially can dramatically reduce total wait time. In the example above, total time is ~3 seconds (the slowest operation) instead of ~6 seconds (sum of all operations).Future.any -- First to Complete
Future.any returns the result of the first Future to complete successfully. This is useful for racing multiple data sources or implementing timeout patterns.
Racing Multiple Data Sources
Future<String> fetchFromCDN1() =>
Future.delayed(Duration(seconds: 3), () => 'Data from CDN 1');
Future<String> fetchFromCDN2() =>
Future.delayed(Duration(seconds: 1), () => 'Data from CDN 2');
Future<String> fetchFromCDN3() =>
Future.delayed(Duration(seconds: 2), () => 'Data from CDN 3');
void main() {
Future.any([
fetchFromCDN1(),
fetchFromCDN2(),
fetchFromCDN3(),
]).then((fastest) {
print(fastest); // Data from CDN 2 (completed first)
});
}
Implementing a Timeout with Future.any
Future<String> fetchWithTimeout(Future<String> operation, Duration timeout) {
return Future.any([
operation,
Future.delayed(timeout, () => throw TimeoutException(
'Operation timed out after ${timeout.inSeconds}s',
)),
]);
}
void main() {
final slowRequest = Future.delayed(
Duration(seconds: 10),
() => 'Slow data',
);
fetchWithTimeout(slowRequest, Duration(seconds: 3))
.then((data) => print('Got data: $data'))
.catchError((error) => print('Error: $error'));
// Error: TimeoutException after 3s
}
Practical Example: Loading App Data
Let’s build a realistic example that simulates loading data for a mobile app’s home screen. This combines multiple Future patterns you’ve learned.
App Data Loader
import 'dart:math';
// Simulated API functions
Future<Map<String, dynamic>> fetchUserProfile() {
return Future.delayed(Duration(milliseconds: 800), () {
return {'name': 'Layla Mahmoud', 'avatar': 'layla.png'};
});
}
Future<List<String>> fetchRecentOrders() {
return Future.delayed(Duration(milliseconds: 1200), () {
return ['Order #1001', 'Order #1002', 'Order #1003'];
});
}
Future<List<String>> fetchRecommendations() {
return Future.delayed(Duration(milliseconds: 600), () {
if (Random().nextBool()) {
throw Exception('Recommendation service unavailable');
}
return ['Product A', 'Product B', 'Product C'];
});
}
Future<int> fetchCartCount() {
return Future.delayed(Duration(milliseconds: 300), () => 3);
}
void main() {
print('Loading home screen data...');
final stopwatch = Stopwatch()..start();
// Fetch critical data in parallel
Future.wait([
fetchUserProfile(),
fetchRecentOrders(),
fetchCartCount(),
]).then((results) {
final profile = results[0] as Map<String, dynamic>;
final orders = results[1] as List<String>;
final cartCount = results[2] as int;
print('\nWelcome, ${profile["name"]}!');
print('Recent orders: ${orders.join(", ")}');
print('Cart items: $cartCount');
// Fetch non-critical data separately (may fail gracefully)
return fetchRecommendations()
.then((recs) {
print('Recommended: ${recs.join(", ")}');
})
.catchError((error) {
print('Recommendations unavailable (showing defaults)');
});
}).whenComplete(() {
print('\nHome screen loaded in ${stopwatch.elapsedMilliseconds}ms');
});
}
Future.wait and non-critical data with graceful error handling is very common in real Flutter apps. The user sees the main content quickly while optional features load or fall back to defaults.Zone’s error handler, and in debug mode Dart will print a warning. Always add .catchError() or wrap your async code in try-catch (with async/await, covered in the next lesson).Summary
In this lesson, you learned the fundamentals of asynchronous programming in Dart:
- Event Loop -- Dart’s single-threaded mechanism for handling async operations
- Future -- represents a value that will be available later
- Future.delayed -- creates a Future that completes after a duration
- Future.value / Future.error -- creates immediately completed Futures
- .then() / .catchError() / .whenComplete() -- chaining and error handling
- Future.wait -- runs multiple Futures in parallel
- Future.any -- returns the first Future to complete
In the next lesson, you’ll learn about async/await -- a cleaner syntax for working with Futures that makes async code look and feel like synchronous code.