DEV Community

Cover image for The Hidden Trap of Dart Streams, Isolates, and ReceivePorts: Why Your Listeners Stop Working (and How to Fix It)
Pshtiwan Mahmood
Pshtiwan Mahmood

Posted on

The Hidden Trap of Dart Streams, Isolates, and ReceivePorts: Why Your Listeners Stop Working (and How to Fix It)

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!;
}
Enter fullscreen mode Exit fullscreen mode

In my UI, I’d listen to dataStream in initState():

_realtimeInstance.dataStream.listen((data) {
  // update state
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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:

  1. Create a private, persistent StreamController.broadcast().
  2. Expose its stream as your public API.
  3. 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.
}
Enter fullscreen mode Exit fullscreen mode

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)