Every Flutter developer meets Futures on day one: you call an API, you await the result, you show it on screen. Then, sooner or later, you meet the second half of Dart async programming — Streams — and the confusion starts. Both deal with values that arrive later. Both use await in some form. So when do you reach for a Future, and when do you need a Stream?
The answer is simpler than most tutorials make it sound. A Future delivers one value, once, at some point in the future. A Stream delivers zero or more values, over time, until it closes. That single distinction drives every decision in this guide: which type to return from a function, how to consume it in a widget, and which mistakes to avoid.
Futures: one value, later
A Future represents a computation that has not finished yet. It will complete with either a value or an error, exactly once. Network requests, file reads, database queries, and shared-preferences loads are all naturally Futures, because each one produces a single answer.
\dart
Future<User> fetchUser(String id) async {
final response = await http.get(Uri.parse('https://api.example.com/users/' + id));
if (response.statusCode != 200) {
throw Exception('Failed to load user');
}
return User.fromJson(jsonDecode(response.body));
}
\\
Consuming a Future is straightforward. Await it inside an async function, or use a FutureBuilder in a widget. Always handle the error path: an unhandled Future error becomes a red screen in debug builds and a silent failure in release builds.
\dart
Future<void> loadUser() async {
try {
final user = await fetchUser('42');
setState(() => _user = user);
} catch (e) {
setState(() => _error = 'Could not load the profile. Try again.');
}
}
\\
Two Future utilities cover most real-world needs. Future.wait runs several independent Futures in parallel and returns all their results at once, which is far faster than awaiting them one after another. The timeout method caps how long you are willing to wait, so a hung request cannot freeze your UI forever.
\dart
Future<DashboardData> loadDashboard() async {
final results = await Future.wait([
fetchUser('42'),
fetchNotifications(),
fetchSettings(),
]).timeout(const Duration(seconds: 10));
return DashboardData(
user: results[0] as User,
notifications: results[1] as List<NotificationItem>,
settings: results[2] as Settings,
);
}
\\
If the work produces exactly one result, it is a Future. Full stop. Wrapping a single API call in a Stream adds complexity for no benefit, and it is one of the most common over-engineering mistakes in Flutter codebases. The REST integration patterns in Flutter REST API Integration: From JSON to UI all follow this rule: one request, one Future, one result.
Streams: many values, over time
A Stream is a sequence of asynchronous events. It can emit many values, it can emit errors, and eventually it closes with a done event. User input, sensor data, WebSocket messages, Firebase realtime updates, and download progress are all naturally Streams, because the data keeps arriving after the first value.
The simplest way to create one is a StreamController: you add events to its sink, and listeners receive them through its stream.
\`dart
class DownloadTracker {
final _controller = StreamController();
Stream get progress => _controller.stream;
Future download(String url) async {
final request = http.Request('GET', Uri.parse(url));
final response = await http.Client().send(request);
final total = response.contentLength ?? 0;
int received = 0;
await for (final chunk in response.stream) {
received += chunk.length;
if (total > 0) {
_controller.add(received / total);
}
}
await _controller.close();
}
}
`\
Note two details. First, the controller is closed when the work finishes, so listeners know no more events are coming. Second, the download itself still uses a Future internally: the HTTP request completes once, while the progress Stream reports many values along the way. Futures and Streams compose like this all the time; they are not rivals.
In a widget, StreamBuilder is the counterpart of FutureBuilder. It rebuilds whenever a new event arrives, and its snapshot tells you the connection state, the latest data, and any error.
\dart
StreamBuilder<double>(
stream: tracker.progress,
builder: (context, snapshot) {
if (snapshot.hasError) {
return const Text('Download failed');
}
final value = snapshot.data ?? 0.0;
return Column(
children: [
LinearProgressIndicator(value: value),
Text('Downloaded ' + (value * 100).round().toString() + '%'),
],
);
},
)
\\
Always handle snapshot.hasError and the waiting state explicitly. A StreamBuilder that only reads snapshot.data will show a blank or stale widget while the stream is still connecting, and users will read that as a broken screen.
Creating streams with async star and yield
For streams you generate yourself, async star functions are cleaner than a hand-managed controller. Mark the function async star, yield each value as it becomes available, and Dart builds the Stream for you.
\dart
Stream<int> countdown(int from) async* {
for (var i = from; i >= 0; i--) {
yield i;
await Future.delayed(const Duration(seconds: 1));
}
}
\\
Each yield pauses the function until the listener is ready for the next value, so a slow listener naturally applies backpressure instead of being flooded. You can also transform streams without writing loops: map converts each event, where filters events by a condition, and distinct drops consecutive duplicates.
\dart
final evenCountdown = countdown(10)
.where((value) => value % 2 == 0)
.map((value) => 'T-minus ' + value.toString());
\\
Stream transformers are lazy: nothing runs until something listens. That is a feature, not a bug — it means creating a stream costs almost nothing, and you only pay for the work when a listener actually needs the data.
Single-subscription versus broadcast streams
By default, a Dart stream allows exactly one listener. Listen a second time and you get a StateError. That default is correct for most cases: a file download, a sensor feed, or a WebSocket connection should have one owner that controls its lifecycle.
When several widgets genuinely need the same events — for example, a connectivity indicator in the app bar and a banner on the home screen — convert the stream with asBroadcastStream. Broadcast streams allow many listeners, but they come with two trade-offs: late listeners miss events that already happened, and the stream keeps running even when nobody is listening unless you manage it yourself.
\dart
final connectivity = Connectivity()
.onConnectivityChanged
.asBroadcastStream();
\\
A practical rule: start single-subscription, and switch to broadcast only when a second listener actually appears. Premature broadcast streams are a classic source of missed-event bugs, because developers assume every listener sees the full history.
The decision table
| Situation | Use | Why |
|---|---|---|
| Load a user profile from an API | Future | One result, then done |
| Watch auth state changes | Stream | State can change many times |
| Read a file from disk | Future | One result, then done |
| Receive WebSocket messages | Stream | Messages arrive indefinitely |
| Run three API calls in parallel | Future.wait | All results needed together |
| Show download progress | Stream | Progress updates over time |
| Debounce search input | Stream | Keystrokes arrive continuously |
| Cache a value for later reuse | Future | The value is computed once |
When in doubt, count the values. One value means Future. Many values over time means Stream. If you find yourself adding a Stream just to deliver one value, simplify it to a Future. If you find yourself polling a Future in a timer loop to get fresh values, that polling loop is a Stream waiting to be written.
This distinction also explains why state management libraries lean on streams so heavily. BLoC, for example, models state as a stream of states over time, which is why understanding streams makes BLoC click. The tutorial in Flutter BLoC Pattern Tutorial: Zero to Production builds directly on the concepts above: events go in, states come out, and the UI rebuilds on each new state.
Five mistakes every Flutter developer makes once
Awaiting a stream. Awaiting a Stream gives you its first event only if you use the first getter, or hangs until it closes if you use await for incorrectly in the wrong place. If you need every event, listen or use await for inside an async function — never await the stream object itself.
Listening twice to a single-subscription stream. Two widgets subscribing to the same controller stream throws at runtime. Share the data through a state management solution or convert to broadcast deliberately, as discussed above.
Forgetting to cancel a subscription. A StreamSubscription keeps its source alive. Cancel it in dispose, or use StreamBuilder and let the framework manage the subscription for you. Leaked subscriptions are a quiet memory leak: the stream keeps emitting, the disposed widget keeps receiving, and the app slowly degrades.
\`dart
class _TickerState extends State {
late final StreamSubscription _subscription;
@override
void initState() {
super.initState();
_subscription = countdown(60).listen((value) {
if (mounted) setState(() => _secondsLeft = value);
});
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}
`\
Never closing a StreamController. An unclosed controller holds its listeners forever and confuses code that waits for the done event. Close it in dispose, or prefer async star functions, which close automatically when the function returns.
Ignoring errors in streams. Stream errors do not throw into surrounding try-catch blocks the way Future errors do. Handle them with onError in listen, check snapshot.hasError in StreamBuilder, or use handleError in a transformation chain. An unhandled stream error crashes the zone, which in a Flutter app usually means a red screen.
A complete example: live search
Live search ties everything together. Keystrokes arrive as a stream, you debounce them so you do not fire a request per character, and each debounced query becomes a single Future that fetches results.
\`dart
class SearchService {
final _queryController = StreamController();
Stream> get results => _queryController.stream
.distinct()
.asyncMap((query) => searchApi(query));
void onQueryChanged(String query) {
_queryController.add(query);
}
Future> searchApi(String query) async {
await Future.delayed(const Duration(milliseconds: 300));
return ['Result for ' + query];
}
void dispose() {
_queryController.close();
}
}
`\
The pattern is worth studying: a Stream for the continuous input, a Future for each discrete lookup, distinct to skip duplicate queries, and asyncMap to turn each query into its async result. Most real-time Flutter features are variations of this shape. Keeping the network layer behind a clean boundary, as in Flutter Repository Pattern: Keep API Calls Out of Widgets, makes this pattern easy to test: feed queries into the stream, await the results, and assert.
Conclusion
Futures and Streams are not two ways of doing the same thing. A Future is a promise of one value. A Stream is a sequence of values over time. Choose by counting the values, handle errors on the path each type actually uses, cancel subscriptions, close controllers, and let StreamBuilder or FutureBuilder own the widget lifecycle whenever you can. Get that mental model right, and the rest of Dart async programming — isolates, async generators, state management — falls into place, because all of it is built on these two ideas. The official Dart documentation on asynchronous programming at dart.dev is the best next read once these patterns feel natural.
Get in touch and let's talk about your project.
Originally published at uzairxdev.online
Top comments (0)