Scoped Streams, Stable Real-Time Visuals

Scoped Streams, Stable Real-Time Visuals

Real-time apps are at their best when they feel calm. Numbers update, charts glide, tiles pulse with new information, but nothing spikes, freezes, or drifts. The UI feels more like a live instrument panel than a slideshow. To get there, though, you need more than a WebSocket and a couple of StreamBuilders. You need a design where streams have clear lifetimes, state is scoped to the UI that owns it, and the backend respects the limits of the client.

This post walks through how to build scoped, reference-counted streams and long-lived UIs that can run for hours without leaking, even when the backend is streaming device telemetry or time-series data at high frequency. The ideas map nicely onto stacks like Flutter plus Python (FastAPI, asyncio, WebSockets), but nothing here is tied to a single framework. Think in terms of client, session manager, and real-time backend, and you can transplant these patterns anywhere.

Why scoped streams matter in real-time products

A real-time product is not just an app with a chart. It's a system where data flows continuously from devices or services, users leave the app open for long periods, and navigation, reconnects, and backgrounding are the norm, not edge cases.

Without discipline you end up with streams that live longer than any screen that uses them, subscriptions attached to widgets instead of features, duplicate listeners for the same topic each doing redundant work, and backends blasting raw events faster than the UI can paint. The symptoms show up slowly: memory creep, GC churn, occasional dropped frames, charts that lag after navigation. You restart the app and everything is fine, for a while.

The fundamental fix is scoping, deciding where each stream is allowed to live and who's allowed to talk to the backend. Once those boundaries are explicit, memory leaks and redraw storms get a lot harder to accidentally create.

Three lifetimes: session, feature, widget

A simple rule of thumb: every stream in your app must belong clearly to one of three lifetimes. Session scope lives for the whole logged-in session or app runtime. Feature scope lives for as long as a route, tab, or screen is active. Widget scope lives for as long as a specific view subtree is mounted.

Most teams get into trouble when they let session concerns leak into widgets or let features talk to the backend directly. Instead think of the three scopes as layers, the session owns the real-time connection and acts as the stream gateway, the feature owns domain logic and transforms the raw streams, and the widget owns only presentation and local UI quirks.

For example, in a data-driven mobile app plotting sensor metrics in real time, the session layer establishes a single WebSocket and routes messages by topic, the feature layer asks the session layer for a topic stream, down-samples it, aggregates it, and exposes a chart-ready stream to the UI, and widgets render charts and tiles from that processed stream without knowing anything about the transport. This separation is what lets you add more sensors, more tabs, or more device types later without turning your stream layer into a ball of mud.

Designing the session layer: one gateway, many topics

The session layer is the only part of the client that talks to the real-time backend directly. It owns a single connection, understands a topic model like device/123/telemetry, maintains a topic registry and reference counts, and demultiplexes incoming messages by topic, publishing them to per-topic subjects. The moment a second component tries to open its own WebSocket, you should have to justify it very carefully. Most of the time a single, well-designed session gateway is enough.

Internally you can structure the session gateway around two maps, one from topic name to subject, and one from topic name to refCount:

class SessionStreams {
  final _subjects = <String, BehaviorSubject<dynamic>>{};
  final _refCounts = <String, int>{};
 
  Stream<T> acquire<T>(String topic) {
    _refCounts[topic] = (_refCounts[topic] ?? 0) + 1;
 
    final subject = _subjects.putIfAbsent(
      topic,
      () {
        // Optionally send 'subscribe(topic)' to backend here
        return BehaviorSubject<dynamic>();
      },
    );
 
    return subject.cast<T>();
  }
 
  void release(String topic) {
    final next = (_refCounts[topic] ?? 0) - 1;
    if (next <= 0) {
      _refCounts.remove(topic);
      _subjects.remove(topic)?.close();
      // Optionally send 'unsubscribe(topic)' to backend here
    } else {
      _refCounts[topic] = next;
    }
  }
 
  void handleIncoming(String topic, dynamic payload) {
    _subjects[topic]?.add(payload);
  }
}

The logic is straightforward but powerful: features ask for a topic stream via acquire(topic), the gateway returns the same subject for all callers, incrementing the reference count, and when features are done they call release(topic). When the count drops to zero, the subject closes and the backend can be told to unsubscribe. You've effectively created a multiplexed pub-sub connection where the backend sees a single socket, and the client gets many typed streams with automatic lifetime management.

Topic names should not be arbitrary string literals scattered through your code, they define a contract between backend and client, and between session layer and feature layer. Prefer structured identifiers like device/{deviceId}/telemetry, device/{deviceId}/alerts, or user/{userId}/notifications. On the backend, routing tables and aggregators understand these strings. On the frontend, features call acquire with these same IDs. This is particularly handy in multi-device ecosystems, a wearable on the pet, a smart feeding bowl, a hub device in between, even if payload formats differ the transport model stays consistent.

The feature layer: turning raw events into UI-ready streams

If the session layer's job is "get data in," the feature layer's job is "shape data for humans." This is where you separate event rate from paint rate, compute derived state, and enforce bounds on how much data the UI must handle.

Imagine telemetry coming in at 50Hz for an accelerometer or 10Hz for bowl weight. Sending every event directly into a chart widget is the fastest way to overload the charting library, flood the garbage collector with tiny objects, and turn smooth scrolling into a slideshow. A better pattern treats the raw per-event stream as input, exposing a second, derived stream of frames at a much saner rate, say 5-10 updates per second:

class ChartStream {
  final Stream<List<DataPoint>> frames;
 
  ChartStream(Stream<DataPoint> raw)
      : frames = raw
          .bufferTime(const Duration(milliseconds: 100))
          .where((batch) => batch.isNotEmpty)
          .map((batch) => _mergeIntoWindow(batch));
 
  static List<DataPoint> _mergeIntoWindow(List<DataPoint> batch) {
    // Append to a circular buffer and return the visible window.
    // Implementation details depend on your chart's model.
  }
}

The feature owns the raw stream, the buffer, and the windowing logic. The widget sees only a stable, bounded stream of frames, easy to reason about. You can tune the buffer window size, the window length, and the aggregation logic, all living in the feature layer, not the widget and not the session manager.

Most real-time screens need more than raw series, a status indicator (streaming, paused, stale), summary tiles, or simple booleans like "isAlerting." These can be built as small streams derived from the main frame stream, and your widgets can subscribe selectively, a header bar observes connection status, a small pill observes an alert flag, a chart tile observes only the data frame stream. This keeps each widget focused and prevents them from reacting to irrelevant changes.

Backend responsibilities: aggregation, not megaphone

A stable client can still struggle if the backend behaves like a firehose. If every sensor tick or DB write is forwarded verbatim to the socket, the client is left doing excess JSON parsing, per-message routing, and fine-grained state recomputation. You get better results when the backend acts as an aggregator that publishes state frames at a controlled cadence.

On the server, think in terms of topic channels. Each channel maintains a mutable state object, accepts deltas from upstream sources, and periodically broadcasts a snapshot of the current state to all subscribers:

class TopicChannel:
    def __init__(self, topic: str):
        self.topic = topic
        self.state = {}          # current snapshot
        self.subscribers = set() # connected websockets
        self._lock = asyncio.Lock()
 
    async def apply_delta(self, delta: dict):
        async with self._lock:
            self.state.update(delta)
 
    async def broadcast_loop(self, interval: float):
        while True:
            await asyncio.sleep(interval)
            async with self._lock:
                if not self.subscribers:
                    continue
                frame = dict(self.state)
            message = {
                "topic": self.topic,
                "version": int(time.time() * 1000), # or monotonic counter
                "timestamp": datetime.utcnow().isoformat() + "Z",
                "state": frame,
            }
            for ws in list(self.subscribers):
                await ws.send_json(message)

Now the backend controls cadence, how often frames get sent for each topic, shape, the structure of the state object the UI consumes, and consistency, each frame is internally coherent. If you need deltas for bandwidth reasons you can emit patch objects instead of full state, but the key idea remains: aggregation happens server-side, not in every client independently.

Each frame should carry a small set of metadata making client logic simpler, a version or sequence number to detect out-of-order delivery, a server timestamp to measure freshness and latency, and optional source tags if multiple pipelines feed the same topic. With that the client can ignore stale frames that arrive late, display stale-data warnings if no fresh frame arrives within a threshold, and infer approximate end-to-end latency for debugging.

When many clients subscribe to a hot topic, or some devices run on weak networks, you need a backpressure strategy. A simple, effective one: maintain a small outgoing queue per client, and if it grows beyond a limit, drop intermediate frames and keep only the latest. In stricter setups, close the connection and rely on client reconnection. Because the frames are self-contained snapshots, it's safe for a client to miss some and still converge on the latest state, the client's session layer doesn't need to replay any backlog, it just keeps processing whatever frames arrive, in order.

Reconnects, navigation, and "ghost" listeners

Real-time connections don't exist in a vacuum. Users switch between Wi-Fi and mobile data, background the app while commuting, and bounce between multiple real-time screens. If reconnects and navigation logic are scattered across features, ghost listeners are almost guaranteed, abandoned subscriptions whose owners no longer exist.

Connection management belongs in the same place as topic management, the session layer. A robust session gateway tracks connection state (disconnected, connecting, connected, backoff), applies exponential backoff between attempts, on successful reconnect replays the set of active topics to the backend, and surfaces a minimal connection status stream to features. Features then become almost stateless regarding connectivity, listening to their topic streams and optionally to a lightweight connection status stream, but never handling reconnection themselves.

On the feature side you want a simple invariant: every acquisition of a topic has a matching release when the feature is destroyed. A base class can enforce most of this:

abstract class RealtimeFeatureBase {
  final SessionStreams session;
  final _topics = <String>[];
 
  RealtimeFeatureBase(this.session);
 
  Stream<T> topic<T>(String name) {
    _topics.add(name);
    return session.acquire<T>(name);
  }
 
  @mustCallSuper
  void dispose() {
    for (final t in _topics) {
      session.release(t);
    }
  }
}

Concrete feature controllers or view models extend this class, call topic<T> instead of hitting the session manager directly, and make sure to call super.dispose() in their own tear-down paths. That one small pattern dramatically lowers the chance a screen popped off the navigation stack is still holding onto a live telemetry stream.

Because topics and refCounts are tracked centrally, it's easy to expose diagnostics for internal builds, a list of active topics with reference counts and last frame timestamps, a log view of acquisitions and releases over time, and a leak detector asserting refCounts for certain topics return to zero after scripted navigation. Simple scripted tests, open and close the same telemetry screen 100 times, should show the topic table returning to the same baseline. If refCounts drift upward, you have a leak.

Observability and test strategy

Even with good architecture, you only gain confidence when you see the system behave under stress. On the client side, instrument the session layer and feature base classes to emit simple metrics, number of active topics, total refCount sum, approximate messages per second per topic, and connection state transitions. Combine that with platform tools like memory graphs and CPU sampling and you can correlate topic counts versus memory usage over time, connection flaps versus UI responsiveness, and navigation patterns versus refCount spikes.

On the backend, track frames per second per topic, per-client send queue lengths, and average frame size and serialization time. Then build a small suite of test scenarios, even just scripts run locally, rapidly switching between multiple telemetry screens, leaving a dashboard open while data streams in for an hour, and simulating network drops on an emulator or device while watching reconnect behavior. You're aiming for flat lines in the right places, memory and topic count should stabilize, not grow unbounded, CPU utilization should show predictable spikes when heavy charts are visible, not random bursts when the app is backgrounded.

At Hoomanely, the mission is giving pet parents a clearer, more data-driven view of their pet's health, feeding patterns, activity levels, and subtle changes in day-to-day behavior. That vision only works if the real-time surfaces are trustworthy. For devices like Everbowl and EverSense, telemetry flows constantly, parents open the app to see live bowl weight or recent motion trends, they don't want to think about streams, reconnections, or memory, they just expect the UI to stay responsive and meaningful over time. The scoped stream architecture described here is what lets the product behave that way, a single disciplined session gateway instead of ad-hoc sockets, feature controllers that treat raw telemetry as just another input, shaping it into charts, tiles, and alerts, and backends that aggregate and frame data with the UI's limits in mind.

Key takeaways

If you want a simple mental model to carry into your next real-time project, make it this: one connection, many topics, scoped carefully. The session layer talks to the network, features talk to the session layer, widgets talk to features. And backends publish frames, not firehoses. When you design with those principles from day one, the rest of the tech stack, whether Flutter and Python, something web-based, or another combination entirely, has a solid foundation to sit on. Your charts will still be animated, your tiles will still glow with fresh data, but under the hood the system will be doing something far less exciting: staying stable.