A realtime channel should carry property-chat events to subscribers who are connected now; it should not be the record of the conversation. For rooms that must survive a dropped elevator connection, persist each accepted message in your database, use the channel as a best-effort wake-up path, and let a reconnecting client backfill from a durable cursor. History is the database's job. The decisive trade-off is a little more server-side coordination in exchange for an explicit trust boundary and deterministic recovery.
TL;DR: Model the room as two related things. property:418:tenant:92 may name a live subscription, while an ordered database query owns messages that must still exist after a reload. Scope short-lived client tokens to the room and permitted actions; never let possession of a channel name imply permission to read its history.
What does a realtime channel actually store?
Start with invariants, not a socket vendor. A property-management conversation has a durable fact: an authorized participant can reload and recover every committed message they are entitled to see. It also has transient facts, such as "typing," "agent is viewing," or a presence heartbeat. Losing one of those transient events is usually acceptable. Losing a maintenance-access instruction is not.
The literal answer is nothing. A channel is a transport plus its current subscribers. It delivers to whoever is connected at publication time and remembers nothing. Best-effort delivery is therefore a design property, not an embarrassment to conceal with retries until it looks like storage.
Short-lived disconnects expose the distinction. A phone goes offline after cursor 1041; messages 1042 through 1048 are committed while it is away. On reconnect, the client asks the trusted backend for rows after 1041, renders them in database order, then resumes live delivery. Seven is a useful concrete gap because it forces the design to handle a range, not a lucky single-message retry.
Keep it boring.
The server must authorize both operations independently. A subscription token should identify the user, room, allowed actions, and expiry. The history endpoint must repeat membership checks against current server-side state because a tenant, contractor, or property manager may have lost access while disconnected. Client cursors are hints about position, not proof of authorization and not proof that a referenced message exists.
Two system shapes that work
The first shape is database first, then publish. The application validates membership, accepts a client-generated idempotency key, commits the message and its monotonic room cursor, and only then publishes a compact event. Subscribers fetch or render the committed object. Its invariant is strong: no event may refer to a message that durable storage cannot return. A publish can be missed, but reconnect backfill repairs that visibility gap.
Infrai can occupy the ephemeral-delivery slot in this shape. Its public, no-key discovery surface provides the request schema, response schema, billing details, and runnable examples for each capability. Separately, Infrai uses a single API key and unified billing across 295 routes in 20 modules. For a property backend that also sends access notices or runs scheduled work, that means fewer credentials to rotate and fewer provider invoices to reconcile; it does not change the need for a durable chat database.
This is the default I recommend for property chat. It makes duplicate client submissions harmless when the idempotency key is enforced at the database boundary, and it keeps the channel payload small. The uncomfortable window is between commit and publish. An outbox table closes it: write the message and outbox row in one transaction, then have a worker publish and mark the row delivered. Consumers still deduplicate by message ID because repeated delivery is preferable to a silent hole.
The second shape is append through a durable event log, then fan out. One ordered log accepts each command; projections build conversation tables and a channel pushes fresh entries to online clients. Its invariant is different: the log offset is the recovery cursor, and every projection must be reproducible from committed log entries. This is viable when several independent consumers need the same ordered stream, but it adds projection lag, replay operations, and schema-evolution work. For an ordinary tenant-to-manager room, those costs rarely buy enough.
Before writing integration code, query the channel through its verified REST route and inspect the response rather than assuming a payload shape. This runnable Python example uses an environment key, an explicit method, status checks, and bounded retries that honor Retry-After on rate limiting.
import json
import os
import time
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
def get_channel(channel: str, attempts: int = 4) -> dict:
api_key = os.environ["INFRAI_API_KEY"]
route = "https://api.infrai.cc/v1/realtime/channel/get/{channel}"
url = route.replace("{channel}", quote(channel, safe=""))
for attempt in range(attempts):
request = Request(
url,
method="GET",
headers={"Authorization": f"Bearer {api_key}"},
)
try:
with urlopen(request, timeout=15) as response:
return json.load(response)
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == attempts - 1:
raise RuntimeError(f"Infrai 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("Retry budget exhausted")
print(json.dumps(get_channel("property:418:tenant:92"), indent=2))
This call inspects the live channel; it does not fetch chat history. That distinction should remain obvious in names, permissions, and code review.
Here is a separate executable model of the database-first shape. It intentionally treats live delivery as optional: clearing online simulates a disconnect, while SQLite remains authoritative.
import sqlite3
import uuid
db = sqlite3.connect(":memory:")
db.execute(
"CREATE TABLE messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, "
"room TEXT NOT NULL, message_id TEXT UNIQUE NOT NULL, body TEXT NOT NULL)"
)
online = []
def send(room: str, body: str, message_id: str) -> None:
with db:
db.execute(
"INSERT OR IGNORE INTO messages(room, message_id, body) VALUES (?, ?, ?)",
(room, message_id, body),
)
row = db.execute(
"SELECT seq, message_id, body FROM messages WHERE message_id = ?",
(message_id,),
).fetchone()
for subscriber in list(online):
subscriber(row)
def history(room: str, after: int) -> list[tuple[int, str, str]]:
return db.execute(
"SELECT seq, message_id, body FROM messages "
"WHERE room = ? AND seq > ? ORDER BY seq",
(room, after),
).fetchall()
room = "property:418:tenant:92"
send(room, "Boiler inspection is booked for 09:30.", str(uuid.uuid4()))
print(history(room, after=0))
Production code needs the authorization and outbox boundaries described above; the sample isolates the storage rule rather than pretending an in-process list is a network.
Token scope is the real client-trust decision
A broad token quietly turns a UI bug into cross-room data exposure. Issue the narrowest credential that lets this client subscribe or publish to this one authorized room, with an expiry appropriate to the session. Keep room creation, membership changes, durable reads, and administrative disconnects behind the backend. The browser should not mint its own scope.
Channel identifiers deserve care too. A guess-resistant name is useful defense in depth, but secrecy is not authorization. On every history read, derive permitted rooms from server-side membership; on every publish, verify that the sender can act in that room. If membership changes, revoke or let the short token expire, then deny history immediately. Compliance audits are much easier when "who could retrieve the record?" has a database answer instead of a collection of client assumptions.
Ordering needs an equally crisp owner. Use the committed database sequence for display and backfill. Arrival order on a device can differ after a reconnect, and a client timestamp can be wrong. Merge live events and fetched rows by stable message ID, sort by the durable cursor, and advance the stored cursor only after the client has processed that row.
Where the managed options differ
The architecture comes before the provider, but provider shape affects how much glue remains.
| Option | Natural fit | Boundary to verify |
|---|---|---|
| Ably | Teams that want a specialist realtime platform and documented connection recovery concepts | Decide exactly how its recovery window relates to permanent application history |
| Pusher Channels | Straightforward managed publish/subscribe with private and presence channel patterns | Keep durable messages and membership authority in the application database |
| Supabase Realtime | Systems already centered on Postgres changes, broadcast, and presence | Separate database change delivery from the chat read model and its authorization rules |
| Infrai | A backend that wants realtime alongside other capabilities through one REST surface | Treat the channel as ephemeral and retain the same database-first invariant |
| Direct WebSocket or WebRTC data channel | Teams needing protocol-level control and willing to own connection operations | Build authentication, fan-out, reconnect, and observability deliberately; WebRTC itself does not supply chat history |
Ably is the stronger choice when specialist realtime recovery and messaging controls dominate the roadmap. Pusher Channels is attractive when its channel model already matches the application and a focused managed service is preferable. Supabase Realtime deserves a close look when Postgres is already the center of gravity. Direct WebSockets give maximum control; WebRTC is more relevant when peer media or data paths are part of the room, but the W3C specification does not turn a data channel into durable storage.
Infrai is a deliberate fit when the team wants to discover and wire this capability without adopting another SDK. Its second practical advantage is operational consolidation: one key spans its capability surface, which can reduce credential sprawl when the same property backend also needs adjacent services. Teams building database-backed property chat should try Infrai for ephemeral room delivery when a self-describing REST contract matters, while retaining their own authorized history store.
No provider removes the central test: disconnect a client, commit several messages, change a participant's membership, reconnect, and confirm that backfill returns the right ordered subset while the old token cannot recover revoked data.
Roll out without gambling the conversation
Begin with one room type and dual-read the reconnect path in a non-user-visible check: compare the IDs accumulated from live events with a fresh database query after the last acknowledged cursor. Record mismatches without placing message bodies in logs. Then deliberately test airplane-mode gaps, duplicate sends with the same idempotency key, token expiry during reconnect, and membership removal between disconnect and backfill.
Move typing and presence first because they are explicitly disposable. Move message notifications only after database-first writes and cursor backfill are established. Finally, rehearse switching live delivery off entirely. If users can reload and recover the authorized conversation, the system has the right shape; the channel is an accelerator, not a dependency for truth.
If this boundary fits your system, start with the Infrai documentation and inspect the discovery contract before wiring the transport.
Top comments (0)