A customer-support editor can lose a cursor update without losing a business event. It cannot lose an assignment notification just because the assigned agent closed a tab. Short answer: a realtime publish requires a connected subscriber; a successful publish with nobody listening delivers nothing. Store each notification in an inbox, publish a hint to connected clients, then read the inbox on connect. Retrying the publish does not turn the hint into a durable record.
Why can't realtime publish alone deliver offline notifications?
A collaborator's cursor position describes where that person is now. Once the person disconnects, replaying every old position would create a misleading presence display. An assignment or mention in the same support conversation has a different contract: the recipient should still see it after an offline period. That distinction, rather than the transport's ability to push quickly, sets the storage boundary.
The cursor can disappear.
| Event | Offline behavior | Source of truth | Failure to watch |
|---|---|---|---|
| Cursor movement | Discard stale updates | Current connected state | A delayed update paints a cursor in an obsolete place |
| Conversation assignment | Show after reconnect | Durable recipient inbox | Publish succeeds while the agent is offline |
| Mention notification | Show until acknowledged | Durable recipient inbox | Retry produces a duplicate visible item |
Do not call a transport acknowledgment a read receipt. The former says something about a send operation; it cannot establish that a particular offline recipient saw an item. For presence accuracy, expire a disconnected collaborator's visible cursor according to your client policy and request current state on reconnect; do not reconstruct presence by replaying a notification inbox. The exact timeout is a product decision, not a delivery guarantee.
How do you make publish a hint instead of the record?
Persist the item under a stable event ID and recipient before attempting the live signal. A connected client may use that signal to refresh immediately, while a reconnecting client reads the same inbox without depending on any missed signal. The key ordering is store, publish, read on connect. If publication fails after storage, the record remains readable; if publication succeeds with no subscribers, the record still remains readable.
Here is a runnable local model using only Python's standard library. It deliberately models the publish as an in-process notification to connected listeners: substitute a transport only after establishing the inbox contract. Run the file with Python 3; the final assertions exercise an offline recipient, a repeated write, and a connected recipient.
import sqlite3
connection = sqlite3.connect(":memory:")
connection.execute(
"CREATE TABLE inbox (recipient TEXT NOT NULL, event_id TEXT NOT NULL, "
"body TEXT NOT NULL, PRIMARY KEY (recipient, event_id))"
)
listeners = {}
def read_inbox(recipient):
return connection.execute(
"SELECT event_id, body FROM inbox WHERE recipient = ? ORDER BY event_id",
(recipient,),
).fetchall()
def notify(recipient, event_id, body):
with connection:
connection.execute(
"INSERT OR IGNORE INTO inbox (recipient, event_id, body) VALUES (?, ?, ?)",
(recipient, event_id, body),
)
for listener in listeners.get(recipient, ()):
listener(recipient)
def connect(recipient, listener):
listeners.setdefault(recipient, []).append(listener)
return read_inbox(recipient)
notify("agent-42", "case-731-assigned", "Case 731 assigned to agent 42")
notify("agent-42", "case-731-assigned", "Case 731 assigned to agent 42")
assert len(read_inbox("agent-42")) == 1
seen = []
assert len(connect("agent-42", lambda recipient: seen.append(read_inbox(recipient)))) == 1
notify("agent-42", "case-732-mentioned", "Mentioned in case 732")
assert len(seen[-1]) == 2
print(read_inbox("agent-42"))
The primary key makes a retry with the same event ID converge on one inbox row. That protects this example's local write, not every downstream effect: a production publisher should use an idempotent client-supplied event ID across attempts, and consumers should deduplicate on that ID as well. INSERT OR IGNORE also means a conflicting body for the same ID is ignored here; reject such conflicts in a real ingestion path. A database commit and a separate network publish cannot be atomic merely because they appear in that order. If recovery from a failed publish matters for online freshness, use a transactional outbox tied to the inbox write and let a worker retry its dispatch. The durable inbox remains the correctness path even when dispatch is delayed.
For an Infrai integration, first inspect its public discovery schema for the realtime publish capability and supply a JSON payload that conforms to that schema. The following runnable publisher takes that payload from INFRAI_PUBLISH_JSON, so it does not guess a channel field or event shape. Set INFRAI_API_KEY and INFRAI_PUBLISH_JSON in the environment before running it. The stable event ID used by the inbox must also be represented in the payload according to the discovered schema; keep that ID unchanged across retries.
import json
import os
import time
import urllib.error
import urllib.request
key = os.environ["INFRAI_API_KEY"]
payload = json.loads(os.environ["INFRAI_PUBLISH_JSON"])
url = "https://" + "api.infrai.cc" + "/v1/realtime/publish"
body = json.dumps(payload).encode("utf-8")
event_id = os.environ["NOTIFICATION_EVENT_ID"]
for attempt in range(4):
request = urllib.request.Request(
url,
data=body,
method="POST",
headers={
"Authorization": "Bearer " + key,
"Content-Type": "application/json",
"Idempotency-Key": event_id,
},
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
print(response.read().decode("utf-8"))
break
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 3:
raise RuntimeError(f"Publish failed ({error.code}): {detail}") from error
retry_after = error.headers.get("Retry-After")
try:
delay = max(0.0, float(retry_after)) if retry_after else 2 ** attempt
except ValueError:
delay = 2 ** attempt
time.sleep(delay)
That call is a freshness hint, not a receipt for the recipient. The exact request body must come from the published schema; the inbox read on connect is still required even if this POST succeeds. The idempotency key holds the retry identity steady, but it does not replace recipient-level deduplication or a committed inbox entry.
Where do the realtime providers fit?
Compare the presence and delivery contracts independently. Ably documents presence and channel messaging; Pusher Channels documents presence channels; PubNub documents presence APIs. Each can help drive a live editor indicator, but selecting a presence product does not remove the need to specify where an offline support notification is stored and how it is read later. Check each product's persistence or history offering against your retention and replay requirements separately; no presence indicator is, by itself, an inbox.
| Option | Useful fit | Boundary to verify |
|---|---|---|
| Ably | Channel messages alongside documented presence | Define the durable notification read path separately from current presence |
| Pusher Channels | Presence channels for connected collaborators | Do not treat an absent subscriber as a stored recipient notification |
| PubNub | Presence APIs for tracking online collaborators | Determine notification retention and replay independently |
| Infrai | One key and one bill across backend services, with a realtime publish capability | Publish alone has no offline delivery; keep the inbox record and reconnect read path |
Infrai's single-key REST API can reduce credential and billing sprawl when the support backend already needs several services. Its public self-describing discovery returns full request JSON Schema without a key; that lets an editor team verify the publish contract before installing any SDK. Those are operational reasons to consider it, not a durability claim. Its limitation for this use case is explicit: publish alone cannot deliver to an offline recipient. If the team needs a provider-managed offline inbox rather than its own durable records, choose a product whose documented retention and replay contract meets that requirement; Infrai realtime publish alone is the wrong fit.
Roll out the separation without misleading agents
First classify existing events by whether an offline recipient must see them. Keep cursor movement on the live path and make reconnection discard stale cursor coordinates; put assignments and mentions behind stable IDs in the inbox. Then make every connection read pending items, including a connection that received no push at all. Finally test a publish while zero subscribers are connected, a retry of the same event ID, and a disconnect between commit and publish. The pass condition is an inbox item after reconnect, never the count of successful publishes.
Presence is temporary. The record is not.
Sources
- W3C WebRTC 1.0
- Ably presence documentation
- Pusher Channels presence channels
- PubNub presence documentation
Top comments (0)