Skip to content

03 · Streams

Async basics introduced Future for a single value that arrives later, plus a quick taste of Stream. A Stream<T> is the sequence version: it can emit zero, one, or many values over time — a good fit for user input events, WebSocket messages, file-watching, or any "things keep happening" scenario. This module goes deep on how to produce, consume, transform, and safely handle errors from streams.

Producing a stream with async*

async* marks a generator function that can yield values over time instead of returning one value with return.

Stream<int> countUpTo(int max) async* {
  for (int i = 1; i <= max; i++) {
    await Future.delayed(const Duration(milliseconds: 100));
    yield i; // emit one value and keep going
  }
}

Future<void> main() async {
  // `await for` consumes a stream one value at a time, in order.
  await for (final n in countUpTo(3)) {
    print('Got: $n');
  }
  print('Done');
  // Got: 1
  // Got: 2
  // Got: 3
  // Done
}

Two ways to consume a stream

await for reads naturally but pauses the enclosing async function until the stream finishes. .listen() is the lower-level, callback-based alternative — it doesn't block, and gives you a StreamSubscription you can cancel early.

Future<void> main() async {
  final sub = countUpTo(2).listen(
    (value) => print('listen got: $value'),
    onDone: () => print('listen done'),
  );

  await Future.delayed(const Duration(milliseconds: 300));
  await sub.cancel(); // stop listening -- important to avoid leaks
}

Stream<int> countUpTo(int max) async* {
  for (int i = 1; i <= max; i++) {
    await Future.delayed(const Duration(milliseconds: 100));
    yield i;
  }
}
// listen got: 1
// listen got: 2
// listen done

Always keep the StreamSubscription and cancel it when you're done listening (e.g. when a widget is disposed) — an uncancelled subscription keeps its callback (and everything it closes over) alive for as long as the stream lives.

The trap: single-subscription vs. broadcast

By default, streams (including anything from async*) are single-subscription — they can only be listened to once, ever. Listening a second time throws.

Stream<int> countUpTo(int max) async* {
  for (int i = 1; i <= max; i++) {
    yield i;
  }
}

Future<void> main() async {
  final single = countUpTo(3);
  single.listen((v) => print('first: $v'));

  try {
    single.listen((v) => print('second: $v'));
  } catch (e) {
    print('Caught: $e');
    // Caught: Bad state: Stream has already been listened to.
  }
}

If you need multiple independent listeners (typical for UI events or anything "broadcast" by nature), use a StreamController.broadcast():

import 'dart:async';

Future<void> main() async {
  final controller = StreamController<int>.broadcast();

  controller.stream.listen((v) => print('listener A: $v'));
  controller.stream.listen((v) => print('listener B: $v'));

  controller.add(10);
  controller.add(20);
  await Future.delayed(const Duration(milliseconds: 50));
  await controller.close();
  // listener A: 10
  // listener B: 10
  // listener A: 20
  // listener B: 20
}

A broadcast stream drops events that arrive before anyone is listening, and it doesn't buffer — every current listener gets every event from the point they subscribed onward, not from the beginning.

Single-subscription Broadcast
Listeners allowed Exactly one, ever Any number
Created via async*, Stream.fromIterable*, etc. StreamController.broadcast()
Typical use One-shot data pipelines, file reads UI events, sockets, pub/sub

* Some built-in single-subscription-labeled streams (like Stream.fromIterable) are lenient about re-listening; don't rely on that — generator-based (async*) streams reliably enforce the single-listener rule, and code should be written as if any single-subscription stream will.

Transforming streams: map, where, and friends

Streams support the same functional-style methods as Iterablemap, where, take, skip — except they operate lazily, value by value, as each one arrives.

Future<void> main() async {
  final evensDoubled = Stream.fromIterable([1, 2, 3, 4, 5, 6])
      .where((n) => n.isEven)
      .map((n) => n * 2);

  await for (final v in evensDoubled) {
    print('transformed: $v');
  }
  // transformed: 4
  // transformed: 8
  // transformed: 12
}

Error handling in streams

An error inside a stream is its own kind of event — distinct from a done value — and await for and .listen() handle it differently.

Stream<int> riskyNumbers() async* {
  yield 1;
  yield 2;
  yield* Stream.error(Exception('something went wrong'));
  yield 3; // still runs -- an error event doesn't end the stream by itself
}

Future<void> main() async {
  // await-for: an error is thrown like a normal exception, ending the loop.
  try {
    await for (final n in riskyNumbers()) {
      print('Value: $n');
    }
  } catch (e) {
    print('Caught in await-for: $e');
  }
  // Value: 1
  // Value: 2
  // Caught in await-for: Exception: something went wrong

  // listen(): onError fires, but -- unless you pass cancelOnError: true --
  // the subscription is NOT automatically cancelled, so later events
  // (including "3" here) can still arrive.
  riskyNumbers().listen(
    (n) => print('listen value: $n'),
    onError: (e) => print('listen onError: $e'),
    onDone: () => print('listen onDone'),
  );
  // listen value: 1
  // listen value: 2
  // listen onError: Exception: something went wrong
  // listen value: 3
  // listen onDone
}

This asymmetry is a genuine gotcha: code that works correctly with await for (loop stops on error) can behave completely differently once rewritten with .listen() (keeps going unless you opt in with cancelOnError: true or cancel manually inside onError).

Cheat sheet

Concept Meaning
async* / yield Define a stream that emits values over time
yield* Emit every value from another stream (delegate)
await for Consume a stream synchronously-looking; stops on error
.listen(onData, onError, onDone) Consume via callbacks; errors don't auto-stop it
StreamController.broadcast() Create a stream multiple listeners can subscribe to
.map() / .where() Lazily transform stream values as they arrive
cancelOnError: true Make .listen() behave like await for on error

How It Actually Works

An async* generator function is transformed by the compiler into a state machine plus a StreamController-like object, very similarly to how async functions become Future-driven state machines. Each yield suspends the generator and hands one event to the stream's internal buffer; execution resumes only when the underlying controller determines a listener is ready for more (this is where backpressure enters — an async* generator naturally pauses at its yield point rather than racing ahead and buffering unboundedly, unlike a plain Stream.fromIterable which just push everything through).

Single-subscription vs. broadcast is not a usage convention — it changes the internal buffering strategy of the Stream object itself. A single-subscription stream lazily starts producing events only once .listen() is called, and buffers/holds events if the listener applies backpressure (e.g., pauses the subscription); it guarantees each event is delivered to exactly one listener and enforces (via a runtime check) that a second .listen() call throws a StateError. A broadcast stream, by contrast, has no single-subscriber contract — it does not buffer events for listeners that haven't subscribed yet, and it fans out each event to every currently-subscribed listener independently, which is why broadcast streams routinely drop early events if you subscribe after the producer already started emitting.

Stream.map/.where don't eagerly transform anything at the point you call them — they return a new Stream wrapping the original, whose .listen() implementation pulls from the source and applies the transformation lazily, per-event, only once someone actually subscribes to the transformed stream. This lazy-wrapping chain is why building up a long pipeline of stream transformations is cheap until the final .listen() triggers the whole chain to start pulling data.

Exercise

Write a function Stream<int> countdown(int from) that yields values from from down to 1, one every 300ms, then throws an error instead of a final 0. Consume it twice: once with await for inside a try/catch, and once with .listen() passing cancelOnError: true so the two behave the same way. Then build a StreamController<String>.broadcast() with two listeners that print incoming messages with different prefixes (e.g. "[A] $msg" and "[B] $msg"), add three messages to it, and close it.