Start Debugging

FutureBuilder/StreamBuilder vs Riverpod AsyncValue in Flutter: which should you use?

Use FutureBuilder or StreamBuilder for a self-contained, throwaway async widget. Reach for Riverpod AsyncValue once the result is shared, cached, or mutated. Here is the decision, the gotchas, and runnable code for both. Tested on Flutter 3.44 and flutter_riverpod 3.3.1.

If you are deciding between Flutter’s built-in FutureBuilder / StreamBuilder and Riverpod’s AsyncValue, the short answer is: keep the builders for a single, self-contained widget that owns a throwaway async result, and move to Riverpod AsyncValue the moment that result is shared across screens, cached, refreshed, or mutated. The builders are not “the beginner version” of the same thing. They are a UI primitive that subscribes to one async object. AsyncValue is a state model that lives outside the widget tree. This guide is tested on Flutter 3.44 (stable, 2026-05-18), Dart 3.12, and flutter_riverpod 3.3.1 (the 3.0 line shipped 2025-09-10).

They solve overlapping problems at different layers

FutureBuilder and StreamBuilder are widgets. You hand each one a Future or Stream, and it gives your builder callback an AsyncSnapshot<T> describing the current connection state (waiting, active, done) plus the latest data or error. The widget subscribes when it is inserted, unsubscribes when it is removed, and re-subscribes if you pass it a different Future/Stream instance. That is the entire contract. There is no caching, no sharing, and no memory of the result once the widget leaves the tree.

Riverpod’s AsyncValue<T> is not a widget at all. It is a sealed union with three subtypes (AsyncData, AsyncLoading, AsyncError) that a provider exposes as its value. The async work runs inside a provider that lives outside the widget tree, so any widget can read it, several widgets can read the same instance, and the result survives rebuilds and navigation. You render it with value.when(...) or a Dart 3 switch, the same way you render an AsyncSnapshot, but the source of truth is a provider rather than a widget field.

So the real question is not “which renders three states better.” Both render three states fine. The question is where the async result should live and how many things need to see it.

Feature matrix

ConcernFutureBuilder / StreamBuilder (Flutter 3.44)Riverpod AsyncValue (flutter_riverpod 3.3.1)
What it isA widget that subscribes to one Future/StreamA sealed state type exposed by a provider
Where the result livesIn the widget, dies when the widget unmountsIn a provider, outside the tree, survives navigation
Sharing across screensNo, each builder re-runs its own workYes, one provider read from many widgets
Caching / dedupNone, you memoize the Future yourselfBuilt in, provider caches until invalidated
Trigger on every rebuildYes, if the Future is created in buildNo, provider build runs once until invalidated
Loading + previous dataManual, snapshot drops data while waitingvalue.isLoading keeps value during refresh
Mutations / refreshReassign the Future and setStateref.invalidate or AsyncValue.guard in a notifier
Testing without a widgetHard, needs pumpWidgetEasy, read the provider in a plain ProviderContainer
DependenciesZero, ships with the SDKflutter_riverpod package
Lines of boilerplate for a one-offMinimalMore setup for a single throwaway call

When FutureBuilder or StreamBuilder is the right call

Reach for the built-in builders when the async result genuinely belongs to one widget and nobody else needs it.

Here is the correct shape. The Future is created once in initState, not in build, so the widget does not re-fetch on every parent rebuild.

// Flutter 3.44, Dart 3.12
class UserCard extends StatefulWidget {
  const UserCard({super.key, required this.id});
  final String id;

  @override
  State<UserCard> createState() => _UserCardState();
}

class _UserCardState extends State<UserCard> {
  late Future<User> _user;

  @override
  void initState() {
    super.initState();
    _user = api.fetchUser(widget.id); // created ONCE, not in build
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<User>(
      future: _user,
      builder: (context, snapshot) {
        return switch (snapshot) {
          AsyncSnapshot(connectionState: ConnectionState.waiting) =>
            const CircularProgressIndicator(),
          AsyncSnapshot(hasError: true, :final error) =>
            Text('Failed: $error'),
          AsyncSnapshot(hasData: true, :final data?) =>
            Text(data.name),
          _ => const SizedBox.shrink(),
        };
      },
    );
  }
}

The single most common bug with this widget is creating the Future inline, like future: api.fetchUser(widget.id) directly in build. Every rebuild then allocates a new Future, FutureBuilder sees a new identity, and it restarts from the loading state. That failure mode is common enough to have its own write-up: see why FutureBuilder recreates its Future on every rebuild for the full repro and every variant that triggers it.

When Riverpod AsyncValue is the right call

Move to AsyncValue when the async result stops being a private detail of one widget.

The same three-state render, now sourced from a provider:

// Flutter 3.44, Dart 3.12, flutter_riverpod 3.3.1
final userProvider = FutureProvider.family<User, String>((ref, id) {
  return api.fetchUser(id); // runs once, cached per id, shared everywhere
});

class UserCard extends ConsumerWidget {
  const UserCard({super.key, required this.id});
  final String id;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(userProvider(id));
    return switch (user) {
      AsyncData(:final value) => Text(value.name),
      AsyncError(:final error) => Text('Failed: $error'),
      _ => const CircularProgressIndicator(),
    };
  }
}

Two widgets calling ref.watch(userProvider('42')) share one fetch and one cached result. There is no initState, no stored field, and no “create the Future once” discipline to remember, because the provider already runs its build exactly once per argument until it is invalidated. For the full set of states, mutations with AsyncValue.guard, and keeping previous data on refresh, see how to show loading and error states with AsyncValue.

The rebuild and re-fetch behaviour that actually decides it

Performance is not the axis here. Both approaches render at the same frame rate. What differs is how many times your async work runs, and that is a correctness and cost issue, not a raw-speed one.

Put a counter inside the async call and watch what happens when the surrounding widget rebuilds (a theme toggle, a keyboard opening, a parent setState):

If your async work is a cheap local read, none of this matters and the builder wins on simplicity. If it is a network call, a database query, or anything with a cost or a rate limit, the caching is the whole reason AsyncValue exists, and hand-rolling the same behaviour around FutureBuilder reimplements a worse version of Riverpod’s provider cache.

The gotcha that picks for you

A few constraints settle the decision regardless of taste.

You are already using Riverpod. If the app has providers, do not mix FutureBuilder into a screen that reads them. Reading a provider’s data and then wrapping a second FutureBuilder around a different async call gives you two unrelated lifecycles on one screen and two places where “loading” can be true. Expose the second call as a provider too and render both with AsyncValue. Consistency here prevents the class of bug where one half of the screen is stale.

The result must outlive the widget. Anything fetched in initState dies with the State. If the user navigates forward and back and you do not want a fresh spinner and a fresh network call every time, you need a cache that lives above the widget. That is a provider. FutureBuilder cannot give you cross-route persistence no matter how you arrange it.

You touch ref after an await. This is a Riverpod-specific trap, not a reason to avoid it: if you await inside a notifier and then read ref after the widget that triggered it is gone, you hit Cannot use "ref" after the widget was disposed. The fix is to capture what you need before the await. It is worth knowing before you commit, and it is covered in the fix for using ref after disposal.

You explicitly want zero dependencies. A pub package example, a reproduction case, or a team policy against state-management libraries forces the builders. That is a legitimate constraint, and the builders are perfectly capable for self-contained async UI.

StreamBuilder has one extra wrinkle

Everything above applies to Future work. Streams add a subscription lifecycle, and that tilts the decision a little further toward Riverpod for anything non-trivial. StreamBuilder resubscribes when you pass it a new Stream instance and unsubscribes when it leaves the tree, but it does not multicast: two StreamBuilders on the same single-subscription stream will throw, because a single-subscription Stream allows only one listener. Riverpod’s StreamProvider sits in front of the stream, so multiple widgets read one AsyncValue without fighting over the subscription, and the latest value is cached for late subscribers. If a stream is displayed in exactly one place, StreamBuilder is fine. If more than one widget needs it, StreamProvider removes the single-listener problem entirely.

The recommendation, with the full context behind it

Default to Riverpod AsyncValue for any async result that is shared, cached, refreshed, or mutated, which in a real app is most of them. You get one fetch instead of N, free caching across navigation, isLoading that preserves previous data on refresh, and logic you can test without a widget. Keep FutureBuilder and StreamBuilder for genuinely self-contained, throwaway async UI: a leaf widget that loads one thing, shows it, and forgets it when it unmounts, especially in apps that carry no state-management dependency. The builders are not training wheels you outgrow. They are the right tool when the async result has an audience of one, and the wrong tool the moment it has an audience of two. Pick by ownership, not by familiarity.

If you are still choosing a state-management approach more broadly, the trade-offs between packages are in Provider vs Riverpod vs Bloc for Flutter state management in 2026. And if your async UI keeps surfacing failures, how to handle network errors gracefully in a Flutter app covers turning thrown exceptions into a clean error state in both models.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back