Sparround

Stream basics

A Stream<T> is a sequence of values arriving over time. A Future completes once; a Stream emits zero, one or infinitely many data events, then finishes with done or reports an error (and may continue after an error).

The simplest mental model: Future<T> is roughly one value, Stream<T> is roughly the asynchronous counterpart of Iterable<T>.

There are two ways to consume a stream:

  • stream.listen(onData, onError:, onDone:, cancelOnError:) — you get a StreamSubscription back and stay in control (pause, resume, cancel).
  • await for (final x in stream) { ... } — a loop inside an async function. A break or return cancels the subscription automatically.

A stream is passive: in the single-subscription case, nothing starts until listen is called.

PropertySingle-subscription (default)Broadcast
Number of listeners**One**, ever; a second `listen` throws a `StateError`As many as you like, at any time
Events emitted before `listen`Buffered — the listener receives all of them**Lost** — a late subscriber never sees them
Pause behaviourThe source pauses or buffers (backpressure works)The source does not pause — events are buffered for that listener
Typical sourceFile reads, an HTTP response body, an `async*` generatorUser events, an event bus, timers
How to create one`StreamController<T>()``StreamController<T>.broadcast()` or `stream.asBroadcastStream()`

The cancellation obligation. Every time you call listen, you take on an obligation: to cancel() the subscription. An uncancelled subscription:

  • keeps the callback and everything it captures alive — a memory leak;
  • keeps processing events even after the object is logically dead — attempts to write into disposed state;
  • keeps the source open (a socket, a timer, a platform channel) — battery and data cost.

The rule is simple: wherever `listen` is called, `cancel` must be planned. In a long-lived object, hold the subscription in a field and cancel it in dispose(). In Flutter that is a StatefulWidget's dispose(); if you use await for, cancellation is automatic.

A StreamController carries an extra obligation: close(). An unclosed controller never sends done, so an await for loop never ends and whatever holds the controller is never released.

Where do streams show up in real apps? Keep the answer ready — the "give an example" question always comes:

  • User input: text typed into a search field (with debounce), scroll position, gesture events.
  • Network: WebSockets, Server-Sent Events, reading a large HTTP response in chunks (response.stream).
  • Platform: location updates, sensors and the accelerometer, battery status, connectivity changes, notifications.
  • Databases: Firestore snapshots(), Drift/Isar watch() — a new event arrives whenever the query result changes.
  • Internal event bus: auth state changes, locale changes — these are typical broadcast streams.

An important nuance: most of these should be broadcast, because several places listen to them. If a repository returns a single-subscription Stream, consumers can hit an unexpected StateError.

Interview tip. The classic question: "what is the difference between a single-subscription and a broadcast stream?" A strong answer does not stop at listener count — give three differences: (1) how many listeners; (2) buffering — a single-subscription stream holds events until listen, a broadcast stream does not and a late subscriber loses them; (3) backpressure — with a single-subscription stream pause() can actually slow the source, with a broadcast stream it cannot.

Second question: "what happens if you never cancel a subscription?" Answer: a memory leak, writes into a disposed object, and the source staying open. Sum it up in one line: `listen` is an obligation and `cancel` is how you pay it.

The most common mistake is describing a Stream as "a Future that returns several values" and skipping the buffering and cancellation differences.

📚 Sources and documentation