skip to content

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.