Blog
13

The Forgotten `await`: Flutter's Silent Race Condition

Flutter's Silent Race Condition: The Forgotten await in BLoC Event Handlers

March 26, 2026

The bug was simple: search results appeared before the search finished.

The user typed a query, pressed search, and the screen immediately showed "No results found" — then, half a second later, the results appeared. Sometimes. Other times the results showed correctly on the first try. Restart the app and it worked. Try again and it didn't.

No error in the console. No crash. No red screen. Just a UI that sometimes lied about the state of the world for a few hundred milliseconds.

The cause was a single missing word in a single line of code:

dart
// The bug:
searchClientLeads(event.query);

// The fix:
await searchClientLeads(event.query);

Five characters. The difference between "run this and wait for the result" and "start this and immediately move on."

What `await` Actually Does

Dart is single-threaded but asynchronous. When you call an async function, it returns a Future — a promise that a value will arrive later. The function starts executing, hits its first await internally, and yields control back to the caller.

Without await, the caller gets the Future object and ignores it. Execution continues to the next line. The async work happens eventually, but the caller doesn't wait for it.

dart
Future<List<Lead>> searchClientLeads(String query) async {
  final response = await httpClient.get('/api/leads?q=$query');
  return response.data.map((json) => Lead.fromJson(json)).toList();
}

When you call this function without await:

dart
void _onSearch(SearchEvent event, Emitter<LeadState> emit) {
  emit(LeadState.loading());

  searchClientLeads(event.query); // no await — returns Future, ignored

  emit(LeadState.loaded(results: [])); // runs immediately
}

Here's what happens in order:

  1. LeadState.loading() is emitted — the loading spinner appears
  2. searchClientLeads starts — the HTTP request begins
  3. LeadState.loaded(results: []) is emitted immediately — "No results" shows
  4. Some time later, the HTTP response arrives — but nobody is listening for it

The state machine just lied to the UI. It said "loaded with zero results" while the actual search was still in flight. The results, when they eventually arrive, are silently discarded.

With await

dart
Future<void> _onSearch(SearchEvent event, Emitter<LeadState> emit) async {
  emit(LeadState.loading());

  final results = await searchClientLeads(event.query); // waits

  emit(LeadState.loaded(results: results)); // runs after results arrive
}

Now the event handler pauses at the await line. Dart yields control to the event loop, the HTTP request completes, and only then does the handler resume and emit the loaded state with actual data. The state machine tells the truth.

Why the Compiler Doesn't Help

This is the frustrating part. Dart has a lint for this: unawaited_futures. But it's not enabled by default. Without it, calling an async function without await is perfectly valid Dart. The function returns a Future<void> or Future<List<Lead>>, and you're allowed to ignore return values.

The compiler sees:

dart
searchClientLeads(event.query);

And thinks: "A function call. Return value not used. That's fine." It doesn't know — and can't know — that ignoring this particular return value means ignoring the entire point of calling the function.

Enable the Lint

In your analysis_options.yaml:

yaml
linter:
  rules:
    - unawaited_futures

Now the analyzer warns on every unawaited Future. If you intentionally want to fire-and-forget (logging, analytics, pre-caching), you use unawaited() to make the intention explicit:

dart
import 'dart:async';

unawaited(analytics.logEvent('search', query)); // intentional fire-and-forget

This tells both the analyzer and future readers: "Yes, I know this returns a Future. No, I don't need to wait for it."

The Patterns That Breed This Bug

1. Refactoring From Sync to Async

The function started synchronous — maybe it searched a local cache:

dart
List<Lead> searchClientLeads(String query) {
  return _cache.where((lead) => lead.name.contains(query)).toList();
}

Every caller worked fine without await because there was no Future. Then someone added an API fallback:

dart
Future<List<Lead>> searchClientLeads(String query) async {
  final cached = _cache.where((lead) => lead.name.contains(query)).toList();
  if (cached.isNotEmpty) return cached;
  return await _repository.searchLeads(query);
}

The function is now async. The callers still compile because ignoring a return value is valid. But they're all broken — they all continue executing as if the search already completed.

This is the most dangerous version of the bug because it's introduced by a change in one file and silently breaks every caller across the codebase.

2. BLoC Event Handlers That Forget async

dart
// Missing both async and await:
void _onSearch(SearchEvent event, Emitter<LeadState> emit) {
  emit(LeadState.loading());
  final results = searchUseCase(event.query); // results is Future<List<Lead>>, not List<Lead>
  emit(LeadState.loaded(results: results)); // type error if you're lucky, silent bug if not
}

If LeadState.loaded accepts dynamic or Object (which happens with some state management patterns), this compiles. The state now carries a Future object instead of actual data. The UI tries to render a Future as a list and either crashes or shows nothing.

3. Multiple Awaits With Only One Awaited

dart
Future<void> _onRefresh(RefreshEvent event, Emitter<DashboardState> emit) async {
  emit(DashboardState.loading());

  final leads = await leadsUseCase.getAll();
  final stats = statsUseCase.getStats(); // forgot await
  final notifications = notificationsUseCase.getRecent(); // forgot await

  emit(DashboardState.loaded(
    leads: leads,
    stats: stats,       // Future<Stats>, not Stats
    notifications: notifications, // Future<List<Notification>>, not List<Notification>
  ));
}

You awaited the first one. You forgot the other two. This is especially common in dashboard-style handlers that aggregate multiple data sources. The first call works, the other two are either type errors or silently wrong.

The Race Condition Version

The fire-and-forget bug above is deterministic — it always shows wrong data immediately. But there's a subtler variant that's genuinely intermittent:

dart
Future<void> _onSearch(SearchEvent event, Emitter<LeadState> emit) async {
  emit(LeadState.loading());

  searchClientLeads(event.query).then((results) {
    emit(LeadState.loaded(results: results)); // emit inside .then()
  });

  // execution continues here immediately
}

This uses .then() instead of await. The emit inside .then() will fire — but the event handler returns immediately, and BLoC may process the next event before .then() completes. If the user types fast, you get overlapping searches where old results arrive after new ones, overwriting correct data with stale data.

This is a true race condition: the order of operations depends on network timing. It works on fast connections, fails on slow ones, and is nearly impossible to reproduce in a debugger because pausing changes the timing.

The fix is always the same: await.

dart
Future<void> _onSearch(SearchEvent event, Emitter<LeadState> emit) async {
  emit(LeadState.loading());
  final results = await searchClientLeads(event.query);
  emit(LeadState.loaded(results: results));
}

Sequential, predictable, debuggable.

Why This Matters in Clean Architecture

In clean architecture, async boundaries are everywhere:

javascript
UI → BLoC (async event handler)
  → Use Case (async call)
    → Repository (async data fetch)
      → HTTP Client (async network request)

Every is a function call that returns a Future. Every one of them needs to be awaited for the chain to work correctly. Miss one, and the layers above it proceed with incomplete data.

The use case pattern makes this particularly easy to miss. A use case's call method wraps the repository:

dart
class SearchClientLeadsUseCase {
  final ClientLeadRepository _repository;

  SearchClientLeadsUseCase(this._repository);

  Future<Either<Failure, List<Lead>>> call(String query) {
    return _repository.searchLeads(query); // no await needed — just forwarding
  }
}

This works because the method returns the Future — it doesn't need await internally. But the caller still needs to await it:

dart
// In the BLoC:
final result = await searchClientLeadsUseCase(query); // must await

The trap: you see that the use case doesn't use await internally and subconsciously pattern-match that await isn't needed at the call site either. But forwarding a Future and consuming a Future are different operations. The use case forwards. The BLoC consumes. The BLoC must await.

Key Insights

`Future<void>` looks like `void` but behaves nothing like it. A function returning void completes before the caller's next line runs. A function returning Future<void> starts before the caller's next line runs. The difference is invisible in the function signature unless you're looking for it, and the compiler won't flag it without unawaited_futures enabled.

Enable `unawaited_futures` in every Dart project. It's the single highest-value lint you can add. It catches a bug category that has no symptoms at compile time, intermittent symptoms at runtime, and is notoriously hard to reproduce. Add it to analysis_options.yaml on day one.

A race condition without threads is still a race condition. Dart's single-threaded model prevents data races (two threads corrupting shared memory). It does not prevent race conditions (two async operations completing in an unexpected order). Every unawaited Future in an event handler is a potential race condition — the handler completes before the operation does, and the next event starts processing against incomplete state.

If you change a function from sync to async, check every caller. This is the refactoring that introduces the most forgotten awaits. The function now returns a Future, all existing callers still compile, and none of them await the result. Search for every call site and add await. The compiler won't remind you. The unawaited_futures lint will — another reason to enable it.

Related Topics

flutter async not workingflutter missing awaitbloc emit before future completesflutter unawaited futures lintdart future void vs voidflutter race condition blocdart await forgottenflutter bloc event handler asyncunawaited_futures dartflutter sync to async refactor bug

Ready to build your app?

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