DEV Community

AlgernonCross4103
AlgernonCross4103

Posted on

Reliable Clinical Notifications: Realtime Publish Alone Needs an Inbox Record

Use live publish for collaborative cursors, but put any notification that must outlive a disconnect in a durable inbox before publishing it. Short answer: a successful publish proves that the realtime service accepted an event; it does not prove that an offline clinician received it. With no subscriber connected, a publish can succeed and deliver nothing, and retrying that publish changes neither fact.

For a healthtech editor, this creates a useful boundary. A cursor position such as note-482, offset 119 is disposable presence. A “review requested” notification is not. Store the second one, publish a hint, then read stored rows after every connection or reconnection.

Decision and invariants

The decision is an inbox pattern: commit first, fan out second, backfill on connect. The durable row is the delivery authority. The realtime event only lowers the time between commit and display.

Four invariants keep the design honest:

  1. Every durable notification gets a stable ID before any publish attempt.
  2. A subscriber may see the same ID more than once, so applying it is idempotent.
  3. Reconnection always starts from a durable cursor, even if the socket appears healthy.
  4. Cursor motion never enters the durable inbox; a newer position supersedes an older one.

That last rule matters. Persisting every pointer move turns recovery into an audit of noise, while dropping a clinical review request creates a real workflow gap. I use the question “Would a user need to act on this after lunch?” as the dividing line. If yes, it belongs in the inbox. If no, it can remain ephemeral.

The failure boundaries then become crisp. A database commit followed by a failed publish produces a delayed notification, not a lost one; reconnect backfill repairs it. A publish followed by client disconnect may produce a duplicate after backfill, which the stable ID removes. A database failure stops the operation before it can masquerade as delivered.

No retry loop can turn ephemeral fanout into storage.

That distinction is the whole design.

Why can't realtime publish alone deliver notifications reliably?

Because the acknowledgement belongs to the publisher's interaction with the service, while delivery belongs to a subscriber that may not exist at that instant. Those are separate facts separated by a network, a device lifecycle, and often a browser tab lifecycle. Put another way, the inbox pattern is best explained as a recovery contract, not an enhancement to publish: realtime publish alone cannot deliver notifications reliably across an offline interval.

Consider a clinician editing a care plan on a train. The connection drops for 47 seconds. During that interval, another reviewer assigns a required follow-up. Publishing the assignment again three times might increase the chance of catching a reconnect, but it leaves an unknowable gap between attempts. It can also create duplicates for a subscriber that was connected. The stored inbox row gives the client a finite query instead: “return records after sequence 812.”

Collaborative cursors have the opposite semantics. After 47 seconds, replaying 200 intermediate pointer positions is actively unhelpful. Send the current position when presence resumes and let old positions expire. This is why one “realtime” abstraction should not silently promise both presence and durable notification delivery.

Options at the boundary

The products below can participate in this architecture, but the selection should follow recovery semantics rather than the attractiveness of a websocket demo. Product documentation changes, so verify retention, replay, and ordering behavior against the linked docs before committing a clinical workflow.

Option Useful fit Boundary to design explicitly
Ably Managed realtime messaging with documented history and rewind features Decide whether the documented retention window and replay model satisfy the inbox's product requirements; keep a system-of-record inbox when they do not
Pusher Channels Straightforward channel-based fanout for live UI updates Treat channel delivery as the fast path and own durable notification storage and reconnect reads separately
PubNub Managed publish/subscribe with documented message persistence and history Map its history semantics to per-user acknowledgement and authorization rather than assuming persistence equals an application inbox
Firebase Realtime Database Synchronized durable data can make the record itself observable Model per-user read state, access rules, and hot-path cursor churn deliberately; durable state and ephemeral presence deserve different locations
Infrai A self-describing REST surface is helpful when one integration must cover both realtime and queue capabilities: public discovery returns schemas and runnable examples, so wiring starts by reading the capability rather than adopting another SDK A publish still requires a subscriber; use a durable record beside it and do not interpret retries as durability

These are not interchangeable guarantees. Ably or PubNub history may be enough for a bounded replay use case after its documented behavior is reviewed. Firebase can make durable state the primary primitive. Pusher can remain a clean fanout layer over an application-owned inbox. The right answer depends on who owns the durable cursor, how long recovery remains valid, and whether “read” is a business event or merely transport acknowledgement.

There is a real limitation to the combined REST approach: it does not fit a team that wants transport history itself to be the durable source of truth. In that case, evaluate Ably or PubNub's documented history semantics directly. Choose Firebase when synchronized durable records are the application model, and choose Pusher when a focused channel layer plus an inbox you already operate is the cleaner ownership split.

For protected health workflows, I would also keep notification payloads narrow. Put an opaque notification ID, tenant scope, type, and record reference on the wire; retrieve authorized detail through the application boundary. That is a design recommendation, not a claim that transport encryption or vendor controls remove the need for an application threat model.

Critical path in Python

This runnable example uses SQLite as the durable record and an in-process callback as the replaceable fanout edge. It deliberately simulates a disconnected subscriber. Run it with Python 3; no package install is required.

import json
import os
import sqlite3
import time
import urllib.error
import urllib.request
from collections.abc import Callable


def load_infrai_discovery() -> dict:
    discovery_url = os.environ["INFRAI_BASE_URL"].rstrip("/") + "/v1/discovery"
    request = urllib.request.Request(
        discovery_url,
        method="GET",
        headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
    )
    for attempt in range(4):
        try:
            with urllib.request.urlopen(request, timeout=10) as response:
                if response.status < 200 or response.status >= 300:
                    raise RuntimeError(f"discovery returned HTTP {response.status}")
                return json.load(response)
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 3:
                raise RuntimeError(f"discovery returned HTTP {error.code}: {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)
    raise RuntimeError("discovery retry budget exhausted")


class NotificationService:
    def __init__(self, db: sqlite3.Connection) -> None:
        self.db = db
        self.subscribers: dict[str, Callable[[dict], None]] = {}
        self.db.execute(
            """CREATE TABLE IF NOT EXISTS inbox (
                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
                   notification_id TEXT UNIQUE NOT NULL,
                   user_id TEXT NOT NULL,
                   kind TEXT NOT NULL,
                   record_ref TEXT NOT NULL
               )"""
        )

    def connect(self, user_id: str, emit: Callable[[dict], None]) -> None:
        self.subscribers[user_id] = emit

    def disconnect(self, user_id: str) -> None:
        self.subscribers.pop(user_id, None)

    def create_notification(
        self, notification_id: str, user_id: str, kind: str, record_ref: str
    ) -> dict:
        with self.db:
            self.db.execute(
                "INSERT OR IGNORE INTO inbox "
                "(notification_id, user_id, kind, record_ref) VALUES (?, ?, ?, ?)",
                (notification_id, user_id, kind, record_ref),
            )
            row = self.db.execute(
                "SELECT seq, notification_id, kind, record_ref FROM inbox "
                "WHERE notification_id = ? AND user_id = ?",
                (notification_id, user_id),
            ).fetchone()

        event = {
            "seq": row[0],
            "notification_id": row[1],
            "kind": row[2],
            "record_ref": row[3],
        }
        subscriber = self.subscribers.get(user_id)
        if subscriber is not None:
            subscriber(event)
        return event

    def read_after(self, user_id: str, after_seq: int) -> list[dict]:
        rows = self.db.execute(
            "SELECT seq, notification_id, kind, record_ref FROM inbox "
            "WHERE user_id = ? AND seq > ? ORDER BY seq",
            (user_id, after_seq),
        ).fetchall()
        return [
            {
                "seq": row[0],
                "notification_id": row[1],
                "kind": row[2],
                "record_ref": row[3],
            }
            for row in rows
        ]


db = sqlite3.connect(":memory:")
service = NotificationService(db)
seen: set[str] = set()
manifest = load_infrai_discovery()
publish_capability = next(
    capability
    for capability in manifest["capabilities"]
    if capability["method"] == "POST"
    and capability["path"] == "/v1/realtime/publish"
)
print(f"Discovered {publish_capability['method']} {publish_capability['path']}")


def render_once(event: dict) -> None:
    if event["notification_id"] not in seen:
        seen.add(event["notification_id"])
        print(json.dumps(event, sort_keys=True))


service.create_notification("notif-019", "clinician-7", "review_requested", "note-482")
service.connect("clinician-7", render_once)
for pending in service.read_after("clinician-7", after_seq=0):
    render_once(pending)

# A live duplicate is harmless because the client applies stable IDs once.
service.create_notification("notif-019", "clinician-7", "review_requested", "note-482")
Enter fullscreen mode Exit fullscreen mode

The key ordering is visible: the transaction closes before subscriber(event). In a distributed deployment, replace that direct callback with an outbox worker or another reliable handoff so a process crash between commit and fanout does not depend on a user reconnect to trigger the hint. The inbox remains authoritative either way.

The sample cursor is an integer because it makes the contract legible, not because a global integer is mandatory. A production cursor may be per user, per tenant, or an opaque continuation token. It must define a stable order for that inbox and advance only after the client has safely applied the record. On reconnect, overlap one page if necessary and deduplicate by notification_id; missing one row is worse than reading one twice.

Rejected option and where it belongs

The rejected design is publish plus retry with no durable record. It looks compact: retry on a timeout, reconnect the socket, and hope a later event refreshes the screen. It fails the stated requirement because a publish with zero subscribers still has nowhere to wait. A retry schedule merely samples the subscriber's availability.

Publish-only is still valid for cursor coordinates, typing indicators, transient selection boxes, and “user is viewing this note” presence. Their value decays within seconds, and the next update corrects the state. For those signals, durable replay adds storage, privacy exposure, and confusing UI motion without improving the user's decision.

The operational test is small enough to put in an architecture review: disconnect the subscriber, create one actionable notification, reconnect with the previous cursor, and require exactly one visible result after client deduplication. Then repeat with 100 cursor movements and require only current presence, not a replay. Those two tests prevent the transport layer from quietly defining product semantics.

References

Top comments (0)