The Hidden Trap of Dart Streams, Isolates, and ReceivePorts: Why Your Listeners Stop Working (and How to Fix It)
TL;DR:
If you use Dart isolates with ReceivePort
and expose its stream to your Flutter UI, your listeners will silently break whenever you restart the isolate. The fix? Use a persistent StreamController.broadcast()
as your public stream and pipe all events into it.
The Problem: Listeners That Stop Listening
Recently, I hit a frustrating bug in my Flutter app. I was using isolates to fetch real-time data, exposing a dataStream
getter in my service like this:
Stream<dynamic> get dataStream {
if (_receiver == null) return Stream.empty();
return _receiver!;
}
In my UI, I’d listen to dataStream
in initState()
:
_realtimeInstance.dataStream.listen((data) {
// update state
});
It worked—until I restarted the service (by calling start()
again). Suddenly, my UI stopped receiving updates. No errors, no warnings, just… silence.
The Subtle Cause: ReceivePort Replacement
Every time you call start()
, you create a new ReceivePort
:
_receiver = ReceivePort();
But your UI is still listening to the old stream! The new ReceivePort
is a new stream object, and your listeners are now disconnected.
Result:
Your UI never receives data after the first restart.
Why Isn’t This in the Docs?
- The Dart/Flutter docs focus on simple, static streams.
- Most tutorials don’t cover isolates, or dynamic replacement of streams.
- The
ReceivePort
docs don’t warn you about this “hot swap” problem.
This is one of those bugs you only learn about the hard way.
The Robust Solution: Persistent StreamController
Here’s the pattern you want:
- Create a private, persistent
StreamController.broadcast()
. - Expose its stream as your public API.
- Pipe all data from your
ReceivePort
into this controller.
Example:
final StreamController<dynamic> _controller = StreamController.broadcast();
Stream<dynamic> get dataStream => _controller.stream;
Future<void> start() async {
_receiver = ReceivePort();
_receiver!.listen((event) {
_controller.add(event);
});
// ... start isolate, etc.
}
Now, your UI can listen to dataStream
once, and always receive updates—no matter how many times you start/stop the service or replace the port.
The Takeaway
- Never expose a stream that may be replaced under the hood.
- Always use a persistent
StreamController.broadcast()
for your public API. - Pipe all sources (ReceivePort, sockets, etc.) into it.
Bonus: Why Is This a “Broadcast” Stream?
- A broadcast stream allows multiple listeners (e.g., UI, logs, analytics).
- It prevents “single subscription” errors if you have more than one consumer.
Final Thoughts
If you’re building anything with Dart isolates and real-time data, this pattern will save you hours of silent bugs and confusion. I wish I’d seen this in the docs—so I’m writing it for you!
Did this save you? Let me know in the comments, or share your own Dart gotchas!
Author:
Dr. Pshtiwan Mahmood
Flutter/Dart Enthusiast | Real-time Systems Debugger
Top comments (0)