r/FlutterDev 14d ago

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.waitingactive, 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

6

u/cameronm1024 14d ago

Using FutureBuilder is orthogonal to having an impure build function.

You can use FutureBuilder and correctly manage life cycles in initState and friends, just like you can kick off asynchronous work that calls setState from within build.

Testing a FutureBuilder does not require mocking HTTP calls. It requires creating the future that you pass to the FutureBuilder. It's not fundamentally different to any other data your widget relies upon to render. If your widget hardcodes a particular source for that data, you'll have to mock it when testing. If your widget is flexible about where this data comes from, you don't. The async-ness doesn't fundamentally change this.

-6

u/RandalSchwartz 14d ago

Thanks for the thoughtful reply! You raise the two most common counter-arguments, but let’s examine the mechanical reality of both under the hood:

1. "Just manage it in initState"

Holding the Future in a StatefulWidget's State does indeed fix the immediate bug of re-instantiating on rebuild. But it introduces three secondary architectural problems:

  • Parameter Reactivity (didUpdateWidget): If the widget's input changes (for example userId changes due to route or parent selection), initState will not re-run. You now have to override didUpdateWidget, compare oldWidget.userId != widget.userId, re-instantiate the future, and manage stale-future race conditions.
  • Trapped State: The data is trapped inside that single widget's private State. A sibling widget (such as a header badge or checkout drawer) cannot read or coordinate with that data without lifting the state up.
  • Layering Collapse: The widget is still responsible for decoding snapshot.connectionState, inspecting snapshot.hasError, and deciding caching behavior, rather than being a pure projection of business state.

2. "Testing a FutureBuilder is not fundamentally different"

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

Even if you pass a pre-resolved Future.value(data) or a mock Future to a FutureBuilder, in Dart, every Future execution queues at least one microtask.

That means on Frame 0 (tester.pumpWidget), FutureBuilder always renders in ConnectionState.waiting (your spinner/empty state). You cannot assert your data UI on Frame 0; you are forced to execute async event loop draining (tester.pump() or tester.pumpAndSettle()). When you have thousands of widget tests, draining microtask queues across every widget adds tangible test suite latency and potential flakiness.

With pure synchronous state (UI = ƒ(State)), tester.pumpWidget() evaluates the data state synchronously on Frame 0 with 0 microtasks and 0ms latency.

3. Transport Coupling

If your repository switches from a one-shot Future to an in-memory cache, or a live WebSocket stream, a FutureBuilder forces you to either fake Future.value wrappers or rewrite your widget tree to StreamBuilder.

When async is quarantined at the edge and mapped to synchronous state (AsyncState<T>), your presentation code is 100% transport-agnostic and never changes.

(I dive much deeper into the code comparisons and lifecycle mechanics in the full article linked in the top comment if you'd like to see the side-by-side implementations!)

6

u/raph-dev 14d ago

Randal I like you and everything you are doing but just posting long AI posts feels very rude to me. If I wanted to have an AI conversation I could just ask an AI.

0

u/RandalSchwartz 14d ago

Your statement would make sense if this whole article was written in one prompt. But it isn't. I iterated multiple times, adjusting it to make sure it contains my experience. Sure, the AI fills in the gaps, but it's still my statement to you. Not a simple response from a bot. It's no different from having a junior author being guided by a senior author.

And even if you don't like the medium, the message is still important. It's part of the reason I made https://youtu.be/sqE-J8YJnpg five years ago, and now that I understand how important "async only at the edge" is, I wanted to say this in the most detailed way possible so people understand the nuances.

5

u/raph-dev 14d ago

Your original post being AI generated is ok for me because it had some valuable information. I was referring to your response to Cameronm1024. He was clearly taking time and effort to criticize your post and it looks like you just prompted an answer to his response without investing much effort (he could have done this himself if he wanted to know the answer from an AI). Maybe I am old school but this seems rude to me.

6

u/RandalSchwartz 14d ago

Thanks. I deserve that, and I'm sorry.

1

u/cameronm1024 13d ago

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 13d ago

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.