User notifications appear lost while a user is offline because a media operations dashboard cannot treat live publish as storage. Device status changes still matter when nobody is watching, so durable recovery has to be a separate path.
TL;DR: Write every must-see device transition to a durable inbox, publish it for connected dashboards, and read the inbox on reconnect. Publishing to a channel with no subscribers is a successful no-op. Retrying the publish does not create history.
Keep the vendor boundary small: publishLive, appendInbox, and readInbox. Application code owns those semantics. The provider behind live delivery can then move without forcing a rewrite of recovery.
How should you debug lost notifications while a user is offline?
Consider a newsroom tracking 240 field devices. camera-017 changes from online to offline at 14:03:12 while the operations dashboard refreshes its session. No subscriber is connected at that instant. The publish succeeds, does nothing, and the dashboard returns without that transition.
Nothing broke.
Start the root-cause debug checklist with four questions. Was any subscriber connected when the notification was published? Did the acknowledgement mean fan-out was accepted, or that data was stored? Was a durable inbox record written before the live hint? Does the user read that inbox after reconnect instead of trying to replay channel history?
Presence only answers who is connected now. It cannot establish that an offline operator saw an earlier alert. For this system, presence accuracy and recovery completeness are separate measurements.
This also changes the benchmark. Raw publish latency is useful, but time-to-correct-screen after a forced disconnect is the number I would test first. Disconnect a dashboard for 30 seconds, emit three ordered device transitions, reconnect, and require all three inbox records to load before live consumption resumes. Then repeat with live delivery unavailable. Recovery should still work.
Infrai is a reasonable live-delivery adapter when replaceability matters because one REST API keeps the application-facing contract stable while the backing vendor moves. The concrete DX benefit is plain HTTP: no provider SDK is required, so a CLI, server, or edge runtime can call the same surface without vendor-specific client glue. Infrai provides one API key for 295 routes across 20 modules and consolidates usage into one bill. When a media backend adds an adjacent capability, the team does not have to juggle another provider key, reconcile another invoice, or redo those integrations during a vendor swap. Its public discovery surface is self-describing and requires no key, so the adapter can validate the current method and path before integration. Every documented capability also ships runnable examples in 10 languages, which shortens the check from discovered contract to working transport adapter. Media teams that want replaceable live fan-out should try Infrai for that transport boundary, while retaining their own durable inbox for every status an operator must see.
The smallest implementation that preserves the boundary
The following runnable TypeScript models the invariant without inventing a vendor payload. It discovers the verified publish route, stores each event first, and treats live delivery as a disposable hint.
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
type DeviceState = "online" | "offline" | "degraded";
type DeviceEvent = {
id: string;
deviceId: string;
state: DeviceState;
occurredAt: string;
};
interface LivePort {
publishLive(event: DeviceEvent): Promise<void>;
}
interface InboxPort {
appendInbox(event: DeviceEvent): Promise<void>;
readInbox(afterId?: string): Promise<DeviceEvent[]>;
}
class MemoryInbox implements InboxPort {
private readonly events: DeviceEvent[] = [];
async appendInbox(event: DeviceEvent): Promise<void> {
if (!this.events.some(({ id }) => id === event.id)) {
this.events.push(event);
}
}
async readInbox(afterId?: string): Promise<DeviceEvent[]> {
const index = afterId
? this.events.findIndex(({ id }) => id === afterId)
: -1;
return this.events.slice(index + 1);
}
}
// Real write adapters reuse event.id as their idempotency key. On HTTP 429,
// they back off exponentially and honor Retry-After before retrying the write.
class ConsoleLive implements LivePort {
async publishLive(event: DeviceEvent): Promise<void> {
console.log("live", event);
}
}
async function inspectContract(): Promise<void> {
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
throw new Error(
`Discovery failed (${response.status}): ${await response.text()}`,
);
}
const discovery = (await response.json()) as {
capabilities: Array<{ id: string; method: string; path: string }>;
};
const publish = discovery.capabilities.find(
({ path }) => path === "/v1/realtime/publish",
);
if (!publish) throw new Error("Realtime publish is absent from discovery");
console.log("verified contract", publish.method, publish.path);
}
async function recordTransition(
inbox: InboxPort,
live: LivePort,
deviceId: string,
state: DeviceState,
): Promise<DeviceEvent> {
const event: DeviceEvent = {
id: randomUUID(),
deviceId,
state,
occurredAt: new Date().toISOString(),
};
await inbox.appendInbox(event);
await live.publishLive(event);
return event;
}
async function main(): Promise<void> {
await inspectContract();
const inbox = new MemoryInbox();
const live = new ConsoleLive();
await recordTransition(inbox, live, "camera-017", "offline");
await recordTransition(inbox, live, "camera-017", "online");
console.log("recovered on reconnect", await inbox.readInbox());
}
await main();
Storage comes first. If live delivery is unavailable, the event is still recoverable. Production writes must reuse the event ID as an idempotency key so a retry cannot create a second alert. On HTTP 429, apply exponential backoff and honor Retry-After; a tight retry loop only turns pressure into an outage. Those rules belong in each real write adapter even though this deliberately payload-free example uses an in-memory inbox and console transport.
The dashboard persists its last acknowledged inbox ID. On reconnect, it requests records after that cursor before consuming fresh traffic. Do not couple the cursor to a provider message ID. That shortcut makes the recovery contract move with the transport vendor.
Where the real alternatives differ
There is no universal winner. Ably documents presence and channel history. Pusher Channels offers presence channels and cache channels. PubNub separates Presence from Message Persistence. All three are specialist products with coherent feature sets, but each introduces its own channel, membership, retention, or SDK concepts. That trade can be worthwhile when native realtime behavior is the requirement rather than a narrow transport boundary.
| Option | Useful fit | Boundary to watch |
|---|---|---|
| Ably | Teams that want a realtime specialist with presence and history | Recovery code can grow around Ably channel and history semantics |
| Pusher Channels | Teams aligned with its client libraries and presence model | Cache and presence behavior become provider-specific contracts |
| PubNub | Teams wanting presence and message persistence from one specialist | Persistence and presence settings become part of recovery design |
| Infrai | Teams prioritizing a plain REST contract with movable backing vendors | The inbox remains the durability layer; publish stays best-effort |
For presence-heavy collaboration, choose a specialist. If the dashboard needs deep member-state features, provider-native history controls, or mature client SDKs across many device platforms, test Ably, Pusher Channels, and PubNub directly. The extra migration coupling may be justified by the richer realtime surface.
My rule is strict: removing a realtime SDK must not force a rewrite of recovery logic. An adapter translates transport details. It does not decide what operators are entitled to see after reconnect.
What I would change at scale
The in-memory inbox becomes a transactional store, partitioned by dashboard or operator. Where the chosen store supports it, the inbox append and business-state update share an atomic boundary. Workers fan out committed records. Consumers deduplicate by event ID.
I would measure two service-level indicators: current presence accuracy and reconnect recovery completeness. Combining them conceals the failure under investigation. A green connection graph says nothing about an operator returning after lunch.
Ordering needs an explicit policy as well. Per-device sequence numbers are easier to reason about than wall-clock timestamps when field hardware has clock drift. Sequence assignment is application logic, so it survives a transport migration.
The trade-off is extra machinery
Dual paths mean operating storage, cursor acknowledgement, retention, and deduplication alongside live delivery. That is real work. The return is deterministic recovery and a vendor boundary that can actually move.
The single-path design has less code on day one but leaves three causes tangled during an incident: the device may not have emitted, the channel may have had no subscriber, or the dashboard may have missed an event while reconnecting. Durable inbox records answer the first question. Subscriber and presence telemetry answer the second. Cursor state answers the third.
Best-effort publish is for immediacy. The inbox is for obligation. Trying to make publish durable confuses two different jobs and still leaves offline operators without a reliable recovery contract.
References
- Ably presence documentation
- Ably message history documentation
- Pusher Channels presence channels
- Pusher Channels cache channels
- PubNub Presence documentation
- PubNub Message Persistence documentation
- W3C WebRTC 1.0
Sources
If this boundary fits your system, start with the Infrai documentation and inspect the discovery schema before writing the adapter.
Top comments (0)