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

async & await in Dart: Common Mistakes and Best Practices

DartAsyncFlutter

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:

  1. Mark a function async to use await inside it. An async function always returns a Future.
  2. await pauses the function until the awaited future completes, then gives you its value (or throws its error).

From Part 1, never forget: an async function runs synchronously up to its first await. The pause-and-resume only kicks in at the first await. 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_futures lint. When you intentionally don't await (rare), mark it explicitly with unawaited(...) from dart:async so 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 await in a loop is correct.
  • No (independent) → Future.wait to overlap them.

⚠️ Don't confuse this with await for (used on streams, Part 4) or forEach with an async callback — myList.forEach((e) async {...}) does not wait for the bodies and silently fires futures into the void. Use a real for loop 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()). build runs on every frame, so you'd refire the request constantly. Create it once (in initState or 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:

  1. Always await (or deliberately unawaited) every future. Turn on the unawaited_futures and discarded_futures lints.
  2. Sequential only when dependent. Independent work → Future.wait.
  3. try/catch requires await to catch async errors.
  4. Type your return as Future<T> (or Future<void>) so callers know it's async.
  5. Don't make synchronous contracts async — use FutureBuilder/StreamBuilder in UI, and create the future once.
  6. Don't over-async — keep pure/synchronous code synchronous.
  7. Guard against using a disposed widget after an await: in Flutter, check if (!mounted) return; before touching context/setState once an await has 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 async function runs synchronously until its first await and always returns a Future.
  • The big mistakes: forgetting await (silent bugs + escaped errors), sequential loops for independent work (use Future.wait), try/catch without await, making sync contracts async, and using a disposed widget after await.
  • Let lints (unawaited_futures, discarded_futures) and FutureBuilder do 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.