A Five-Line StreamController Wrapper That Prevents 'Add After Close' Crashes
The guard we add to every StreamController in a Flutter fintech app, and why checking isClosed once at the top of a function isn't enough.
Bad state: Cannot add new events after calling close is one of the most common crash reports in an event-heavy Flutter app, and it almost always shows up in the same place: a balance stream, a transaction feed, or a WebSocket relay that keeps emitting after the screen that cared about it is gone.
Why it happens #
A StreamController gets closed when a widget disposes, a provider is torn down, or a user logs out mid-request. If an in-flight async operation — an API response, a socket message, a timer callback — tries to add() to that controller after the fact, Dart throws immediately. There's no recoverable state; it's a hard crash.
The instinct is to guard it with isClosed:
if (!_controller.isClosed) {
_controller.add(event);
}That's correct exactly once: at the point you check it. Dart's event loop is single-threaded and cooperative, so there's no thread race here — but there is a timing gap wherever your code has an await between the check and the add(). Close the controller during that gap, and the check you did five lines earlier is already stale.
The fix #
Wrap it once, and re-check right before every add — never at the top of a long async function:
class SafeStreamController<T> {
SafeStreamController(this._controller);
final StreamController<T> _controller;
Stream<T> get stream => _controller.stream;
void safeAdd(T event) {
if (_controller.isClosed) return;
_controller.add(event);
}
void safeAddError(Object error, [StackTrace? stackTrace]) {
if (_controller.isClosed) return;
_controller.addError(error, stackTrace);
}
Future<void> close() => _controller.close();
}The rule that actually matters here isn't "wrap your controller" — wrapping it is trivial. It's this:
The part people miss
If your handler does await somethingAsync() and then calls safeAdd, that's fine — the check happens right before the add, inside the same function call. The bug comes back the moment someone "optimizes" by hoisting the isClosed check above multiple awaits to avoid repeating it. Don't hoist it.
Where this bites hardest #
Anywhere a stream outlives a typical widget lifecycle on purpose — a balance listener that's meant to survive screen navigation, a background sync service, a singleton event bus. Those are exactly the streams event-driven fintech architecture depends on (see the event-driven balance updates pattern), which is why this five-line wrapper earns a permanent spot in the base of that architecture rather than being bolted on after the first crash report.
Stuck on this in your own app?
This is the kind of problem I help fintech teams get right the first time — see how App Architecture & Consulting engagements work, or just tell me what you're building.