Futures in Dart — Async, Await, and the Event Loop
Futures represent values that don't exist yet. Understanding them unlocks network calls, file I/O, and responsive Flutter apps.
Why we need asynchronous code
Some operations take time. A network request might take 500 milliseconds. Reading a large file might take 100 milliseconds. If our code just waited — did nothing else — the entire app would freeze.
// Hypothetical synchronous code (don't do this)
var data = httpGet('https://api.example.com/users'); // blocks for 500ms
print(data); // nothing can happen until the above finishes
In a Flutter app, blocking means no animations, no scrolling, no user input. The screen literally freezes. Users think the app has crashed.
Asynchronous code solves this. Instead of waiting, we say "start this operation and tell me when it's done." Meanwhile, other code can run — animations continue, buttons respond, the app stays alive.
Dart's answer to this is the Future — an object that represents a value that will exist later.
What is a Future?
A Future<T> is a promise that a value of type T will be available eventually. It's not the value itself — it's a container that will hold the value when the operation completes.
Future<String> fetchUsername() {
// Returns immediately with a Future
// The actual string comes later
return Future.delayed(
Duration(seconds: 2),
() => 'Ankit',
);
}
void main() {
var future = fetchUsername();
print(future); // Instance of 'Future<String>'
print(future.runtimeType); // Future<String>
}
A Future can be in one of three states:
Key insight: A Future completes exactly once. Once it has a value (or an error), it never changes. If you await the same Future multiple times, you get the same result.
Handling Futures with then() and catchError()
The classic way to handle a Future is with callbacks. The then() method registers a callback that runs when the Future completes successfully.
fetchUsername().then((name) {
print('Got name: \$name');
});
print('This prints first!');
// Output:
// This prints first!
// Got name: Ankit (after 2 seconds)
Notice that "This prints first!" appears immediately. The then() callback doesn't block — it schedules code to run later.
Chaining Futures. If the callback returns a Future, the chain continues.
fetchUsername()
.then((name) => fetchUserDetails(name))
.then((details) => print(details));
Catching errors. Use catchError() to handle failures.
fetchUsername()
.then((name) => print('Hello, \$name'))
.catchError((error) => print('Failed: \$error'));
The finally equivalent. Use whenComplete() to run code regardless of success or failure.
fetchData()
.then((data) => process(data))
.catchError((e) => handleError(e))
.whenComplete(() => hideLoadingSpinner());
This callback style works, but it gets messy with complex logic. That's why Dart has async/await.
Async and await — making futures readable
The async and await keywords let us write asynchronous code that looks synchronous.
// With callbacks
void loadData() {
fetchUsername().then((name) {
fetchDetails(name).then((details) {
updateUI(details);
});
});
}
// With async/await
Future<void> loadData() async {
var name = await fetchUsername();
var details = await fetchDetails(name);
updateUI(details);
}
Same logic, but the second version reads top-to-bottom like synchronous code.
Rules of async/await:
• A function using
await must be marked async
• An
async function always returns a Future
•
await pauses execution until the Future completes
• Code after
await runs when the Future completes
Future<int> calculate() async {
var a = await fetchA(); // pauses here
var b = await fetchB(); // pauses here
return a + b; // runs after both complete
}
// Even returning a plain value, it's wrapped in a Future
Future<int> simple() async {
return 42; // actually returns Future<int> that completes with 42
}
Error handling with try/catch. Errors in async code work just like synchronous code.
Future<void> loadData() async {
try {
var data = await fetchData();
process(data);
} catch (e) {
print('Error: \$e');
} finally {
hideSpinner();
}
}
The event loop — how Dart runs async code
Dart is single-threaded. There's only one thread of execution. So how does async code work without blocking?
The answer is the event loop. Dart maintains a queue of tasks. When your code awaits a Future, it's not blocking — it's scheduling the rest of the function to run later, when the Future completes.
Two queues: Dart actually has two queues. The microtask queue has higher priority — it's drained completely before the event loop picks the next event. Most Future callbacks are microtasks.
void main() {
print('1 - main starts');
Future(() => print('4 - event queue'));
Future.microtask(() => print('3 - microtask'));
print('2 - main ends');
}
// Output:
// 1 - main starts
// 2 - main ends
// 3 - microtask
// 4 - event queue
Warning: Long synchronous code blocks the event loop. No events are processed. The UI freezes. This is why heavy computation should go to isolates (covered in a later episode).
Running Futures in parallel
Sequential awaits are easy to read, but sometimes we want operations to run simultaneously.
// Sequential — takes 3 seconds total
var a = await fetchA(); // 1 second
var b = await fetchB(); // 1 second
var c = await fetchC(); // 1 second
// Parallel — takes 1 second total (all run at once)
var results = await Future.wait([
fetchA(),
fetchB(),
fetchC(),
]);
// results is a List: [resultA, resultB, resultC]
Future.wait() takes a list of Futures and returns a Future that completes when all of them complete. The result is a list of all values.
Records for typed results. With Dart 3 records, we can do better:
Future<(User, List<Post>, Settings)> loadProfile(int userId) async {
var (user, posts, settings) = await (
fetchUser(userId),
fetchPosts(userId),
fetchSettings(userId),
).wait;
return (user, posts, settings);
}
Error handling in parallel. If any Future fails, Future.wait() fails. Use eagerError: false to wait for all Futures regardless.
try {
var results = await Future.wait(
[fetchA(), fetchB(), fetchC()],
eagerError: false,
);
} catch (e) {
// At least one failed
}
First to complete. Use Future.any() when you only need the first result.
// Race multiple servers, use first response
var fastest = await Future.any([
fetchFromServer1(),
fetchFromServer2(),
fetchFromServer3(),
]);
Creating Futures
Most Futures come from libraries — HTTP clients, file I/O, timers. But sometimes we need to create our own.
Future.value() — a Future that's already complete.
Future<int> getCachedOrFetch(String key) {
var cached = cache[key];
if (cached != null) {
return Future.value(cached); // already have it
}
return fetchFromNetwork(key); // need to fetch
}
Future.error() — a Future that's already failed.
Future<User> getUser(int id) {
if (id < 0) {
return Future.error(ArgumentError('ID must be positive'));
}
return fetchUser(id);
}
Future.delayed() — completes after a delay.
Future<String> fetchWithDelay() {
return Future.delayed(
Duration(seconds: 2),
() => 'Data loaded',
);
}
Completer — manual control over completion. Useful when bridging callback-based APIs to Future-based code.
Future<String> readFileCallback() {
var completer = Completer<String>();
legacyReadFile(
'data.txt',
onSuccess: (content) => completer.complete(content),
onError: (error) => completer.completeError(error),
);
return completer.future;
}
Warning: A Completer can only be completed once. Calling complete() or completeError() twice throws an error.
Test your understanding
7 questions
Seven questions covering Futures, async/await, and the event loop.