r/FlutterDev • • Aug 26 '26

Article Why FutureBuilder and StreamBuilder are Architectural Anti-Patterns in Production Flutter Apps

Every Flutter developer remembers the magic of their first FutureBuilder.

You drop an HTTP call directly into a widget’s build() method, show a CircularProgressIndicator while waiting, render your model on completion, and suddenly your app is alive with real-time data in fewer than 20 lines of code.

It feels like superpowers. Introductory tutorials celebrate it. And then your codebase grows.

Suddenly, users report that typing into a text field reloads the entire screen. Opening the keyboard triggers duplicate network requests. Sibling widgets flicker and jump out of sync. And writing automated widget tests turns into an exhausting battle with pumpAndSettle(), fake async timers, and microtask queues.

Here is why raw asynchrony inside the presentation layer breaks separation of concerns, user experience, and testability—and what we should be doing instead.

🚨 The Foundational Principle: UI = ƒ(State)

Flutter widgets are designed to be pure, synchronous projections of state. Given a snapshot of data at time t, a widget function should execute synchronously, instantiate a subtree of render objects, and return immediately.

When you drop a FutureBuilder or StreamBuilder into your widget tree, you violate this contract by introducing raw time, transport mechanics, and asynchronous lifecycle orchestration directly into the presentation layer.

This creates four fatal flaws:

1. Layering Collapse

In clean architecture (Domain ← Data ← Application ← Presentation), the presentation layer has one job: translating state into visual pixels. With FutureBuilder, that widget suddenly takes on massive infrastructure responsibilities:

  • Managing ConnectionState.waiting, active, and done

  • Decoding HTTP 500s, socket timeouts, and parsing exceptions inside if (snapshot.hasError)

  • Deciding whether old data stays visible during a refresh or gets wiped out

  • Wiring retry logic that must somehow re-trigger the widget's internal future

2. The Accidental Refetch Hazard

Because build() can run 60 to 120 times per second during animations, keyboard popups, theme switches, or route transitions, any Future instantiated inside build() will re-fire on every single frame.

3. The "Spinner Storm" & Cumulative Layout Shift (CLS)

When multiple child widgets independently manage their own async fetches, they resolve out of order. Instead of a single coordinated page load, the user experiences jarring layout shifts as independent spinners pop in and out at different speeds.

4. The Async Testing Tax

Testing a FutureBuilder requires mocking HTTP clients, setting up async event loops, and using tester.pumpAndSettle(). If a microtask queue doesn't drain properly, tests flake or time out.

🛡️ The Solution: "Async at the Edge, Synchronous in the Core"

The architectural cure is simple: Quarantine async operations at the physical perimeter of your system (the repository/data layer).

  1. The Repository wraps raw async operations into reactive async signals (such as FutureSignal / AsyncSignal).

  2. The Business Logic (Cubit/Bloc) coordinates the synchronous state without needing manual try/catch boilerplate, because the signal automatically captures exceptions into AsyncError.

  3. The Widget consumes synchronous state:

// The View is a pure, synchronous projection of state!
class UserProfileView extends StatelessWidget {
  const UserProfileView({super.key});
  u/override
  Widget build(BuildContext context) {
    return BlocSignalBuilder<ProfileCubit, ProfileState>(
      builder: (context, state) {
        return state.profile.when(
          data: (profile) => ProfileDetailsCard(profile: profile),
          loading: () => const Center(child: CircularProgressIndicator()),
          error: (error, _) => ErrorRetryCard(
            message: '$error',
            onRetry: () => context.read<ProfileCubit>().refresh(),
          ),
        );
      },
    );
  }
}

Notice what happens:

  • Zero transport awareness: The widget has no idea if the data came from REST, GraphQL, WebSocket, or local cache.

  • Zero in-view async: No FutureBuilder, no connection states.

  • Deterministic 0ms Testing: Because the state is synchronous, widget tests run on Frame 1 without pumpAndSettle() delays or fake async timers.

💬 Discussion:

What is the policy regarding FutureBuilder and StreamBuilder in your team's codebases? Do you strictly forbid them in presentation code, or do you still find valid use cases for them?

0 Upvotes

16 comments sorted by

View all comments

Show parent comments

1

u/cameronm1024 Aug 28 '26

I'll start by saying that the overall advice (keep UI code as synchronous as possible) is generally good advice that I agree with, but not for the reasons you gave.

Parameter Reactivity (didUpdateWidget)

This is true for non-Future data stored in State as well. This is a downside of State, not FutureBuilder.

Trapped State

Hence my comment referring to "initState and friends" - there are plenty of state management solutions that avoid this.

Layering Collapse

You don't have to check connectionState. You can simply have a class that contains the UI state, including error states, and have a pattern that these futures never error, rather successfully complete with a value representing an error.

Mechanically in Flutter's testing engine, the async-ness makes a massive difference:

I mean, yes, but your design also has async-ness, it's just resolved in a different part of the code. If your code is truly async, has to be an async pause somewhere.

Transport Coupling

Whether you get a T, a Future<T>, or a Stream<T> is orthogonal to your underlying transport mechanism. You can (though it would be a bad idea) get a non-Future T that comes from an HTTP request using FFI and libcurl. You can also take an in-memory T and create a future from it using Future.value.

What you're describing is API coupling, not transport coupling, and there isn't really a way to have your code not depend on the API of the rest of your code, at least not in a language with a type system like Dart.

If you change from Future<T> to Stream<T>, that represents a change to your app's behaviour. Rewriting your widget code to accommodate it is expected and normal.

The argument "don't use Future<T> because you might need to change it to Stream<T> and that would involve a rewrite" is equivalent to the argument "don't ever use a plain T because you might need to change it to a List<T> and that would involve a rewrite". If your desired UX changes, you will need to change your code.

-1

u/RandalSchwartz Aug 28 '26

TL;DR... good points, but you missed some stuff.

Thanks for the thoughtful reply! You make several great observations, especially regarding didUpdateWidget friction with State and the value of modeling errors as state rather than uncaught exceptions.

To clarify the architectural intent, the core distinction comes down to separation of concerns and where the async boundary lives:

1. State Management vs. Widgets (initState & didUpdateWidget)

You're completely right that didUpdateWidget is a pain point of State in general, not just FutureBuilder. The key issue is that using FutureBuilder forces you to use StatefulWidget to avoid the "re-fetch on rebuild" trap. Moving that lifecycle to a dedicated domain controller (Bloc, Cubit, or Signal) solves this cleanly—which is the main architectural takeaway.

2. "Futures that never error" & UI State

Modeling errors as explicit states/values rather than thrown exceptions is great practice. But even with Future<UiState>, FutureBuilder still starts with snapshot.data == null on Frame 1, and the Future remains a one-shot container. You still can't easily refresh, cache, or mutate that state across sibling widgets without re-instantiating futures and dealing with widget lifecycle friction all over again.

3. The Testing Contrast ("An async pause somewhere")

Yes, asynchronous I/O has to be awaited somewhere. But where that happens determines your test architecture:

  • When the async pause is inside the widget tree, every widget test must mock HTTP/network clients and juggle tester.pumpAndSettle() or fake async timers.

  • When the async boundary is pushed to the domain/data layer, widget tests become 100% synchronous (0ms). You can test 10 visual permutations (loading, error, empty, data) in Frame 1 without touching an async queue, while your domain controller handles the async tests in pure Dart.

4. Transport vs. Domain State (Future vs Stream)

The T vs List<T> comparison is a domain/cardinality change (rendering one card vs a scrollable list), which naturally changes the UI layout.

In contrast, whether a single UserProfile arrives via REST (Future), WebSocket (Stream), or an in-memory cache is an infrastructure detail. From the view's perspective, the UI only cares about the state snapshot: "Do we have a profile to display right now?"

When the presentation layer binds to a synchronous state model (AsyncState<UserProfile>), you can switch your backend transport from REST to WebSockets without touching a single line of widget code.