Most apps fetch something — a profile, a list, a live feed — and the screen has to cope with three realities at once: the data is not here yet, the request might fail, or it has arrived. FutureBuilder and StreamBuilder are Flutter's built-in widgets for turning that asynchronous source into UI without writing your own loading flags by hand. They hand you an AsyncSnapshot describing the current state, and you decide what to paint. This tutorial covers both widgets, every connection state, the infamous trap where a rebuild silently refetches your data, when a Stream beats a Future, and how these fit alongside your own state.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowEvery snippet below is paste-ready against current stable Flutter. The Future you feed a FutureBuilder is usually the result of an HTTP request — the same async pattern underneath.
We'll start with a single Future, handle each snapshot state, fix the recreated-future bug, then move to StreamBuilder for data that keeps changing.
If you'd rather watch a real list screen wired to an API with loading and error states, the channel walks through these flows in shipping apps.
FutureBuilder: a widget for a Future
A Future is a value that will exist later — the result of a network call, a file read, or a database query. FutureBuilder subscribes to that Future and rebuilds whenever its status changes, calling your builder with the latest AsyncSnapshot. You never manage setState for the loading flag yourself; the widget does it for you.
FutureBuilder<User>(
future: _userFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Something went wrong: ${snapshot.error}');
}
final user = snapshot.data!;
return Text('Hello, ${user.name}');
},
)
The generic type <User> tells the builder that snapshot.data is a User?, so you get type safety and autocompletion. Notice the builder runs more than once: first while waiting, then again when the data or error arrives. Your job is to return sensible UI for whichever moment it is called in.
It helps to picture the timeline. The very first time the builder runs, the future has not resolved, so snapshot.data is null and connectionState is waiting. A few hundred milliseconds later the future completes, the framework schedules a rebuild, and the builder runs again — this time with either hasData true or hasError true and connectionState set to done. You are not writing a function that returns the finished UI; you are writing a function that returns the right UI for each frame along the way. That mental shift is the whole trick to using these widgets well.
Handle every AsyncSnapshot state
The AsyncSnapshot is the whole API surface, and getting all its branches right is what separates a robust screen from one that flashes blank or crashes. There are four fields you care about.
connectionState—none,waiting,active, ordone.hasDataanddata— true once a value has arrived.hasErroranderror— true if the source threw.
A complete builder checks the waiting state first, then the error, then the data, falling through to an empty state if there is genuinely nothing.
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.waiting:
return const Center(child: CircularProgressIndicator());
default:
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (snapshot.hasData) {
return UserCard(user: snapshot.data!);
}
return const Text('No data');
}
}
Checking connectionState == waiting rather than !snapshot.hasData matters: on a refetch the snapshot can still hold the previous data while a new request is in flight, and you usually want to keep showing it rather than flashing a spinner. Reach for hasError before data so a failed request never falls through to a null-bang crash.
A small but important detail: an error in the future does not crash your app, but it also will not surface unless you check hasError. The exception is captured into the snapshot, so if you only ever read snapshot.data you will see a blank or null result with no clue why. Logging snapshot.error and snapshot.stackTrace during development saves a lot of confusion, and showing a friendly retry button in the error branch turns a dead end into a recoverable state for the user. Treat the error branch as first-class UI, not an afterthought.

Wire screens to real APIs and live data
The Complete Flutter Guide builds list, detail, and feed screens with FutureBuilder, StreamBuilder, and Firebase inside real, shipped apps.
Enrol nowThe recreated-future trap (build a Future once)
This is the single most common FutureBuilder bug, and it is sneaky because the screen works on first load. The mistake is creating the Future inline in build.
// WRONG: a new future on every rebuild
@override
Widget build(BuildContext context) {
return FutureBuilder<User>(
future: api.fetchUser(), // recreated each build()
builder: (context, snapshot) { /* ... */ },
);
}
Every time the framework rebuilds this widget — a parent rebuild, a keyboard opening, a theme change — build runs again, api.fetchUser() fires a fresh request, and the builder snaps back to the waiting state. You get duplicate network calls and a spinner that flickers on. The cure is to create the Future exactly once and store it in a State field.
class _UserScreenState extends State<UserScreen> {
late final Future<User> _userFuture;
@override
void initState() {
super.initState();
_userFuture = api.fetchUser(); // created once
}
@override
Widget build(BuildContext context) {
return FutureBuilder<User>(
future: _userFuture, // reused across rebuilds
builder: (context, snapshot) { /* ... */ },
);
}
}
Now rebuilds reuse the resolved Future. When you genuinely want to refetch — pull-to-refresh, a retry button — reassign _userFuture inside setState so a new request runs deliberately rather than by accident. Creating it in initState is exactly what that lifecycle method is for.
StreamBuilder for ongoing data
A Future resolves once. When the data keeps changing — a chat thread, a Firestore query, a websocket, a periodic timer — you want a Stream, which emits many values over time. StreamBuilder is the mirror image of FutureBuilder: same builder shape, same AsyncSnapshot, but it rebuilds on every event the stream pushes.
StreamBuilder<int>(
stream: _counterStream,
initialData: 0,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
return Text('Ticks: ${snapshot.data}');
},
)
The initialData argument seeds the first frame so you avoid a null check before the stream emits. The same recreated-source trap applies: build the Stream once in initState rather than inline, or each rebuild resubscribes from scratch. Because the widget listens to the stream, you do not have to cancel the subscription yourself — StreamBuilder manages it for you.
FutureBuilder vs StreamBuilder
The choice is about how many values the source produces. Use a FutureBuilder for a one-off read: fetch this user, load this document, run this query once. Use a StreamBuilder when the value updates and the UI should follow — a live database collection, real-time presence, sensor readings, or a clock. The rendering code is nearly identical; you swap future: for stream: and the rest of the builder stays the same.
For anything beyond a single screen, neither widget is your app's state management. They are a thin bridge from an async source to widgets. Once data is resolved, it often belongs in your own state so other parts of the tree can read it without refetching — hold it in a ValueNotifier or a fuller solution. Think of FutureBuilder and StreamBuilder as the last mile, not the warehouse.
Common mistakes
- Creating the Future or Stream in build. It refetches on every rebuild; create it once in
initStateand store it in a field. - Forgetting the loading state. Check
connectionState == ConnectionState.waitingfirst or the screen flashes blank. - Ignoring errors. Without a
hasErrorbranch a failed request falls through to a null-bang crash. - Using
snapshot.data!blindly. ConfirmhasDatabefore the bang, or guard with a null check. - Treating these widgets as state management. They render one source; persistent app state belongs in a notifier or store.
Frequently asked questions
What is the difference between FutureBuilder and StreamBuilder?
FutureBuilder listens to a Future — one value, then done — so it suits a one-off read. StreamBuilder listens to a Stream that can emit many values over time, so it suits live data. Both give you an AsyncSnapshot in the same builder shape; only the source differs.
How do I show loading and error states with FutureBuilder?
Inspect the snapshot in the builder: return a spinner while connectionState == ConnectionState.waiting, an error widget when snapshot.hasError, and your content when snapshot.hasData. Handling all three branches means the user never sees a blank screen or an unhandled exception.
Why does my FutureBuilder keep refetching?
Because the Future is created inside build, so every rebuild makes a new one and restarts the builder. Create the Future once — assign it to a State field in initState — and pass that field instead.
When should I use a StreamBuilder?
When the data changes over time and the UI should follow: a Firestore query, a websocket feed, a location or sensor stream, or a timer. For data you read once, a FutureBuilder is simpler. One value, use a Future; many values, use a Stream.
Further reads
Keep going with the tutorials that pair with this guide:
- Flutter Development Guide 2026 — the full Flutter hub.
- Flutter HTTP Requests — the Future you usually build.
- Flutter ValueNotifier — hold the resolved state.
- Dart Language for Flutter — Futures and Streams in depth.
- Flutter REST API Integration — the full data flow.
- Flutter WebSockets — stream real-time data over a socket.
- Flutter Cloud Firestore CRUD — real-time reads and writes.
- Flutter Shimmer Loading — skeleton placeholders while data loads.
Sources: Flutter documentation — FutureBuilder, StreamBuilder, AsyncSnapshot, and ConnectionState (api.flutter.dev). Verified against current stable Flutter.