Every bug in this post comes from real code. Not contrived examples — actual patterns that shipped, passed code review, worked in testing, and broke in production. They all share one trait: they look correct at first glance. The async behavior is doing something the developer didn't expect, and the symptom shows up far from the cause.
Each bug maps to a concept from earlier in this series. The event loop's queue priority (Post 0), the way Futures work as state machines (Post 1), Stream subscription lifecycle (Post 2), and Isolate message passing boundaries (Post 3). The patterns become obvious once you see them through the lens of the mechanism.
Bug 1: The fire-and-forget Future
The code:
class AnalyticsService {
Future<void> trackEvent(String name) async {
await http.post(
Uri.parse('$baseUrl/analytics'),
body: jsonEncode({'event': name, 'timestamp': DateTime.now().toIso8601String()}),
);
}
}
// In a widget:
void _onButtonPressed() {
analyticsService.trackEvent('checkout_started'); // No await
navigator.pushNamed('/checkout');
}trackEvent returns a Future<void>. The call site ignores it — no await, no .then(), no .catchError(). The Future floats free, unattached to any error handler.
What goes wrong: If the analytics server is down, http.post throws. The Future completes with an error. Nobody catches it. The Dart runtime reports an unhandled exception. In Flutter, this surfaces through FlutterError.onError or PlatformDispatcher.instance.onError. In production, your crash reporting tool logs it. You see a spike of SocketException: Connection refused crashes that have nothing to do with your user-facing functionality — the checkout worked fine, but the analytics call failed silently and then screamed.
Worse: if the analytics endpoint is slow, the Future is still pending when the user navigates away. The completion callback (inside http.post) holds a reference to the AnalyticsService instance and its dependencies. If those dependencies are scoped to a route that's now disposed — you're in stale-reference territory.
The fix: Either await it, catch it, or explicitly mark it as intentionally unawaited:
// Option 1: await it (if you can afford the delay)
await analyticsService.trackEvent('checkout_started');
navigator.pushNamed('/checkout');
// Option 2: catch errors explicitly
analyticsService.trackEvent('checkout_started').catchError(
(e) => debugPrint('Analytics failed: $e'),
);
navigator.pushNamed('/checkout');
// Option 3: unawaited() — documents the intent, still needs error handling
void _onButtonPressed() {
unawaited(
analyticsService.trackEvent('checkout_started').catchError(
(e) => debugPrint('Analytics failed: $e'),
),
);
navigator.pushNamed('/checkout');
}unawaited() from dart:async doesn't change behavior — it's a no-op function that silences the unawaited_futures lint. The lint exists precisely because fire-and-forget Futures are a common source of unhandled errors. If you're intentionally not awaiting, unawaited() documents that decision and forces you to think about error handling.
The deeper lesson: a Future is a commitment (Post 1). The work starts immediately. The errors need handling. "I don't care about the result" doesn't mean you don't care about the error.
Bug 2: setState after dispose
The code:
class _UserProfileState extends State<UserProfile> {
User? _user;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadUser();
}
Future<void> _loadUser() async {
final user = await userService.fetchUser(widget.userId);
setState(() {
_user = user;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
if (_isLoading) return CircularProgressIndicator();
return Text(_user!.name);
}
}What goes wrong: The user navigates to this page, then quickly presses back before the API responds. The widget is disposed — dispose() runs, the Element is unmounted. But _loadUser is still awaiting the network response. When the response arrives, the await resumes the function (as a microtask, Post 0), and setState is called on a disposed State.
FlutterError: setState() called after dispose()
_UserProfileState#a1b2c(lifecycle state: defunct, not mounted)The State object is "defunct" — it's been removed from the tree. Calling setState on it is an error because there's no Element to mark as dirty, no widget to rebuild.
Why it's sneaky: The bug only appears when the API call takes longer than the user's patience. In development, with a fast local server, the response comes back before you can navigate away. In production, on a slow connection, the user taps back during loading — and the crash surfaces.
The fix: Check mounted before calling setState:
Future<void> _loadUser() async {
final user = await userService.fetchUser(widget.userId);
if (!mounted) return; // Widget was disposed during the await
setState(() {
_user = user;
_isLoading = false;
});
}mounted is false after dispose() runs. The check is cheap (a boolean read) and should appear after every await in a State method that calls setState.
Every. Single. One.
Future<void> _complexFlow() async {
final user = await fetchUser();
if (!mounted) return;
final orders = await fetchOrders(user.id);
if (!mounted) return; // Check again — time has passed
final recommendations = await fetchRecommendations(user.id);
if (!mounted) return; // And again
setState(() {
_user = user;
_orders = orders;
_recommendations = recommendations;
});
}Repetitive? Yes. Necessary? Also yes. Each await is a suspension point where arbitrary time passes and arbitrary state changes can happen — including the widget being disposed.
The BLoC/Riverpod alternative avoids this entirely: state lives outside the widget, and the BlocBuilder/Consumer handles the subscription lifecycle. The setState-after-dispose bug is specifically a StatefulWidget + raw async pattern problem.
Bug 3: The race condition between sequential awaits
The code:
class _SearchPageState extends State<SearchPage> {
List<Product> _results = [];
void _onSearchChanged(String query) async {
final results = await searchService.search(query);
if (!mounted) return;
setState(() => _results = results);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(onChanged: _onSearchChanged),
Expanded(child: ProductList(products: _results)),
],
);
}
}The user types "flu" — the search call fires. Then they type "flutter" — another search call fires. "flu" → 200ms response time. "flutter" → 80ms response time (maybe the server caches the extended query). The "flutter" results arrive first and are displayed. Then the "flu" results arrive and overwrite them.
The user typed "flutter" but sees results for "flu."
Why it happens: Each keystroke calls _onSearchChanged. Each call creates an independent async operation. The await in each call suspends independently. There's no coordination between them — no guarantee that they complete in the order they were started. The event loop processes completions in the order they arrive from the OS, which is the order the server responded, not the order you sent the requests.
The fix: Cancel stale requests. There are several approaches:
class _SearchPageState extends State<SearchPage> {
List<Product> _results = [];
int _searchGeneration = 0; // Simple generation counter
void _onSearchChanged(String query) async {
final generation = ++_searchGeneration; // Capture current generation
final results = await searchService.search(query);
if (!mounted) return;
if (generation != _searchGeneration) return; // A newer search has started
setState(() => _results = results);
}
}The generation counter is the cheapest approach. Each search increments the counter. When the result arrives, it checks whether it's still the latest search. If not, the result is discarded.
For a more robust solution, use a CancelableOperation from package:async:
CancelableOperation<List<Product>>? _pendingSearch;
void _onSearchChanged(String query) async {
_pendingSearch?.cancel(); // Cancel the previous search
_pendingSearch = CancelableOperation.fromFuture(
searchService.search(query),
);
final results = await _pendingSearch!.valueOrCancellation();
if (results == null || !mounted) return; // Cancelled or disposed
setState(() => _results = results);
}Or debounce the input so searches don't fire on every keystroke:
Timer? _debounce;
void _onSearchChanged(String query) {
_debounce?.cancel();
_debounce = Timer(Duration(milliseconds: 300), () async {
final results = await searchService.search(query);
if (!mounted) return;
setState(() => _results = results);
});
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}Debouncing doesn't eliminate the race entirely (two searches can still overlap if the first one is slow), but it dramatically reduces the frequency.
The deeper lesson: sequential await statements within a single function are ordered. But multiple calls to an async function are not coordinated. Each invocation is an independent operation on the event loop. If the same async function can be triggered multiple times (by user input, by a Stream, by a timer), you need explicit coordination — cancellation, generation tracking, or debouncing.
Bug 4: The infinite stream listener
The code:
class _DashboardState extends State<Dashboard> {
@override
void initState() {
super.initState();
_setupListeners();
}
void _setupListeners() {
// Listen to authentication state changes
authBloc.stream.listen((state) {
if (state is Unauthenticated) {
navigator.pushReplacementNamed('/login');
}
});
// Listen to notification stream
notificationService.stream.listen((notification) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(notification.message)),
);
});
// Listen to connectivity changes
connectivity.onConnectivityChanged.listen((result) {
setState(() => _isOnline = result != ConnectivityResult.none);
});
}
}Three .listen() calls. Zero stored subscriptions. Zero cancellations.
What goes wrong: When the user navigates away from the Dashboard, the widget is disposed. But the three stream subscriptions are still alive. The streams (auth, notifications, connectivity) are broadcast streams that outlive the widget. The callbacks keep firing:
- The auth callback tries to push a route using a
navigatorthat may no longer be valid. - The notification callback calls
ScaffoldMessenger.of(context)with a stalecontext— theBuildContextis an unmounted Element (Three Trees Post 1). - The connectivity callback calls
setState()on a disposed State — Bug 2 again.
Three callbacks, three different failure modes, all caused by the same mistake: not cancelling stream subscriptions.
If the user navigates to the Dashboard again, initState runs again, _setupListeners runs again, and three more subscriptions are created. The old ones are still running. Now there are six. Navigate away and back — nine. Each set leaks memory and produces stale callbacks.
The fix:
class _DashboardState extends State<Dashboard> {
late final StreamSubscription _authSub;
late final StreamSubscription _notificationSub;
late final StreamSubscription _connectivitySub;
@override
void initState() {
super.initState();
_authSub = authBloc.stream.listen((state) {
if (state is Unauthenticated && mounted) {
navigator.pushReplacementNamed('/login');
}
});
_notificationSub = notificationService.stream.listen((notification) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(notification.message)),
);
}
});
_connectivitySub = connectivity.onConnectivityChanged.listen((result) {
if (mounted) {
setState(() => _isOnline = result != ConnectivityResult.none);
}
});
}
@override
void dispose() {
_authSub.cancel();
_notificationSub.cancel();
_connectivitySub.cancel();
super.dispose();
}
}Every .listen() stores its StreamSubscription. Every subscription is cancelled in dispose(). Every callback checks mounted because stream events can be delivered between dispose() being called and the cancellation taking effect (the event might already be scheduled as a microtask).
The pattern is mechanical: store, check, cancel. If you see .listen() in a StatefulWidget without a corresponding cancel() in dispose(), that's a leak.
Bug 5: The unawaited initialization
The code:
class DatabaseService {
late final Database _db;
Future<void> init() async {
_db = await openDatabase('app.db');
await _db.execute('CREATE TABLE IF NOT EXISTS ...');
}
Future<List<User>> getUsers() async {
return _db.query('users'); // Uses _db directly
}
}
// In main:
void main() async {
final dbService = DatabaseService();
dbService.init(); // No await!
runApp(MyApp(dbService: dbService));
}init() returns a Future<void>. The call site doesn't await it. runApp executes immediately, the widget tree builds, some widget calls dbService.getUsers(), and:
LateInitializationError: Field '_db' has not been initialized.The database hasn't opened yet. init() started the asynchronous work, but the await openDatabase(...) inside it suspended the function and returned. The calling code continued to runApp without waiting.
Why it's sneaky: This sometimes works. If the first screen doesn't call getUsers() immediately — if there's a splash screen or a login screen that takes a second — the database has time to initialize in the background. The bug only surfaces when the first database access happens before the initialization Future completes. It's a race condition against time, and it depends on device speed.
The fix: Await initialization before starting the app:
void main() async {
WidgetsFlutterBinding.ensureInitialized(); // Required before async work in main
final dbService = DatabaseService();
await dbService.init(); // Wait for the database to be ready
runApp(MyApp(dbService: dbService));
}Or, if you don't want to delay the splash screen, use a Future to gate access:
class DatabaseService {
late final Future<Database> _dbFuture;
DatabaseService() {
_dbFuture = _initDatabase();
}
Future<Database> _initDatabase() async {
final db = await openDatabase('app.db');
await db.execute('CREATE TABLE IF NOT EXISTS ...');
return db;
}
Future<List<User>> getUsers() async {
final db = await _dbFuture; // Waits if init hasn't completed
return db.query('users');
}
}Now getUsers() always awaits the initialization Future. If the database is already open, the await completes immediately (the Future is already in the completed state — Post 1). If it's still opening, the caller waits. No race condition.
The deeper lesson: late final fields combined with async initialization are a race condition waiting to happen. The late keyword says "I promise this will be initialized before you use it." An unawaited init() means that promise depends on timing. Either await the initialization or use a Future as the field type so every access implicitly waits.
The pattern
All five bugs share a structure:
- An async operation starts.
- Time passes (the
awaitsuspends, the event loop runs other events). - The world changes during that time (the widget disposes, another operation starts, the initialization completes).
- The continuation runs in a world that no longer matches the assumptions it was written under.
The event loop guarantees ordering within a single chain of await statements. It does not guarantee anything about the state of the world when an await resumes. Between the suspension and the resumption, any number of events can process — user navigation, state changes, other async completions, widget disposal.
The defenses are simple but non-negotiable:
- Check `mounted` after every `await` in StatefulWidget methods.
- Store and cancel every `StreamSubscription` in
dispose(). - Handle errors on every Future — awaited or not.
- Coordinate concurrent calls to the same async function (generation counters, cancellation, debouncing).
- Await initialization or gate access behind a
Future.
None of these are complex. They're just easy to forget when the async syntax makes everything look like synchronous code. The await keyword hides the suspension point — the moment where time passes and the world can change. Seeing the await as a boundary where assumptions need rechecking is the skill that prevents async bugs.
This is Post 4 of the Async Dart series. Previous: Isolates: Real Parallelism. First post: The Event Loop.