Ankit Ranjan
Back to Deep Dives

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.

May 24, 2026 7 topics 7 quiz questions
Share:
1

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.

Synchronous vs Asynchronous executionSynchronous (blocking)fetch data (500ms)processrenderTotal: 500ms blocked, then work beginsUI frozen during fetch!Asynchronous (non-blocking)start fetchanimatescrolltap...waiting...data arrives!processUI stays responsive during fetchUser can interact the whole time!

Dart's answer to this is the Future — an object that represents a value that will exist later.

2

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:

The three states of a FutureUncompletedOperation in progressNo value yetwaiting...Completed (value)Success! Value availableFuture<String> → 'Ankit'Completed (error)Failed! Error availableFuture<String> → ErrorOnce completed:• State never changes• Value/error is cached• Can be awaited again

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.

3

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.

4

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();
  }
}

5

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.

The Dart Event LoopEvent Queueuser taptimer callbacknetwork responsemore events...Events processed oneat a time, in orderdequeueDart Runtime1. Pick event from queue2. Run to completion3. Pick next event4. Repeat foreverMicrotask QueueHigher priority than eventsSchedulled by Future.then(),scheduleMicrotask(), etc.Drained before next eventKey Points• Single-threaded• One event at a time• await = schedule later• Never blocks the loop• Long sync code freezes the entire app!• Keep event handlers short and fast

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

6

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(),
]);

7

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.

Search

Loading search...