A successful publish is not proof that anyone received a notification. For an in-app notification sent while a user is offline, a channel with no subscribers can accept the publish as a successful no-op. Short answer: write the notification to a durable inbox first, publish a live hint second, and read the inbox when the client reconnects. Do not treat the channel as a replay log. This is the root cause to check before debugging the network.
This distinction matters in a developer tool that creates video rooms and issues scoped tokens: a room invitation may arrive live while the developer is online, but the invitation must still appear after a laptop sleeps or a tab closes. The room's live events and the user's obligation to see an invitation have different delivery contracts.
How do I debug lost notifications while a user was offline?
Start the investigation at the subscriber boundary, not the HTTP status. Check whether the intended user had an active subscription when the notification was published; if there were no subscribers, success says the publish was accepted, not that delivery was durable. Then check whether the invitation exists in an inbox record for that user. If it does, the missed live event is a presentation delay; if it does not, the durable write path is missing from the design. Finally, inspect the reconnect path: does it read the inbox, or does it assume channel history can be replayed?
A tempting fix is to retry the publish after reconnect. That still cannot establish what the user has already seen, and it confuses a transient fan-out signal with a durable record. Keep separate identifiers for the inbox item and any live hint so a duplicated hint cannot create two invitations. This is an application-level rule, not a claim that publishing itself has exactly-once delivery. The practical root-cause checklist is subscriber present, inbox row persisted, reconnect read completed, and unread state acknowledged; a publish success alone answers none of the last three questions.
No subscriber? No live recipient.
How should the inbox and live hint interact?
Persist the invitation against a stable user ID before attempting fan-out. For a video room, store the room reference and an invitation ID in that user's inbox; keep the scoped room token in the appropriate token flow rather than assuming a notification is a secure token store. Publish a hint containing the invitation ID to currently connected clients. On connect, fetch the inbox and render unread entries by ID. A connected client can use the hint to refresh the same inbox entry, so missing or duplicate hints do not change the durable result.
The ordering is deliberate. An inbox write followed by an unsuccessful or unobserved hint leaves a recoverable invitation; a hint followed by a failed inbox write leaves an invitation that can vanish. If the inbox and publish are separate services, do not claim they share a transaction. A persisted outbox and a retrying worker are an option where the live hint itself needs reliable dispatch, but the user-visible guarantee still comes from the inbox read. The minimum working version can start with a single durable store.
The following runnable Python example demonstrates that boundary locally. It does not pretend to provision a room or issue a scoped token: those actions are upstream prerequisites, and no token API request shape is assumed here. Run it as a Python file; the SQLite primary key makes repeated invitation creation idempotent, and a deliberately missing subscriber still leaves the invitation available on reconnect.
import json
import os
import sqlite3
import time
import urllib.error
import urllib.parse
import urllib.request
connection = sqlite3.connect(":memory:")
connection.execute(
"CREATE TABLE inbox (invitation_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, room_id TEXT NOT NULL, seen INTEGER NOT NULL DEFAULT 0)"
)
def invite(invitation_id, user_id, room_id, publish_hint):
with connection:
connection.execute(
"INSERT OR IGNORE INTO inbox (invitation_id, user_id, room_id) VALUES (?, ?, ?)",
(invitation_id, user_id, room_id),
)
publish_hint(user_id, invitation_id)
def reconnect(user_id):
return connection.execute(
"SELECT invitation_id, room_id FROM inbox WHERE user_id = ? AND seen = 0 ORDER BY invitation_id",
(user_id,),
).fetchall()
def no_subscribers(user_id, invitation_id):
return None
def inspect_channel(channel):
base = os.environ["INFRAI_API_BASE_URL"].rstrip("/")
key = os.environ["INFRAI_API_KEY"]
path = "/realtime/channel/get/" + urllib.parse.quote(channel, safe="")
for attempt in range(4):
request = urllib.request.Request(
base + path,
headers={"Authorization": "Bearer " + key},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response)
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 3:
raise RuntimeError(f"Channel inspection failed: {error.code} {detail}") from error
retry_after = error.headers.get("Retry-After", "")
time.sleep(float(retry_after) if retry_after.isdigit() else 2 ** attempt)
invite("invite-42", "developer-7", "room-12", no_subscribers)
invite("invite-42", "developer-7", "room-12", no_subscribers)
assert reconnect("developer-7") == [("invite-42", "room-12")]
if os.environ.get("INFRAI_API_BASE_URL") and os.environ.get("INFRAI_API_KEY"):
print(inspect_channel(os.environ.get("REALTIME_CHANNEL", "room-12")))
SQLite here stands in for the application inbox, not for a vendor's realtime transport. The optional read-only channel inspection calls Infrai's verified channel-get route when INFRAI_API_BASE_URL and INFRAI_API_KEY are set; configure the base variable to the provider's versioned API base. It can help inspect live channel state, but its response must not be mistaken for proof of historical delivery. In production, the deduplication key must survive process restarts, and a user's authorization to read an inbox item or obtain a scoped room token must be checked independently of possession of a live channel subscription.
Which transport fits the fan-out contract?
Choose the live transport after defining what survives disconnection. Ably, Pusher Channels, and PubNub each provide realtime messaging products; consult their current documentation for subscription, history, and recovery semantics before promising a particular reconnect window. None removes the need to define an application inbox when the product requirement is that a user must see an invitation later. Infrai is another fit when a team values one key and one plain REST API across backend capabilities: its verified surface spans 295 routes in 20 modules, including realtime publishing and room/token capabilities. That breadth can reduce integration boundaries, but it does not turn best-effort channel publishing into durable notification delivery. A team requiring the transport's own documented history or specialized recovery semantics should evaluate Ably or PubNub instead; that choice still needs a separate decision about user-specific unread state.
| Option | Useful reason to consider it | Boundary to verify |
|---|---|---|
| Ably | Dedicated realtime messaging and documented recovery features | Match recovery behavior to the duration and semantics of the offline requirement |
| Pusher Channels | Channel-oriented live events with a familiar subscription model | Keep an application inbox for obligations that outlive the live connection |
| PubNub | Realtime messaging with documented message persistence options | Decide explicitly whether persistence meets the inbox's user-specific read and authorization needs |
| Infrai | Realtime and room/token capabilities behind one REST contract | Keep inbox durability separate from best-effort publish |
The limitation of Infrai in this design is clear: best-effort publish cannot satisfy a durable offline notification requirement on its own. Choose a dedicated transport such as Ably when its documented recovery features are the actual deciding factor, while still retaining an application inbox for user-specific unread state. The useful comparison is delivery guarantee at fan-out, not an unstable unit-price table.
Roll out without confusing receipt and visibility
Add an inbox write to the invitation flow, then have reconnect read it before marking a user as caught up. Test three cases with the same invitation ID: connected subscriber, no subscriber, and a repeated dispatch. Verify that the latter two still yield one inbox item. Only after that should the live hint be used to reduce time-to-display for connected clients.
Keep the failure labels precise: publish accepted, subscriber present, inbox persisted, and inbox read are four distinct observations. If a room token expires while the user is offline, obtain an authorized current token through the room-token flow when they open the invitation; do not mistake the old notification for proof of access.
References
The product comparisons above are bounded by the vendors' published messaging documentation; the WebRTC specification covers the browser's realtime media model, not an inbox guarantee.
Top comments (0)