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:
// 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.
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:
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:
LeadState.loading()is emitted — the loading spinner appearssearchClientLeadsstarts — the HTTP request beginsLeadState.loaded(results: [])is emitted immediately — "No results" shows- 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
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:
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:
linter:
rules:
- unawaited_futuresNow 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:
import 'dart:async';
unawaited(analytics.logEvent('search', query)); // intentional fire-and-forgetThis 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:
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:
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
// 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
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:
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.
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:
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:
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:
// In the BLoC:
final result = await searchClientLeadsUseCase(query); // must awaitThe 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.