skip to content

In a Flutter app, what goes wrong when an RxDart subject owned by a screen's controller is never closed, and what does close() change?

level: seniorimportance: should knowfreq 34%

answer

  1. done event ends every listener
  2. add after close: StateError
  3. cached value survives close
  4. addStream blocks close
  5. close_sinks is opt-in

basics

~20 s

An unclosed subject keeps its listeners, their captured objects and any upstream feed alive while something long-lived references it, and listeners waiting for done never finish. close() sends done to every listener and makes later add or addError throw StateError.

solid answer

~40 s

`close()` closes the subject's broadcast controller: every current listener gets a done event, `isClosed` becomes true, and any later `add` or `addError` throws `StateError` — 'Cannot add new events after calling close'. The cached state is kept, so a `BehaviorSubject`'s `value` and a `ReplaySubject`'s `values` are still readable, and calling `close()` twice is harmless. If a controller never closes its subject, the damage depends on what still references it: an upstream subscription feeding it keeps the source running, listener closures keep screen objects reachable, `await for` loops never end, and an unbounded `ReplaySubject` keeps growing. So in `dispose` I cancel the upstream subscription first, then close the subject, and guard late async callbacks with `isClosed`.

code

dart · 16 lines
dart
import 'package:rxdart/rxdart.dart';

Future<void> main() async {
  final subject = BehaviorSubject<int>.seeded(1);
  subject.listen(print, onDone: () => print('done'));

  await subject.close(); // prints 1, then done
  await subject.close(); // harmless: same done future

  print(subject.value); // 1 - the cached value survives
  try {
    subject.add(2);
  } on StateError catch (e) {
    print(e.message); // Cannot add new events after calling close
  }
}

go deeper

for a junior

Remember to close every subject you create in the owner's dispose, and that adding after close throws StateError.

for a middle

Explain what close delivers to listeners, that the cached value survives, and the cancel-then-close order for a subject fed by another stream.

for a senior

Diagnose real leaks by what still references the subject, spot the addStream-blocks-close trap, and guard async callbacks that complete after dispose.

for a principal

Decide how the team enforces disposal: an opt-in lint with known gaps, ownership conventions in review, or moving lifecycles into a library that disposes automatically.

## What close() does on an RxDart subject Every rxdart subject wraps a broadcast `StreamController`, and `Subject.close()` closes it. Concretely: - Every **current listener** receives a done event, so their `onDone` callbacks run and their subscriptions end. - `isClosed` becomes `true`, and the returned `Future` completes when the done event has been delivered. - Any later **`add` or `addError` throws `StateError`** with the message "Cannot add new events after calling close". - The **cached state survives**: a `BehaviorSubject` still returns its last `value`, and a `ReplaySubject` still returns its buffered `values`. The subject stops accepting events; it does not forget them. - Calling `close()` a second time is harmless; the broadcast controller returns the same done future. ## What an unclosed subject actually costs A subject that nothing references is garbage-collected like any other object, closed or not. The damage happens when something **long-lived still reaches it**: | Leftover | Effect | |---|---| | An upstream `listen(_subject.add)` subscription that is never cancelled | the source — a location feed, a socket, a timer — keeps running and keeps the subject reachable | | Listeners that captured a screen's objects | those objects stay reachable after the screen is gone | | `await for`, `toList()` or `.last` waiting on the subject | they wait forever, because no done event arrives | | A `ReplaySubject` without `maxSize` | its buffer grows for as long as the subject lives | The typical Flutter shape is a controller or view model owned by a screen, holding a subject that a singleton service feeds. The screen closes, but the service's subscription still points at the subject, so everything the subject's listeners captured stays alive. ## Closing in the right order In the owner's teardown: 1. **Cancel the upstream subscriptions** that feed the subject, so nothing tries to `add` after the close. 2. **Close the subject** (and every other subject the owner created). 3. Let the rest of the owner's teardown run — for a `State`, the framework's own `dispose` ordering applies. If you close first and cancel second, an event already in flight can hit `add` on a closed subject and throw. ## The addStream trap `Subject.addStream(source)` pipes another stream into the subject, but **while it is active** the subject throws `StateError` on `add`, `addError`, a second `addStream` — and on **`close()`**. With a finite source that is fine: the returned `Future` completes and the subject is usable again. With an endless source such as a sensor feed, `close()` in `dispose` throws. Prefer `listen` with a stored `StreamSubscription` for long-lived sources. ## Late async callbacks A network call started by the screen may complete after `dispose`. Its callback then calls `_subject.add(result)` on a closed subject and throws `StateError`. Options: - cancel the operation's subscription in `dispose`, which is the cleanest fix; - or guard with `if (!_subject.isClosed) _subject.add(result);` where cancellation is not possible. ## Tooling that helps — and its limits The Dart linter rule **`close_sinks`** flags `Sink` instances (a subject is one) that are never closed. It is a stable rule but belongs to **no predefined set**: neither `package:lints` core or recommended nor `flutter_lints` enables it, so you add it to `analysis_options.yaml` yourself. Its documentation lists known limitations — it does not track every pattern — which is why rxdart's own sources carry `// ignore: close_sinks` comments where ownership is clear. Treat it as a hint, and find real retention with a memory profiler. ```dart import 'dart:async'; import 'package:rxdart/rxdart.dart'; class SearchController { SearchController(Stream<List<String>> results) { _sub = results.listen(_items.add, onError: _items.addError); } final _items = BehaviorSubject<List<String>>.seeded(const []); late final StreamSubscription<List<String>> _sub; ValueStream<List<String>> get items => _items.stream; Future<void> dispose() async { await _sub.cancel(); // 1: stop the feed await _items.close(); // 2: done to listeners; add() now throws } } ```

  • Why can close() throw StateError even on a subject that is still open?
    Because an `addStream` is in progress. While it runs, rxdart's `Subject` rejects `add`, `addError`, another `addStream` and `close` with a `StateError`. With an endless source that state never ends, so feed long-lived sources with `listen` and a cancellable subscription instead.
  • Does the close_sinks lint catch an unclosed BehaviorSubject for you by default?
    No. `close_sinks` is a stable Dart linter rule, but it is not in any predefined set: `package:lints` and `flutter_lints` leave it off, so you must enable it in `analysis_options.yaml`. Even then its documented limitations mean it misses some patterns, so it is a hint rather than a guarantee.

saying these in an interview costs you the question

  • Every subject you forget to close leaks, even when nothing references it.
  • After close(), a BehaviorSubject's value is cleared and reading it throws.
  • Adding to a closed subject is silently ignored.
  • Calling close() twice on a subject throws.
  • Close the subject first, then cancel the subscription that feeds it.
  • flutter_lints enables close_sinks, so unclosed subjects are always reported.