Fixing Dart Async/Await Pitfalls That Break Flutter UI State

May 20, 2026 6 min read 47 views
Minimalist illustration of async data flow arrows inside a mobile phone frame with geometric shapes on a soft indigo background

Your button press fires, the loading spinner appears, and then nothing updates. Or worse: the UI updates but with data from the previous request. Dart's async/await model is clean on the surface, but Flutter's widget lifecycle adds several traps that catch even experienced developers off guard.

This article covers the specific mistakes that break UI state in Flutter apps and shows you exactly how to fix them.

What you'll learn

  • Why calling setState after a widget is disposed crashes your app and how to guard against it
  • How unawaited futures silently swallow errors and leave your UI in a wrong state
  • Why FutureBuilder rebuilds more than you expect and what to do about it
  • How to cancel async operations cleanly when a widget leaves the tree
  • Common async mistakes inside initState and how to handle them safely

Prerequisites

You should be comfortable with Flutter's StatefulWidget lifecycle and have written at least a few screens that load data from an API or local storage. Code examples use Dart 3 syntax, but the patterns apply to any recent Flutter version.

The setState After Dispose Crash

This is the most common async bug in Flutter. You kick off a network request, the user navigates away before it finishes, and when the future resolves your code calls setState on a widget that no longer exists in the tree.

The error message looks like this:

setState() called after dispose()
This error happens if you call setState() on a State object for a widget that no longer appears in the widget tree.

The fix is simple: check mounted before calling setState.

Future<void> _loadData() async {
  final result = await fetchUserProfile();
  if (!mounted) return; // guard here
  setState(() {
    _profile = result;
  });
}

mounted is a property on State that returns false once the widget has been removed from the tree. Always check it before any setState call that follows an await.

Unawaited Futures Hide Errors

When you call an async function without await and without attaching a .catchError handler, any exception it throws disappears silently. Your UI just stays in whatever state it was in, with no error message and no log entry in release mode.

// Bad β€” errors vanish silently
void _onButtonPressed() {
  saveToDatabase(formData); // no await, no error handling
}

// Better
void _onButtonPressed() {
  saveToDatabase(formData).catchError((e) {
    // show a snackbar or update error state
    _showError(e.toString());
  });
}

// Best β€” use async properly
Future<void> _onButtonPressed() async {
  try {
    await saveToDatabase(formData);
  } catch (e) {
    _showError(e.toString());
  }
}

The Dart linter has a rule called unawaited_futures that flags these. Enable it in your analysis_options.yaml so the problem surfaces at compile time rather than in production.

linter:
  rules:
    - unawaited_futures

If you genuinely want to fire-and-forget (rare, but valid), wrap the call with unawaited() from dart:async to make your intent explicit and silence the lint warning.

async in initState β€” The Wrong Pattern

initState cannot be async. If you try to make it one, Flutter will not await it, and you'll load data after the first build runs with empty state. This is a common source of flickering or missing data on first render.

// Wrong β€” initState is not actually awaited by Flutter
@override
void initState() {
  super.initState();
  await _loadData(); // compile error, and wrong conceptually
}

// Correct β€” call async work in a regular method
@override
void initState() {
  super.initState();
  _loadData(); // fire and handle internally
}

Future<void> _loadData() async {
  try {
    final data = await fetchData();
    if (!mounted) return;
    setState(() => _data = data);
  } catch (e) {
    if (!mounted) return;
    setState(() => _error = e.toString());
  }
}

This pattern keeps initState synchronous as Flutter expects, while still handling errors and guarding against the disposed-widget crash.

FutureBuilder Rebuilding on Every Render

FutureBuilder is convenient, but it has a sharp edge: if you pass a future that is created inline β€” inside the build method β€” a new Future is created on every rebuild. This means your network request fires again every time anything in the widget tree causes a repaint.

// Bad β€” a new Future is created on every build call
@override
Widget build(BuildContext context) {
  return FutureBuilder(
    future: fetchProducts(), // called fresh every rebuild
    builder: (context, snapshot) {
      // ...
    },
  );
}

// Good β€” store the Future in state, created once
late final Future<List<Product>> _productsFuture;

@override
void initState() {
  super.initState();
  _productsFuture = fetchProducts();
}

@override
Widget build(BuildContext context) {
  return FutureBuilder(
    future: _productsFuture, // stable reference
    builder: (context, snapshot) {
      // ...
    },
  );
}

By assigning the future once in initState and handing the stored reference to FutureBuilder, you guarantee the request runs exactly once per widget lifecycle.

Race Conditions with Multiple Async Calls

Imagine a search field that fires a network request on every keystroke. If the user types quickly, request 1 may resolve after request 3, and your UI ends up showing results for an earlier query. This is a classic race condition.

The cleanest Flutter-native fix is to track a sequence counter and ignore results from stale requests.

int _requestId = 0;

Future<void> _search(String query) async {
  final thisRequest = ++_requestId;
  final results = await searchApi(query);

  if (thisRequest != _requestId) return; // stale, discard
  if (!mounted) return;

  setState(() => _results = results);
}

Every time _search is called it increments the counter. When a result arrives, it only applies if the counter still matches β€” meaning no newer request has been issued since.

For more complex scenarios, consider using a CancelableOperation from the async package, or move the logic into a state management solution like Riverpod or BLoC that handles debouncing and cancellation for you.

Not Cancelling Async Work on Dispose

Even with the mounted guard, long-running async operations keep executing after a widget is disposed. That wastes resources and can cause subtle bugs when shared state is involved. The right approach is to cancel the work entirely.

For Stream-based work, always cancel your StreamSubscription in dispose.

StreamSubscription<LocationData>? _locationSub;

@override
void initState() {
  super.initState();
  _locationSub = locationStream.listen((data) {
    if (!mounted) return;
    setState(() => _location = data);
  });
}

@override
void dispose() {
  _locationSub?.cancel();
  super.dispose();
}

For HTTP requests, look at whether your HTTP client supports cancellation tokens. The dio package, for example, accepts a CancelToken that you can cancel in dispose. Plain http package requests cannot be cancelled mid-flight, which is one reason many teams switch to dio for Flutter apps.

Common Pitfalls at a Glance

MistakeSymptomFix
No mounted checksetState after dispose crashCheck mounted before setState
Unawaited futureErrors silently droppedAwait or attach catchError
async initStateStale data on first renderUse a separate async method
Inline future in buildRequest fires on every rebuildStore future in initState
No request deduplicationRace condition, stale results shownUse a sequence counter or cancellation
No stream cancelMemory leaks, spurious updatesCancel subscription in dispose

Using async Safely with State Management

Most of the patterns above apply regardless of your state management choice. But if you use Riverpod, BLoC, or similar libraries, many of these concerns are handled for you. Riverpod's AsyncNotifier, for instance, tracks its own lifecycle and will not update state after it has been disposed. BLoC's emit is also safe to call β€” it checks whether the bloc is closed before dispatching an event.

That does not mean you can skip error handling. It means you can stop worrying about the mounted guard and focus on building the logic layer cleanly. If you are managing async state directly in StatefulWidget, the manual guards above are essential.

Next Steps

  • Add the unawaited_futures and cancel_subscriptions lint rules to your analysis_options.yaml today and fix any warnings they surface.
  • Audit every FutureBuilder in your app and confirm its future is stored in State, not created inside build.
  • Add a mounted check after every await that precedes a setState call.
  • For screens with search or live queries, introduce a sequence counter or switch to a solution like Riverpod's AsyncNotifier to handle cancellation automatically.
  • Review any StreamSubscription in your widgets and confirm each one is cancelled in dispose.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.