← Back to blog
Async & Concurrency in Dart · Part 2 of 10
July 6, 202611 min read

Futures in Dart: The Object Behind async/await

DartAsyncFlutter

Futures in Dart

This is Part 2 of the Async & Concurrency in Dart series. In Part 1 you learned where deferred work goes — the event and microtask queues. Now we meet the object that represents a single piece of deferred work: the Future.

Most tutorials jump straight to async/await and never explain the thing those keywords operate on. That's a mistake. await is just convenient syntax over a Future, and when async code misbehaves, it's the Future underneath you need to understand. So let's build that foundation properly — then async/await (in Part 3) will feel obvious.


What is a Future, really?

A Future<T> is an object that represents a value that isn't available yet, but will be (or will fail) at some point later. The T is the type you'll eventually get.

The classic analogy: a Future is a restaurant buzzer. You order food, and instead of standing at the counter blocking the line, you're handed a buzzer. The buzzer isn't your food — it's a promise of food. You go sit down (your program does other work). Later the buzzer either lights up "ready" (completes with a value) or the staff comes over to say "sorry, we're out" (completes with an error).

// This function hands you a buzzer (Future<String>) immediately.
// The actual String shows up 2 seconds later.
Future<String> fetchUsername() {
  return Future.delayed(
    const Duration(seconds: 2),
    () => 'Vivek',
  );
}

void main() {
  print('Ordering...');
  Future<String> buzzer = fetchUsername(); // returns instantly
  print('Buzzer in hand: $buzzer');         // Instance of 'Future<String>'
  print('Doing other work while we wait...');
}

Calling fetchUsername() returns immediately with a Future<String> — not the string. The string materializes later. That gap is the whole point: your thread isn't blocked during those 2 seconds.


A Future's two states (and three outcomes)

The official model is simple. A Future is in one of two states:

  • Uncompleted — the operation is still in flight (the buzzer hasn't lit up).
  • Completed — done. Completion happens in one of two ways:
    • Completed with a value of type T (success), or
    • Completed with an error (failure).
                ┌─────────────────► completed with VALUE  (T)
  uncompleted ──┤
                └─────────────────► completed with ERROR

A Future completes exactly once and then never changes. This immutability is why futures are predictable: once that buzzer lights up, it stays lit. If a function has no useful value to return, its type is Future<void> — it still tells you when the work finished, just not what it produced.


Reading a Future with .then()

Before async/await existed, you read a future's value by registering a callback with .then():

void main() {
  fetchUsername().then((name) {
    print('Got the name: $name'); // runs when the future completes
  });
  print('This prints FIRST');
}

Output:

This prints FIRST
Got the name: Vivek

.then() says: "when this future completes with a value, run this callback with it." The callback is queued (remember the event loop from Part 1) — so 'This prints FIRST' runs before it.

Chaining .then()

.then() itself returns a new future, so you can chain steps. Each step waits for the previous one:

Future<int> fetchUserId() => Future.delayed(const Duration(seconds: 1), () => 42);
Future<String> fetchNameById(int id) =>
    Future.delayed(const Duration(seconds: 1), () => 'User#$id');

void main() {
  fetchUserId()
      .then((id) => fetchNameById(id)) // return a Future → it gets unwrapped
      .then((name) => print('Resolved: $name'));
}

A subtle but important rule: if a .then() callback returns a Future, Dart flattens it — the chain waits for that inner future too. You never end up with a Future<Future<String>>. This flattening is exactly what makes sequential async steps readable.

Foreshadow: that chain is precisely what async/await writes for you. await a; await b; is a.then((_) => b) under the hood — just without the nesting. Hold that thought for Part 3.


Handling errors: .catchError() and .whenComplete()

A future can complete with an error. With the callback API you handle it via .catchError(), and run cleanup with .whenComplete():

Future<double> divide(int a, int b) {
  return Future(() {
    if (b == 0) throw ArgumentError('Cannot divide by zero');
    return a / b;
  });
}

void main() {
  divide(10, 0)
      .then((result) => print('Result: $result'))
      .catchError((error) => print('Caught: $error'))
      .whenComplete(() => print('Done (success or failure)'));
}

Output:

Caught: Invalid argument(s): Cannot divide by zero
Done (success or failure)

The mental model maps cleanly onto try/catch/finally:

| Callback API | try/catch equivalent | Runs when | | --- | --- | --- | | .then(...) | the try body after await | future completes with a value | | .catchError(...) | catch (e) | future completes with an error | | .whenComplete(...) | finally | always, value or error |

.whenComplete() is your finally: close a file, hide a loading spinner, release a lock — regardless of outcome. We'll go much deeper on async errors in Part 7.


Creating futures yourself

You'll often consume futures from libraries, but knowing how to make them is essential.

1. Future.value and Future.error — already done

Future<int> cached = Future.value(7);        // completes with 7
Future<int> failed = Future.error('nope');   // completes with an error

Useful when an API must return a Future but you already have the answer (e.g. a cache hit).

2. Future.delayed — complete after a timer

Future<String> greetLater =
    Future.delayed(const Duration(seconds: 1), () => 'Hello!');

Great for simulating latency in examples and for retry/backoff timing. (Don't use it to "wait for" something that isn't time-based — that's a code smell.)

3. Future(() {...}) — run on the event queue

Future<int> computed = Future(() {
  // queued on the EVENT queue, runs after current sync code
  return 2 + 2;
});

4. Completer — complete a future manually

When the thing that finishes isn't itself a future (a callback-based API, a button press, a native event), wrap it with a Completer:

import 'dart:async';

Future<String> waitForButton() {
  final completer = Completer<String>();

  // Imagine this is wired to a real event/callback:
  someButton.onClick = () => completer.complete('clicked!');
  // On failure you'd call: completer.completeError(...);

  return completer.future; // hand out the buzzer now
}

A Completer gives you the buzzer (completer.future) and the switch to light it up (completer.complete(...) / completer.completeError(...)). Rule: call complete exactly once. It's the bridge from any "calls me back later" API into the future world.


Running futures in parallel: Future.wait

Sequential awaits are easy but can be needlessly slow. If three requests don't depend on each other, fire them together and wait for all with Future.wait:

Future<String> fetchProfile() => Future.delayed(const Duration(seconds: 2), () => 'profile');
Future<String> fetchPosts()   => Future.delayed(const Duration(seconds: 2), () => 'posts');
Future<String> fetchFriends() => Future.delayed(const Duration(seconds: 2), () => 'friends');

Future<void> loadDashboard() async {
  // All three start NOW, overlapping. Total wait ≈ 2s, not 6s.
  final results = await Future.wait([
    fetchProfile(),
    fetchPosts(),
    fetchFriends(),
  ]);
  print(results); // [profile, posts, friends]
}

Because the slow part happens off-thread, overlapping three 2-second waits costs ~2 seconds, not 6. This is one of the highest-leverage async optimizations you can make — and a classic interview question.

⚠️ With Future.wait, if any future errors, the returned future errors. Related helpers worth knowing: Future.any (first to finish wins) and .timeout(Duration) (fail if it takes too long).


A common gotcha: forgetting it's a Future

Future<int> getCount() => Future.value(5);

void main() {
  int count = getCount(); // ❌ compile error: Future<int> is not int
  print(count + 1);
}

getCount() returns a buzzer, not a number — you can't add 1 to a buzzer. You must either await it (inside an async function) or use .then(). Mixing up "the future" and "the future's value" is the #1 beginner mistake, and it's exactly what async/await makes harder to get wrong — which is where we go next.


Practice Challenges

Challenge 1 — Make a delayed future. Write a function Future<int> answer() that completes with 42 after one second, and print it with .then().

Show solution
Future<int> answer() =>
    Future.delayed(const Duration(seconds: 1), () => 42);

void main() {
  answer().then((value) => print(value)); // 42 (after 1s)
}

Challenge 2 — Chain two steps. Given Future<int> double(int x) that returns x * 2 after a delay, chain it to double 5 and then double the result, printing 20.

Show solution
Future<int> doubleIt(int x) =>
    Future.delayed(const Duration(milliseconds: 300), () => x * 2);

void main() {
  doubleIt(5)
      .then((r) => doubleIt(r)) // returns a Future → flattened
      .then((r) => print(r));   // 20
}

Returning a Future from .then() makes the chain wait for it — no Future<Future<int>>.

Challenge 3 — Handle an error. Make a future that throws, and print a friendly message instead of crashing, plus a "cleanup" line that always runs.

Show solution
Future<int> risky() => Future(() => throw StateError('boom'));

void main() {
  risky()
      .then((v) => print('value: $v'))
      .catchError((e) => print('handled: $e'))
      .whenComplete(() => print('cleanup ran'));
}
// handled: Bad state: boom
// cleanup ran

Challenge 4 — Parallel vs sequential. Two independent calls each take 2s. Show how to get both results in ~2s total.

Show solution
Future<String> a() => Future.delayed(const Duration(seconds: 2), () => 'A');
Future<String> b() => Future.delayed(const Duration(seconds: 2), () => 'B');

Future<void> main() async {
  final results = await Future.wait([a(), b()]); // start both, ~2s total
  print(results); // [A, B]
}

Awaiting them one after another (await a(); await b();) would take ~4s instead.

Challenge 5 — Bridge a callback with Completer. Wrap a fake loadConfig(onDone) callback API into a Future<String>.

Show solution
import 'dart:async';

void loadConfig(void Function(String) onDone) {
  Future.delayed(const Duration(seconds: 1), () => onDone('config-data'));
}

Future<String> loadConfigFuture() {
  final completer = Completer<String>();
  loadConfig((data) => completer.complete(data));
  return completer.future;
}

void main() async {
  print(await loadConfigFuture()); // config-data
}

Completer turns a callback-style API into a future you can await.


Questions to test yourself

Q1 (basic). In one sentence, what does a Future<T> represent?

Show answer

A value of type T that isn't available yet but will be produced — or will fail with an error — at some later point. It's a placeholder/promise for a future result.

Q2 (basic). What are the possible states/outcomes of a future?

Show answer

Uncompleted (still running) or completed. Completion is either with a value (success) or with an error (failure). A future completes exactly once and never changes afterward.

Q3 (intermediate). What's the relationship between .then()/.catchError()/.whenComplete() and try/catch/finally?

Show answer

They mirror each other. .then() is the success path (like the code after await in a try), .catchError() is catch, and .whenComplete() is finally — it runs whether the future succeeded or failed. async/await lets you write the try/catch/finally form directly.

Q4 (intermediate). If a .then() callback returns a Future<String>, what type does the chain produce — Future<Future<String>> or Future<String>?

Show answer

Future<String>. Dart flattens nested futures: when a .then() callback returns a future, the chain waits for that inner future and adopts its value. You never get Future<Future<...>>. This flattening is what makes sequential async steps compose cleanly.

Q5 (advanced). When would you use a Completer instead of Future.delayed or async/await?

Show answer

When the completion is driven by something that isn't already a future — a callback-based API, a one-shot event/listener, a button tap, or a native platform callback. A Completer lets you hand out completer.future now and later fire complete(value) / completeError(e) from whatever code learns the result. (Call complete only once.)

Q6 (advanced). Two API calls don't depend on each other and take 3s each. Compare await a(); await b(); to await Future.wait([a(), b()]) in timing, and explain why the difference exists given Dart's single thread.

Show answer

Sequential await a(); await b(); takes ~6s — the second call doesn't even start until the first completes. Future.wait([a(), b()]) starts both immediately and finishes in ~3s. This works on a single thread because the slow part (I/O) happens off-thread; both buzzers are "out" at the same time, and Dart simply waits for both to light up. The thread isn't doing two things at once — it's waiting on two things at once.


Wrapping up

Future is the atom of async Dart:

  • A Future<T> is a promise of a value (or error) that arrives later — a buzzer, not the food.
  • It's uncompleted until it completes once with a value or an error.
  • The callback API — .then() / .catchError() / .whenComplete() — maps onto try/catch/finally, and .then() flattens returned futures so chains stay clean.
  • Create futures with Future.value, Future.error, Future.delayed, Future(() {...}), or bridge callbacks with Completer.
  • Run independent work concurrently with Future.wait to collapse total wait time.

You now understand the object. In Part 3 we replace all this .then() chaining with the syntax you'll actually write every day — async and await — and tackle the common mistakes that trip up even experienced developers.