Streams: Sequences of Asynchronous Events
In the last chapter you met the Future, Dart's way of saying "this value will arrive later." A Future delivers exactly one value. But many things in real software deliver many values over time: a sensor reporting temperature every second, a chat server sending messages as they arrive, a search box updating results with every keystroke.
A Stream is Dart's answer to that. A Stream is like a conveyor belt of events. You stand at the end of the belt, and values keep arriving one after another — now, in a second, in ten seconds, whenever they come. You do not ask for each value; you simply react as each one appears.
- Future: one value, delivered once.
- Stream: many values, delivered over time.
The chapter assumes you have read the previous one and are comfortable with async and await.
Creating and listening to a Stream
Dart can generate a Stream for you with Stream.periodic. It emits a value on a fixed schedule — here, every 100 milliseconds — and a function turns the count into a value.
Future<void> main() async {
final ticks = Stream.periodic(Duration(milliseconds: 100), (i) => i);
await for (final i in ticks.take(3)) {
print('tick $i');
}
}Run this and you will see:
tick 0
tick 1
tick 2Two new ideas are hiding in those few lines, so let's slow down.
The for-await loop
await for (final i in ticks) is a loop that waits. Each time the Stream delivers a value, the body of the loop runs with that value. Then the loop pauses and waits for the next one. This is asynchronous iteration: iteration where you do not know when, or how many times, the next turn will come.
The word async on main is what makes this legal. A for-await loop can only appear inside an async function, because waiting for a Stream means the function itself must be able to pause and resume.
Stopping early with take
A Stream.periodic never ends on its own. It would tick forever. That is dangerous in a loop: your program would hang waiting for values that never stop coming.
The .take(3) call fixes this. It wraps the Stream in a new Stream that passes along at most three values and then closes. A Stream is closed when it says "no more values, ever." The for-await loop ends when the Stream closes.
You can also end the loop early by writing break inside it. Dart then quietly cancels your subscription to the Stream, meaning you stop receiving values. Cancelling matters: if nobody cancels a never-ending Stream, its work continues in the background, doing nothing useful.
The life of a Stream subscription
When you start listening to a Stream, you create a subscription — a personal connection between you and the events. The connection goes through a small life cycle.
Show diagram source
stateDiagram-v2
[*] --> Subscribed
Subscribed --> Subscribed: data event
Subscribed --> Done: stream closes
Subscribed --> Cancelled: listener cancels
Done --> Closed
Cancelled --> ClosedEach arrow labelled "data event" is one turn of your for-await loop. The loop can leave that cycle in only two ways: the Stream closes on its own, or you stop listening. Either way, the connection is finished and the loop is over.
Streams you make yourself
You are not limited to built-in Streams. An async generator is a function marked async* that uses yield to push values into a Stream. This function produces a countdown, one value per second:
Stream<int> countdown(int from) async* {
for (var i = from; i > 0; i--) {
yield i;
await Future.delayed(Duration(seconds: 1));
}
}
Future<void> main() async {
await for (final n in countdown(3)) {
print(n);
}
print('liftoff');
}The yield statement hands one value to whoever is listening, then the function continues. When the function ends, the Stream closes automatically. This is the most common way to write your own Streams, because it reads almost like ordinary code.
Transforming Streams
Like Lists, Streams come with methods such as map, where, take, and skip. The difference is timing. With a List, all the values exist before you transform them. With a Stream, the transformation runs on each value the moment it arrives.
Future<void> main() async {
final ticks = Stream.periodic(Duration(milliseconds: 100), (i) => i);
final names = ticks.map((i) => 'event $i');
final shortOnes = names.where((n) => n.length < 9);
await for (final n in shortOnes.take(2)) {
print(n);
}
}Here map and where do not wait for anything. They set up a pipeline, and every event that flows through gets reshaped and filtered on the way. Streams also have asynchronous-only tools, such as whereType for filtering by type and distinct for suppressing repeated values — but map, where, and take cover most everyday needs.
Streams compared with what you already know
| Idea | List | Stream |
|---|---|---|
| Values | All exist already | Arrive over time |
| Looping | `for (final x in list)` | `await for (final x in stream)` |
| Transforming | `map`, `where` | `map`, `where` (as events arrive) |
| Ends | After the last value | When closed, or cancelled |
| Needs `async`? | No | Yes, for `await for` |
If a List is a photo album you can flip through at will, a Stream is a live broadcast: you watch as it happens, and it is gone once it has passed.
Where you will meet Streams
Streams show up throughout the Dart ecosystem. In Flutter, user input, page navigation, and state updates often arrive as Streams. In server code, an incoming request body is a Stream of chunks. Any library that talks to the outside world — network, files, sensors — tends to offer Streams, because the outside world does not deliver one tidy value and stop.
The rule to carry forward: one later value? Future. Many values over time? Stream. The next chapter completes the picture with isolates, Dart's way of running heavy work on separate threads.
Exercise
A Stream.periodic Stream emits every second and never closes. What happens if you run await for (final i in ticks) { print(i); } with no take and no break?
Show answer
The loop waits forever. The Stream never closes, so the for-await loop never finishes and the rest of the program (if any) never runs. You must limit the Stream with take, or break, or otherwise cancel the subscription.
Exercise
Rewrite Stream.periodic(Duration(milliseconds: 100), (i) => i) values so that only even counts are printed, stopping after the first three even counts.
Show answer
Use where to filter, then take to stop: await for (final i in ticks.where((i) => i.isEven).take(3)) { print(i); }
Spotted a mistake, or code that does not work?