Here's a screen that loads a user's profile, their recent orders, and the store's featured products:
Future<void> loadDashboard() async {
final user = await userService.fetchUser();
final orders = await orderService.fetchRecent(user.id);
final featured = await productService.fetchFeatured();
setState(() {
_user = user;
_orders = orders;
_featured = featured;
});
}Three API calls. Each takes about 300ms. The dashboard loads in roughly 900ms. The developer looks at this and thinks: "Well, network calls take time. That's just how async works."
It's not. The featured products call doesn't depend on the user or the orders. It could have started at the same time as the user fetch. Two of these three calls can run concurrently, on the same single thread, without isolates or any additional machinery. The fix:
Future<void> loadDashboard() async {
final userFuture = userService.fetchUser();
final featuredFuture = productService.fetchFeatured();
final user = await userFuture;
final orders = await orderService.fetchRecent(user.id);
final featured = await featuredFuture;
setState(() {
_user = user;
_orders = orders;
_featured = featured;
});
}Now fetchUser() and fetchFeatured() start simultaneously. Both HTTP requests are in flight at the same time. The dashboard loads in ~600ms instead of ~900ms. Same thread. Same event loop. No parallelism — just better scheduling.
Understanding why this works requires understanding what a Future actually is, not just how to use one.
A Future is a state machine
A Future is not a value. It's not a callback. It's not a thread handle. It's a state machine with three states:
┌──────────────────┐
│ │
create() ────► │ Pending │
│ │
└──────┬───────────┘
│
┌────────────┼────────────┐
│ │ │
▼ │ ▼
┌───────────────┐ │ ┌────────────────┐
│ │ │ │ │
│ Completed │ │ │ Completed │
│ with value │ │ │ with error │
│ │ │ │ │
└───────────────┘ │ └────────────────┘
│
(never goes back)Pending — The operation hasn't finished. The Future doesn't have a value yet. Callbacks registered with .then() are stored in a list, waiting.
Completed with value — The operation succeeded. The Future holds the result. Any .then() callbacks are scheduled as microtasks immediately. Any new .then() callbacks added after completion are also scheduled as microtasks immediately — they don't miss the boat.
Completed with error — The operation failed. The Future holds the error. .catchError() and .onError() callbacks fire. If no error handler is registered, the error becomes an unhandled exception.
The transition from pending to completed happens exactly once. A Future never goes back to pending, and it never changes its completed value. It's immutable once complete — a final snapshot of the operation's outcome.
What `.then()` actually does
.then() doesn't run a callback. It registers a callback and returns a new Future.
final future = fetchUser();
final nameFuture = future.then((user) => user.name);Here's what happened:
fetchUser()returned aFuture<User>in the pending state. `.then((user) =- user.name)` did two things:
- Stored the callback `(user) =
- user.name
in the future's listener list - Created and returned a **new**Future<String>` (also pending)
- Nothing executed yet. The callback is just stored.
Later, when the HTTP response arrives and the original Future completes with a User value:
- The callback `(user) =
- user.name
is scheduled as a **microtask** ([Post 0](/blog/dart-event-loop-explained)) When that microtask runs, the callback executes and produces aStringThenameFuturecompletes with thatString`
This is why .then() chains work:
fetchUser()
.then((user) => fetchOrders(user.id)) // Returns Future<List<Order>>
.then((orders) => orders.length) // Returns Future<int>
.then((count) => print('$count orders'));Each .then() creates a new Future that completes when the callback finishes. If the callback itself returns a Future (like fetchOrders), the chain waits for that inner Future to complete before moving to the next .then(). The runtime handles the unwrapping — you don't get a Future<Future<List<Order>>>.
What `await` compiles to
await is syntactic sugar for .then() plus error handling plus a mechanism to suspend and resume the function. The compiler transforms your async function into a state machine — literally.
This code:
Future<String> greet() async {
final user = await fetchUser();
final greeting = 'Hello, ${user.name}';
return greeting;
}Is transformed by the compiler into something conceptually like:
Future<String> greet() {
final completer = Completer<String>();
// State 0: start
fetchUser().then((user) {
// State 1: after first await
try {
final greeting = 'Hello, ${user.name}';
completer.complete(greeting);
} catch (e, st) {
completer.completeError(e, st);
}
}).catchError((e, st) {
completer.completeError(e, st);
});
return completer.future;
}The real transformation is more complex (it handles multiple awaits, loops, try/catch, and arbitrary control flow), but the essence is the same: each await becomes a split point where the function returns a Future to its caller and registers a continuation callback.
This is why await doesn't block. The function literally returns at the await point. The rest of the function is a callback that runs later, as a microtask, when the awaited Future completes. The event loop continues processing other events in the meantime.
Three await statements in sequence = three split points = the function is chopped into four pieces, each scheduled as a microtask when the previous one completes.
Why the fast version works
Now the dashboard example from the opening makes sense.
Slow version:
final user = await userService.fetchUser(); // Start HTTP, suspend, resume when done
final orders = await orderService.fetchRecent(user.id); // Start HTTP, suspend, resume
final featured = await productService.fetchFeatured(); // Start HTTP, suspend, resumeEach await suspends the function until the Future completes. The second HTTP request doesn't start until the first response arrives. The third doesn't start until the second arrives. They're sequential — 300ms + 300ms + 300ms = 900ms.
Fast version:
final userFuture = userService.fetchUser(); // Start HTTP now (returns pending Future)
final featuredFuture = productService.fetchFeatured(); // Start HTTP now (returns pending Future)
final user = await userFuture; // Suspend until user arrives
final orders = await orderService.fetchRecent(user.id); // Start HTTP, suspend
final featured = await featuredFuture; // Already complete? Resume immediately.fetchUser() and fetchFeatured() are called without await. Both functions execute synchronously up to their first internal await — which starts the HTTP request and registers the completion callback. Both HTTP requests are now in flight simultaneously. The OS network stack handles both connections. Dart's event loop will receive both completions as I/O events.
When we await userFuture, the function suspends until the user data arrives (~300ms). During that 300ms, the featured products response likely also arrived. When we reach await featuredFuture, the Future is already completed — the await doesn't suspend at all, it continues immediately.
Total: ~300ms (user) + ~300ms (orders, sequential because it needs user.id) ≈ 600ms. The featured call was free — it ran during the user call's wait time.
`Future.wait` — the clean version
The pattern above works but is verbose. Future.wait is the idiomatic way to run independent Futures concurrently:
Future<void> loadDashboard() async {
// Start the independent calls concurrently
final results = await Future.wait([
userService.fetchUser(),
productService.fetchFeatured(),
]);
final user = results[0] as User;
final featured = results[1] as List<Product>;
// This one depends on user, so it's sequential
final orders = await orderService.fetchRecent(user.id);
setState(() {
_user = user;
_orders = orders;
_featured = featured;
});
}Future.wait takes a list of Futures and returns a single Future that completes when all of them complete. If any Future in the list completes with an error, the returned Future completes with that error. By default (eagerError: false), it waits for all Futures to finish before reporting the first error. With eagerError: true, it completes with the error immediately, without waiting for the others.
The typing is slightly awkward (you get a List<Object> when the Futures have different types). A cleaner pattern uses Dart 3 records:
Future<void> loadDashboard() async {
final (user, featured) = await (
userService.fetchUser(),
productService.fetchFeatured(),
).wait;
final orders = await orderService.fetchRecent(user.id);
setState(() {
_user = user;
_orders = orders;
_featured = featured;
});
}The .wait extension on record Futures preserves the types — user is User, featured is List<Product>. No casting. This was added in Dart 3.2 and is the recommended approach for concurrent Futures of different types.
Error propagation
Errors in Futures propagate through the chain, similar to exceptions in synchronous code. With async/await, this is straightforward — try/catch works:
try {
final user = await fetchUser();
print(user.name);
} catch (e) {
print('Failed: $e');
}If fetchUser() completes with an error, the await rethrows it, and catch handles it. This is one of the reasons async/await is preferred over .then() chains — error handling reads like synchronous code.
With .then() chains, error handling is more explicit:
fetchUser()
.then((user) => print(user.name))
.catchError((e) => print('Failed: $e'));The critical rule: an unhandled Future error crashes your app (or at least triggers FlutterError.onError in Flutter). If you create a Future and never await it, never call .then() or .catchError() on it, and it completes with an error — that error is "unhandled" and bubbles up to the zone's error handler.
This is the root cause of the "fire-and-forget Future" bug covered in our forgotten await article. A Future that's never awaited doesn't just lose its return value — it loses its error handling. The error might surface as an unhandled exception in a completely different part of the code, or it might crash the app with a stack trace that points to the wrong place.
The Completer: manual Future control
Most Futures are created implicitly — by async functions, by Future(), by I/O operations. But sometimes you need to create a Future and control when it completes from the outside. That's what Completer does.
class WebSocketConnection {
final _readyCompleter = Completer<void>();
Future<void> get ready => _readyCompleter.future;
void _onConnected() {
_readyCompleter.complete();
}
void _onError(Object error) {
if (!_readyCompleter.isCompleted) {
_readyCompleter.completeError(error);
}
}
}
// Usage:
final ws = WebSocketConnection();
await ws.ready; // Suspends until _onConnected() is calledA Completer creates a Future that you complete manually by calling .complete() or .completeError(). The Future starts in the pending state and transitions when you say so.
Critical rule: You can only complete a Completer once. Calling .complete() twice throws a StateError. This is the state machine enforcing its "completed is final" contract. The _readyCompleter.isCompleted check in the error handler above prevents this.
Completers are the escape hatch for bridging callback-based APIs to Future-based APIs. If a library gives you onSuccess/onError callbacks instead of returning a Future, wrap it in a Completer:
Future<String> readNfcTag() {
final completer = Completer<String>();
nfcPlugin.startReading(
onSuccess: (data) => completer.complete(data),
onError: (e) => completer.completeError(e),
);
return completer.future;
}`Future.any` — the race
Future.wait waits for all Futures to complete. Future.any completes as soon as the first Future in the list completes:
final result = await Future.any([
fetchFromCache(), // Returns in 5ms if cached
fetchFromNetwork(), // Returns in 300ms
Future.delayed( // Timeout after 5 seconds
Duration(seconds: 5),
() => throw TimeoutException('Too slow'),
),
]);Whichever Future completes first wins. The others are not cancelled — there's no cancellation mechanism for Futures in Dart (unlike CancellationToken in C# or AbortController in JavaScript). The other Futures keep running; their results are just ignored.
This is worth remembering: starting a Future is a commitment. The work happens regardless of whether you await the result. If fetchFromCache() wins the race, the network request still completes in the background. If it has side effects (writing to a database, sending analytics), those side effects still happen.
Futures are not lazy
In some languages, futures/promises are lazy — they don't start executing until someone awaits or subscribes. Dart Futures are eager. The moment you call fetchUser(), the HTTP request starts. The Future is already pending and the work is in motion.
final future = fetchUser(); // HTTP request starts NOW
// ... do other things ...
await future; // Just waiting for the result — the request is already in flightThis is why the fast dashboard version works: calling the function is starting the work. The await is just the point where you say "I need the result now."
It also means that creating a Future has consequences even if you never use it:
void oops() {
fetchUser(); // HTTP request fires. Response arrives. Nobody handles the error.
}This is a fire-and-forget Future. If fetchUser() fails, the error goes unhandled. The request's side effects (server-side logging, rate-limit counting) still happen. A Future is a commitment to do work, not a plan that waits for approval.
The mental model
A Future is a state machine with three states. await is syntactic sugar that splits your function at each suspension point and chains the pieces together with .then(). The event loop (Post 0) processes the completions as microtasks. Independent Futures can run concurrently because "concurrency" on a single thread just means "multiple I/O operations in flight, with the OS handling the actual waiting."
The practical rules:
- Sequential `await`: use when each call depends on the previous result
- Start-then-await or `Future.wait`: use when calls are independent
- Always handle errors:
try/catchwithawait, or.catchError()with.then() - Never fire-and-forget: if you call an async function,
awaitit or handle its Future - Futures are eager: the work starts when you call the function, not when you
await
The next post looks at Streams — the async primitive for sequences of values over time, and the mechanism underneath BLoC's state management.
This is Post 1 of the Async Dart series. Previous: The Event Loop. Next: Streams: The Async Iterator.