skip to content

Stream Controllers & Transformers

StreamController pairs a sink for adding events with a stream to listen to, with onListen, onPause and onCancel hooks, and transform() reshapes streams. Interviewers ask who must close the controller.

part ofDartoverview, primer and where to startread it →
on this pageshow

explore

questions

6

In Dart, what does a StreamController give you, and whose job is it to call close() on it?

level: juniorimportance: must knowfreq 62%

answer

  1. two ends of one pipe
  2. add, addError, addStream on the input
  3. hand out stream, keep the controller
  4. creator closes, listeners cancel
  5. close() sends done, then add throws

basics

~10 s

A StreamController pairs an input side (add, addError, addStream, close) with the stream it feeds. The code that creates the controller owns it and must close it; listeners only cancel their own subscriptions.

solid answer

~40 s

`StreamController<T>` from `dart:async` gives you a `stream` to hand to listeners and a sink side — `add`, `addError`, `addStream` and `close` (also exposed as the narrower `sink` view). By default it is single-subscription and delivers events asynchronously. The owner — the class or `State` that created it — keeps the controller private, exposes only `controller.stream`, and calls `close()` in its teardown. `close()` queues a done event; after it, `add` throws a `StateError`. Listeners never close the controller: they `cancel()` their subscription. One trap: the `Future` returned by `close()` never completes if a single-subscription controller was never listened to, so do not blindly `await` it in `dispose`.

code

dart · 17 lines
dart
import 'dart:async';

class TypingIndicator {
  final _controller = StreamController<bool>();

  Stream<bool> get changes => _controller.stream;

  void setTyping(bool typing) {
    if (_controller.isClosed) return;
    _controller.add(typing);
  }

  void dispose() {
    // The owner closes. Not awaited: done never completes if nobody listened.
    unawaited(_controller.close());
  }
}

go deeper

for a junior

Recall the two ends: stream for listeners, add/addError/close for the producer. Say clearly that the creator closes the controller and listeners only cancel.

for a middle

Explain what close() does: queues done after buffered events, makes add throw StateError, is idempotent, and returns the done future.

for a senior

Point out the hang: done never completes for an unlistened single-subscription controller, so awaiting close() in dispose can stall teardown. Mention opting in to the close_sinks lint.

for a principal

Frame controller ownership as an API rule for a codebase: private controllers, public streams, one owner with a dispose contract, reviewed the same way as any other resource handle.

## Two ends of one pipe A **`StreamController<T>`** (library `dart:async`) is the standard way to build a `Stream<T>` whose events come from ordinary imperative code — a button handler, a socket callback, a timer — rather than from an `async*` function. It gives you two ends: - the **output end**, `controller.stream`, which you hand to whoever wants to listen; - the **input end**, the controller itself, which implements `StreamSink<T>`: `add(event)`, `addError(error, [stackTrace])`, `addStream(source)` and `close()`. The controller also offers `controller.sink`, a view that exposes *only* the `StreamSink` interface. Passing `sink` rather than the whole controller lets a producer push events without being able to swap the lifecycle callbacks or inspect `hasListener`. The default constructor creates a **single-subscription** controller (one listener, ever) that is **asynchronous** (`sync: false`): each event is delivered to the listener in a later microtask, not inside the `add` call. ## Who owns it The rule interviewers look for is simple: **whoever creates the controller closes it.** In practice: 1. Keep the controller in a private field (`final _controller = StreamController<Message>();`). 2. Expose only `Stream<Message> get messages => _controller.stream;`. 3. Call `_controller.close()` in the owner's teardown — a service's `dispose()`, a Flutter `State.dispose()`, or the `close()` of whatever object wraps it. Listeners are on the other side of the pipe. They end their interest by calling `cancel()` on their `StreamSubscription`; they never call `close()` on someone else's controller. Mixing the two up is how apps end up with half-dead streams that a second screen can no longer use. ## What close() does | Call | Effect | |---|---| | `close()` the first time | Marks the controller closed and queues a **done** event after any buffered events | | `close()` again | Allowed; returns the same `done` future and has no further effect | | `add` / `addError` after close | Throws `StateError('Cannot add event after closing')` | | `isClosed` | `true` as soon as `close()` has been called, even if done is not yet delivered | `close()` returns the same future as `controller.done`. It completes when the done event has been delivered and the listener has stopped listening. For a single-subscription controller that **never got a listener**, or whose listener paused and never resumed, the done event is never sent and **that future never completes**. So `await _controller.close()` inside a `dispose` can hang forever; fire-and-forget it with `unawaited(...)` unless you really need to know delivery finished. ## Why forgetting to close hurts - Listeners never receive `onDone`, so an `await for` loop over the stream never exits and code after it never runs. - Whatever feeds the controller — a periodic `Timer`, a socket subscription — keeps running and keeps the controller and its buffered events reachable. - On a single-subscription controller with no listener, every `add` is **buffered**, so a forgotten producer grows memory without bound. The Dart linter has a `close_sinks` rule that flags sinks you create but never close. It is stable but belongs to **no** preset set, so you opt in to it in `analysis_options.yaml`. ## A chat-app example In a chat client, a typing-indicator service might own a `StreamController<bool>`, call `add(true)` and `add(false)` as the user types, expose `Stream<bool> get changes`, and close the controller when the conversation screen is torn down. The widget that listens (for example through a `StreamBuilder`) only subscribes and lets its subscription be cancelled; it never closes the service's controller.

  • Why can await controller.close() hang in a dispose method?
    `close()` returns the controller's `done` future, which completes only after the done event reaches the listener. If a single-subscription controller was never listened to, or its listener paused and never resumed, done is never delivered, so the future never completes. Use `unawaited(controller.close())` unless you truly need delivery confirmation.
  • What is the difference between handing out controller.sink and the controller itself?
    `controller.sink` is a `StreamSink<T>` view: `add`, `addError`, `addStream`, `close` and `done`. It hides `stream`, `onListen`, `onPause`, `onResume`, `onCancel`, `hasListener` and `isPaused`, so a producer cannot re-wire lifecycle callbacks or listen to its own output.

saying these in an interview costs you the question

  • Listeners should call close() on the controller when they are done.
  • An unclosed StreamController is harmless because the garbage collector closes it.
  • Calling add after close() is silently ignored.
  • Calling close() twice throws an error.
  • await controller.close() always completes once close is called.
open as a page

In Dart, how does a StreamController.broadcast() controller behave differently from a default StreamController when events are added?

level: middleimportance: must knowfreq 52%

basics

~20 s

A default controller allows one listener and buffers events added before it subscribes or while it is paused. A broadcast controller allows many listeners, drops events added while nobody listens, and lets each subscription buffer its own pauses.

open as a page

In Dart, how do Stream.asyncMap and Stream.asyncExpand differ from map, and do they process events concurrently?

level: middleimportance: should knowfreq 33%

basics

~20 s

asyncMap lets the per-event function return a Future and waits for it; asyncExpand maps each event to a whole stream and emits all its events. Both pause the source while that work runs, so events are processed one at a time, in order.

open as a page

In Dart, how would you use a StreamTransformer and transform() to turn a socket's raw bytes into a stream of chat messages?

level: middleimportance: should knowfreq 38%

basics

~10 s

Chain transformers: decode the socket's bytes to text, split into lines, then parse each line with a StreamTransformer built by StreamTransformer.fromHandlers, whose handleData adds a ChatMessage to its sink. transform(t) simply calls t.bind(stream).

open as a page

In Dart, when do a StreamController's onListen, onPause, onResume and onCancel callbacks fire, and what should each one do?

level: middleimportance: should knowfreq 42%

basics

~20 s

onListen fires when the stream gets its listener and should start the source; onPause and onResume fire as the subscription pauses and resumes and should stop and restart it; onCancel fires when the subscription ends and should release it.

open as a page

A Dart StreamController republishing chat messages from a socket keeps growing in memory while its slow listener is paused — why, and how do you fix it?

level: seniorimportance: should knowfreq 30%

basics

~20 s

A StreamController never refuses add: while its listener is paused, or before it listens, every event is queued. If the socket callback keeps calling add, the queue grows. Pause the socket subscription in onPause, or pipe it with addStream.

open as a page