HomeDocumentationThe async nature of Dart
The async nature of Dart
14

Common Async Bugs

Flutter Async Bugs — Fire-and-Forget, setState After Dispose, Race Conditions

April 2, 2026

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:

dart
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:

dart
// 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:

dart
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.

javascript
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:

dart
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.

dart
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:

dart
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:

dart
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:

dart
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:

dart
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:

dart
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 navigator that may no longer be valid.
  • The notification callback calls ScaffoldMessenger.of(context) with a stale context — the BuildContext is 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:

dart
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:

dart
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:

javascript
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:

dart
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:

dart
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:

  1. An async operation starts.
  2. Time passes (the await suspends, the event loop runs other events).
  3. The world changes during that time (the widget disposes, another operation starts, the initialization completes).
  4. 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.

Related Topics

flutter async bugsflutter setstate after disposedart fire and forget futureflutter race conditionflutter stream memory leakdart unawaited futureflutter async common mistakes

Ready to build your app?

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