In Dart, what does a StreamController give you, and whose job is it to call close() on it?
answer
- two ends of one pipe
- add, addError, addStream on the input
- hand out stream, keep the controller
- creator closes, listeners cancel
- close() sends done, then add throws
basics
~10 sA 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 linesimport '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
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.
Explain what close() does: queues done after buffered events, makes add throw StateError, is idempotent, and returns the done future.
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.
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.