async & await in Dart
This is Part 3 of the Async & Concurrency in Dart series. You know the event loop (Part 1) and the Future (Part 2). Now we get to the syntax you'll type every single day: async and await.
The good news: async/await is not new machinery. It's a thin, readable skin over the futures you already understand. The better news: once you see what it compiles to, the classic mistakes — forgotten awaits, accidental sequential loops, "why didn't my error get caught?" — become obvious and avoidable. This post is half how it works, half how people get it wrong.
async/await is just .then() in disguise
Here are the same two-step operation written both ways:
// Callback style (Part 2)
Future<String> greetCallback() {
return fetchUserId().then((id) {
return fetchNameById(id).then((name) {
return 'Hello, $name';
});
});
}
// async/await style — identical behavior, linear shape
Future<String> greetAwait() async {
final id = await fetchUserId();
final name = await fetchNameById(id);
return 'Hello, $name';
}
Same futures, same event-loop scheduling, same result. await just lets you write asynchronous code that reads top-to-bottom like synchronous code. The two rules:
- Mark a function
asyncto useawaitinside it. Anasyncfunction always returns aFuture. awaitpauses the function until the awaited future completes, then gives you its value (or throws its error).
From Part 1, never forget: an
asyncfunction runs synchronously up to its firstawait. The pause-and-resume only kicks in at the firstawait. Everything before it executes in the current event-loop turn.
What async does to the return type
Future<int> answer() async => 42; // you return 42, caller gets Future<int>
Future<void> doStuff() async { ... } // no value, but still tells you WHEN it's done
Even return 42 inside an async function becomes a Future<int> to the caller. You can't "escape" the future — and you shouldn't want to. It's how the caller knows when you're finished.
Mistake #1: forgetting await (the silent killer)
The most common async bug in all of Dart:
Future<void> saveProfile() async {
await database.save(profile);
print('saved');
}
void onTapSave() {
saveProfile(); // ❌ no await — fire and (accidentally) forget
showSnackBar('Saved!'); // shows INSTANTLY, before the save finishes
}
Without await, saveProfile() returns a future that nobody waits on. showSnackBar('Saved!') runs immediately — you're lying to the user, who sees "Saved!" before the write completes (or even if it fails). Worse, any error inside an unawaited future can become an unhandled error that bypasses your try/catch.
Future<void> onTapSave() async {
await saveProfile(); // ✅ wait for the real result
showSnackBar('Saved!'); // now this is truthful
}
Tooling saves you here. Enable the
unawaited_futureslint. When you intentionally don't await (rare), mark it explicitly withunawaited(...)fromdart:asyncso readers know it's deliberate:import 'dart:async'; unawaited(analytics.log('save_tapped')); // fire-and-forget, on purpose
Mistake #2: await inside a loop when you don't need order
// ❌ SLOW: each URL waits for the previous one to finish.
Future<List<String>> fetchAllSlow(List<String> urls) async {
final results = <String>[];
for (final url in urls) {
results.add(await http.get(url)); // 10 urls × 1s each = ~10s
}
return results;
}
Each await fully blocks the loop before starting the next request. If the requests are independent, that's wasteful. Fire them all, then await together:
// ✅ FAST: all requests in flight at once.
Future<List<String>> fetchAllFast(List<String> urls) async {
final futures = urls.map((url) => http.get(url)); // start everything
return await Future.wait(futures); // ~1s total
}
The mental test: does request N depend on the result of request N-1?
- Yes (each needs the previous) → sequential
awaitin a loop is correct. - No (independent) →
Future.waitto overlap them.
⚠️ Don't confuse this with
await for(used on streams, Part 4) orforEachwith an async callback —myList.forEach((e) async {...})does not wait for the bodies and silently fires futures into the void. Use a realforloop when you need to await.
Mistake #3: assuming try/catch catches an un-awaited error
Future<void> load() async {
try {
fetchData(); // ❌ not awaited — the error escapes this try/catch
} catch (e) {
print('caught: $e'); // never runs; error becomes unhandled
}
}
try/catch only catches an async error if you await the future inside the try. Without the await, the function moves on and the error surfaces later, outside the block:
Future<void> load() async {
try {
await fetchData(); // ✅ now the error lands in catch
} catch (e) {
print('caught: $e');
}
}
We'll devote all of Part 7 to async error handling — but this one rule prevents most surprises: no await, no catch.
Mistake #4: making build() (or any sync API) async
A Flutter build method, an override that must return a non-future, an == operator — these can't be async. You can't await your way out of a synchronous contract:
// ❌ build must return a Widget synchronously — you can't await here.
@override
Widget build(BuildContext context) async { ... } // not allowed
The right pattern is to let the framework wait for you: hold the future and let a FutureBuilder (or StreamBuilder for streams) rebuild when it resolves:
// ✅ Kick off the future once (e.g. in initState), render its states.
@override
Widget build(BuildContext context) {
return FutureBuilder<User>(
future: _userFuture, // created ONCE, not rebuilt every frame
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) return Text('Error: ${snapshot.error}');
return Text('Hi ${snapshot.data!.name}');
},
);
}
Gotcha within the gotcha: never create the future inside
build(future: fetchUser()).buildruns on every frame, so you'd refire the request constantly. Create it once (ininitStateor a state manager) and pass the stored future in.
Mistake #5: await-ing things that aren't futures (and over-awaiting)
await on a non-future just returns the value immediately — harmless but pointless, and it still yields to the event loop (costing a microtask hop). More importantly, don't sprinkle async/await where there's nothing asynchronous:
// Pointless async — there's nothing to wait for.
Future<int> addAsync(int a, int b) async => a + b; // just return a + b normally
Keep functions synchronous unless they genuinely do async work. Every async boundary adds an event-loop hop and a Future wrapper.
Best practices checklist
A distilled set of habits that prevent most async bugs:
- Always
await(or deliberatelyunawaited) every future. Turn on theunawaited_futuresanddiscarded_futureslints. - Sequential only when dependent. Independent work →
Future.wait. try/catchrequiresawaitto catch async errors.- Type your return as
Future<T>(orFuture<void>) so callers know it's async. - Don't make synchronous contracts async — use
FutureBuilder/StreamBuilderin UI, and create the future once. - Don't over-async — keep pure/synchronous code synchronous.
- Guard against using a disposed widget after an
await: in Flutter, checkif (!mounted) return;before touchingcontext/setStateonce anawaithas resumed.
That last one bites everyone eventually:
Future<void> onSubmit() async {
final result = await api.submit(form);
if (!mounted) return; // ✅ the widget may be gone after the await
setState(() => _result = result);
}
Putting it together
A small, realistic flow showing dependent vs independent work, error handling, and cleanup:
Future<Dashboard> loadDashboard(String userId) async {
showLoading();
try {
// Dependent: we need the user before we can load THEIR data.
final user = await fetchUser(userId);
// Independent: posts and friends don't depend on each other → overlap.
final results = await Future.wait([
fetchPosts(user.id),
fetchFriends(user.id),
]);
return Dashboard(user: user, posts: results[0], friends: results[1]);
} catch (e) {
log('dashboard failed: $e');
rethrow; // let the caller (and UI) react
} finally {
hideLoading(); // always, success or failure
}
}
Notice: the dependent step is a plain await; the independent steps overlap with Future.wait; errors are caught (then rethrown for the UI); cleanup lives in finally. That's idiomatic async Dart.
Practice Challenges
Challenge 1 — Rewrite callbacks as await. Convert this to async/await:
Future<String> load() {
return fetchToken().then((t) => fetchUser(t)).then((u) => u.name);
}
Show solution
Future<String> load() async {
final t = await fetchToken();
final u = await fetchUser(t);
return u.name;
}
Same behavior, linear shape. Each .then becomes an await.
Challenge 2 — Fix the slow loop. These 5 independent fetches each take 1s. Make the total ~1s.
Future<List<int>> getAll(List<int> ids) async {
final out = <int>[];
for (final id in ids) {
out.add(await fetchScore(id));
}
return out;
}
Show solution
Future<List<int>> getAll(List<int> ids) async {
return await Future.wait(ids.map(fetchScore));
}
The fetches are independent, so start them all and Future.wait. ~1s instead of ~5s. (Keep the original loop only if each fetch depended on the previous result.)
Challenge 3 — Why isn't this caught? Explain and fix.
Future<void> run() async {
try {
riskyAsync(); // throws after 1s
} catch (e) {
print('handled');
}
}
Show solution
riskyAsync() isn't awaited, so run exits the try block immediately and the error surfaces later as an unhandled async error — catch never sees it. Fix by adding await:
try {
await riskyAsync();
} catch (e) {
print('handled');
}
No await, no catch.
Challenge 4 — Guard after await. This Flutter handler sometimes crashes with "setState after dispose". Fix it.
Future<void> refresh() async {
final data = await api.fetch();
setState(() => _data = data);
}
Show solution
Future<void> refresh() async {
final data = await api.fetch();
if (!mounted) return; // widget may have been disposed during the await
setState(() => _data = data);
}
After an await, time has passed and the widget might be gone. Check mounted before using setState/context.
Challenge 5 — Predict the order. What prints, and in what order?
Future<void> main() async {
print('A');
Future<void> task() async {
print('B');
await Future.delayed(Duration.zero);
print('C');
}
task();
print('D');
}
Show answer
A
B
D
C
A prints. task() runs synchronously to its first await, printing B. The await defers the rest, so control returns and D prints. Later the continuation prints C. (Exactly the "runs synchronously until the first await" rule from Part 1.)
Questions to test yourself
Q1 (basic). What two keywords does async/await add, and what does each require/do?
Show answer
async marks a function so it can use await and makes it return a Future. await pauses the async function until the awaited future completes, then yields its value (or throws its error). You can only use await inside an async function.
Q2 (basic). What does an async function return if its body does return 42;?
Show answer
A Future<int> that completes with 42. An async function always returns a future; the returned value becomes the future's completion value.
Q3 (intermediate). You have a loop doing await fetch(item) for 10 independent items, and it's slow. What's the fix, and when would the loop actually be correct?
Show answer
Replace the sequential loop with await Future.wait(items.map(fetch)) so all requests overlap (~1× instead of ~10× the latency). The sequential loop is only correct when each iteration depends on the previous result (must run in order).
Q4 (intermediate). Why does a try/catch sometimes fail to catch an error from an async call inside it?
Show answer
Because the future wasn't awaited. try/catch only catches an async error if you await the future inside the try. Without await, execution leaves the block before the future fails, so the error escapes as an unhandled async error. No await, no catch.
Q5 (advanced). Why can't build() be async, and what's the idiomatic way to show async data in Flutter?
Show answer
build() has a synchronous contract — it must return a Widget now, for the current frame; an async function returns a Future<Widget>, which the framework can't paint. Instead, kick off the future once (e.g. in initState or a state manager) and render it with a FutureBuilder (or StreamBuilder), which rebuilds when the future resolves. Never create the future inside build, or it refires every frame.
Q6 (advanced). After await api.call() in a Flutter State, why might setState throw, and how do you prevent it?
Show answer
The await yields control; by the time it resumes, the user may have navigated away and the State may have been disposed. Calling setState (or using context) on a disposed State throws. Guard with if (!mounted) return; immediately after the await before touching setState/context. This "use after await" hazard exists because async resumption happens at an unknown later time.
Wrapping up
async/await is readable futures — nothing more, nothing less:
- It compiles down to the
.then()chains from Part 2; same event-loop behavior, linear syntax. - An
asyncfunction runs synchronously until its firstawaitand always returns aFuture. - The big mistakes: forgetting
await(silent bugs + escaped errors), sequential loops for independent work (useFuture.wait),try/catchwithoutawait, making sync contracts async, and using a disposed widget afterawait. - Let lints (
unawaited_futures,discarded_futures) andFutureBuilderdo the heavy lifting.
So far everything completes once. But lots of real-world data arrives as a sequence over time — socket messages, key presses, location updates. For that, one future isn't enough. In Part 4 we meet the Stream: a future that keeps on giving.