HomeDocumentationThe async nature of Dart
The async nature of Dart
14

Futures: Promises With a Dart Accent

Dart Futures Explained — State Machine, await Desugaring, and Performance

April 2, 2026

Here's a screen that loads a user's profile, their recent orders, and the store's featured products:

dart
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:

dart
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:

javascript
                    ┌──────────────────┐
                    │                  │
    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.

dart
final future = fetchUser();

final nameFuture = future.then((user) => user.name);

Here's what happened:

  • fetchUser() returned a Future<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)
  1. 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 a String The nameFuture completes with that String`

This is why .then() chains work:

dart
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:

dart
Future<String> greet() async {
  final user = await fetchUser();
  final greeting = 'Hello, ${user.name}';
  return greeting;
}

Is transformed by the compiler into something conceptually like:

dart
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:

dart
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, resume

Each 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:

dart
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:

dart
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:

dart
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:

dart
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:

dart
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.

dart
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 called

A 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:

dart
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:

dart
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.

dart
final future = fetchUser(); // HTTP request starts NOW
// ... do other things ...
await future; // Just waiting for the result — the request is already in flight

This 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:

dart
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/catch with await, or .catchError() with .then()
  • Never fire-and-forget: if you call an async function, await it 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.

Related Topics

dart future explaineddart await how it worksfuture.wait dartdart async performancedart completerflutter future tutorialdart future state machinedart then vs await

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.