Mobile apps constantly interact with the outside world — fetching data from APIs, reading from disk, waiting for sensor input. All of these operations take time. If your app blocks the main thread while waiting, the UI freezes and the user experience suffers. Asynchronous programming is the solution.
The Problem with Synchronous Code
In a synchronous model, your code executes one line at a time. If a network request takes two seconds, the entire app pauses for two seconds — buttons stop responding, animations freeze, and the app appears broken.
Synchronous Execution
----------------------
[fetch data] --> app frozen for 2 seconds --> [continue]
|
v
BAD: UI blocks, app appears unresponsive
Flutter's main thread (also called the UI thread) is responsible for rendering 60 frames per second. Any blocking operation on this thread drops frames and causes visible stuttering.
The Solution: Futures and Async/Await
Dart solves this with two closely related concepts:
- Futures represent a value that is not available yet but will be in the future
- async/await provides syntactic sugar to write asynchronous code that reads like synchronous code
Asynchronous Execution
-----------------------
[start fetch] --> [UI continues running] --> [fetch completes] --> [handle result]
| |
v v
Non-blocking Future resolves
Futures
A Future<T> is a placeholder for a value of type T that will be available at some point in the future. It can complete with either a value (success) or an error (failure).
// A Future that completes with a String after 2 seconds
Future<String> fetchUsername() {
return Future.delayed(
const Duration(seconds: 2),
() => 'amr_tarek',
);
}
A Future has three states:
Future States
--------------
Uncompleted --> Completed with Value
--> Completed with Error
async and await
The async keyword marks a function as asynchronous. The await keyword pauses execution within that function only — the rest of the app keeps running — until the awaited Future completes.
// Without async/await (callback style — harder to read)
fetchUsername().then((name) {
print('Username: $name');
}).catchError((error) {
print('Error: $error');
});
// With async/await (much cleaner)
Future<void> loadUser() async {
try {
final name = await fetchUsername();
print('Username: $name');
} catch (error) {
print('Error: $error');
}
}
Any function marked async automatically returns a Future. You can await it from another async function.
Making API Calls
The most common use of async programming in Flutter is making HTTP requests to REST APIs.
API Call Steps
1. Identify the endpoint URL
2. Use the http package to send the request
3. Parse the JSON response with dart:convert
4. Update the UI with the new data
5. Handle errors gracefully
Complete API Call Example
import 'package:http/http.dart' as http;
import 'dart:convert';
// Model class
class Post {
final int id;
final String title;
final String body;
Post({required this.id, required this.title, required this.body});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'],
title: json['title'],
body: json['body'],
);
}
}
// Async fetch function
Future<List<Post>> fetchPosts() async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/posts'),
headers: {'Content-Type': 'application/json'},
).timeout(const Duration(seconds: 10)); // Timeout after 10 seconds
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList.map((json) => Post.fromJson(json)).toList();
} else {
throw Exception('Server error: ${response.statusCode}');
}
}
FutureBuilder — Connecting Async Data to the UI
FutureBuilder is a Flutter widget that rebuilds itself based on the state of a Future. It handles the loading, success, and error states automatically.
FutureBuilder<List<Post>>(
future: fetchPosts(),
builder: (context, snapshot) {
// Still waiting for data
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// An error occurred
if (snapshot.hasError) {
return Center(
child: Text(
'Something went wrong:\n${snapshot.error}',
textAlign: TextAlign.center,
),
);
}
// Data is available
if (snapshot.hasData) {
final posts = snapshot.data!;
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(posts[index].title),
subtitle: Text(posts[index].body),
);
},
);
}
return const Center(child: Text('No data available'));
},
)
Error Handling
Robust async code must handle failures gracefully.
try-catch
Future<void> loadData() async {
try {
final data = await fetchPosts();
setState(() => _posts = data);
} on http.ClientException catch (e) {
setState(() => _error = 'Network error: $e');
} on FormatException catch (e) {
setState(() => _error = 'Invalid data format: $e');
} catch (e) {
setState(() => _error = 'Unexpected error: $e');
}
}
Timeouts
Prevent indefinitely hanging requests:
Future<String> fetchWithTimeout() async {
return await fetchData().timeout(
const Duration(seconds: 5),
onTimeout: () => throw TimeoutException('Request timed out'),
);
}
Performance Best Practices
- Debounce requests — delay API calls until the user stops typing to avoid overwhelming the server
- Cache responses — store fetched data locally to avoid repeated network calls
- Use HTTPS — never send data over insecure connections
- Modularize async logic — keep network calls in dedicated service classes, not inside widgets
- Avoid calling
asyncinbuild()— initiateFutureobjects ininitState(), not on every rebuild
class _MyWidgetState extends State<MyWidget> {
late Future<List<Post>> _futurePosts;
@override
void initState() {
super.initState();
// Fetch data once when the widget is created, not on every rebuild
_futurePosts = fetchPosts();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Post>>(
future: _futurePosts, // Use the stored Future, not a new one
builder: (context, snapshot) { ... },
);
}
}