Short answer: For a classroom video product, the least complex reliable design is an inbox record plus realtime fan-out; make the inbox the source of truth, treat publish as the fast path, use presence as a routing hint, and keep the room's scoped token focused on authorization rather than delivery state.
| Choice | Offline survival | Presence role | Best fit |
|---|---|---|---|
| Durable inbox plus any realtime transport | Yes, because the application stores a record | Decides whether to fan out now | Required notifications such as “class moved rooms” |
| Ably | Do not assume it without an application inbox | Connection state can guide the live path | Teams already using Ably's realtime model |
| Pusher Channels | Do not assume it without an application inbox | Subscription state can guide the live path | Straightforward channel fan-out |
| PubNub | Do not assume it without an application inbox | Presence can guide the live path | Systems built around PubNub channels and presence |
| Infrai | Do not assume it from publish alone | Presence can guide the live path | A plain REST integration where public discovery returns schemas, every documented capability has runnable examples in 10 languages, and 295 routes across 20 modules share one key and one bill |
Recommendation: write the notification first, then publish a small invalidation event. On reconnect, read the inbox. This is the choice I would make for a one-person edtech SaaS because it protects the user-visible promise while keeping the replaceable transport outside the core data model.
A successful publish says the service accepted a live fan-out attempt. It doesn't say an offline student received anything. A publish with no subscriber succeeds and delivers nothing, and retrying the same publish doesn't turn it into durable storage.
1. Separate the video room from the notification promise
A teacher opens room algebra-204, and the backend issues scoped tokens for that room. Good. Those tokens answer who may join. They do not answer what a student sees after closing a laptop for 12 minutes and returning after the teacher moved the session to algebra-204b.
That distinction is easy to blur because both operations happen during “join class.” They have different lifetimes. The room and its token support the live media session. The notification is application state that may have to outlive the connection.
WebRTC also does not supply an application inbox. Its standard defines browser APIs and protocols for realtime communications. That is the media plane, not a durable record of a teacher's message.
My shipping rule here is blunt: if missing the message changes what the student should do next, store it. “The hand-raise animation is happening now” can be ephemeral. “Your class moved to another room” cannot.
Ship the boring record.
2. Why can't realtime publish alone deliver offline notifications?
Publish requires a subscriber. If Maya is connected when the teacher changes rooms, she gets the low-latency event. If Luis is offline, there is no subscriber to receive it. The publish may still succeed because delivery to an absent client is not what success proves.
This is the failure boundary that matters more than vendor syntax. Retrying helps when a request did not reach the publish service or received a retryable response. It cannot recover a time interval in which the intended client had no subscription. Ten retries during that interval are still ten ephemeral attempts.
Presence doesn't fix this. Accurate presence can tell the backend that Luis is absent, which is useful for deciding whether live fan-out is worthwhile. It cannot become the missing record. Presence also changes quickly around mobile sleep, network transitions, and reconnects, so I would never make deletion of the durable notification depend on a single presence observation.
Offline is normal.
For a video classroom, I use this decision rule:
- Store every notification whose consequence survives the current session.
- Publish after the store commits, using only the inbox ID and enough data to trigger a refresh.
- On connect or reconnect, read unread records from the inbox.
- Mark a record read only from an explicit application action, not from channel presence.
The order is deliberate. Publish-first creates a race where a client refreshes before the row exists. Store-first may cause a connected client to read a few milliseconds later, but the durable path remains correct.
3. Make presence a hint, never the receipt
Presence accuracy is the primary decision axis for live classroom UX. It determines whether “teacher joined,” “student raised a hand,” or “room moved” appears immediately and whether the roster looks credible. Evaluate it with transitions, not a static count: connect, background, network loss, reconnect, and explicit leave.
But do not promote a presence signal into proof of notification consumption. “Connected” does not mean the event rendered. The tab could be frozen between receipt and paint. The process could terminate before local state commits. For an inbox item, the useful receipt is an application-level read marker tied to the user and notification ID.
This separation also makes a weekly shipping cadence realistic. The durable model stays small: ID, recipient, kind, payload, creation time, and read time. Vendor-specific connection details stay in an adapter. Swapping the live transport should not require migrating the inbox table or changing the rule that protects students who were offline.
I would instrument three counts before adding clever recovery logic: records created, live fan-out attempts, and unread records returned on reconnect. Those are operational counters, not invented reliability percentages. They reveal which path is doing work without pretending that publish acknowledgement equals end-user delivery.
4. Implement store, publish, and read-on-connect
The following TypeScript file runs as-is with Node's TypeScript support or a normal TypeScript runner. It calls the public discovery surface to retrieve the declared realtime publish capability, including its request schema and runnable examples, before exercising the inbox flow. The in-memory adapters keep the notification example honest: no undocumented publish field is implied. In production, InboxStore maps to durable storage and LivePublisher maps to the chosen realtime service.
type Capability = {
id: string;
method: string;
path: string;
params?: unknown;
};
type Discovery = {
capabilities: Capability[];
};
async function discoverRealtimePublish(): Promise<Capability> {
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/discovery`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
const discovery = (await response.json()) as Discovery;
const capability = discovery.capabilities.find(
(item) => item.method === "POST" && item.path === "/v1/realtime/publish",
);
if (!capability) throw new Error("Realtime publish capability is unavailable");
return capability;
}
throw new Error("Discovery remained rate-limited");
}
type Notification = {
id: string;
userId: string;
kind: "room-moved";
roomId: string;
createdAt: string;
readAt: string | null;
};
interface InboxStore {
put(notification: Notification): Promise<void>;
unread(userId: string): Promise<Notification[]>;
}
interface LivePublisher {
publish(userId: string, event: { type: "inbox-changed"; id: string }): Promise<void>;
}
class MemoryInbox implements InboxStore {
private readonly rows = new Map<string, Notification>();
async put(notification: Notification): Promise<void> {
this.rows.set(notification.id, notification);
}
async unread(userId: string): Promise<Notification[]> {
return [...this.rows.values()].filter(
(row) => row.userId === userId && row.readAt === null,
);
}
}
class MemoryPublisher implements LivePublisher {
constructor(private readonly connectedUsers: Set<string>) {}
async publish(
userId: string,
event: { type: "inbox-changed"; id: string },
): Promise<void> {
if (this.connectedUsers.has(userId)) {
console.log("live event", userId, event);
}
}
}
async function notifyRoomMoved(
inbox: InboxStore,
live: LivePublisher,
notification: Notification,
): Promise<void> {
await inbox.put(notification);
await live.publish(notification.userId, {
type: "inbox-changed",
id: notification.id,
});
}
async function main(): Promise<void> {
const publishCapability = await discoverRealtimePublish();
console.log("discovered", publishCapability.id, publishCapability.path);
const inbox = new MemoryInbox();
const connectedUsers = new Set<string>();
const live = new MemoryPublisher(connectedUsers);
await notifyRoomMoved(inbox, live, {
id: "notice-2026-09-24-001",
userId: "student-luis",
kind: "room-moved",
roomId: "algebra-204b",
createdAt: "2026-09-24T09:00:00Z",
readAt: null,
});
connectedUsers.add("student-luis");
console.log("inbox on reconnect", await inbox.unread("student-luis"));
}
void main();
The live adapter intentionally carries an ID, not the whole authoritative message. A reconnect and an event race can both trigger reads; the result is still the same inbox state. If live publish fails after the write, the next connect still finds the record. For stronger immediate retry behavior, add a transactional outbox beside the inbox, but do not confuse that mechanism with repeatedly publishing an unstored event. The trade-off is another table and read path to operate, even for a tiny product. I accept that cost for required notifications; I don't pay it for transient hand-raise animation.
5. Choose the transport after choosing the guarantee
Ably, Pusher Channels, and PubNub are real candidates, not interchangeable logos. Their official documentation organizes realtime delivery differently: Ably documents channel history and connection recovery, Pusher documents channel subscriptions and cache channels, and PubNub documents message persistence plus presence. Read those exact semantics against your disconnect window and retention requirement. Do not infer durable inbox behavior from the word “realtime.”
The runner-up is better when its native client libraries, connection recovery model, or presence semantics remove more work from the product than a plain REST surface would. A team with an established Ably deployment should first test its recovery behavior. A Pusher-based app may value its existing channel integration. A PubNub-based classroom may already rely on its presence and persistence model. Keeping the inbox vendor-neutral lets each team make that choice without betting notification correctness on it.
The REST option is attractive when I want to outsource undifferentiated integration work and avoid learning another SDK. Its discovery response can describe the request schema, response schema, billing, and runnable examples before I wire a capability. It also covers 295 routes across 20 modules under one key, so adding an adjacent backend capability doesn't require another credential and billing integration. Still, discovery convenience does not change the architecture: live publish remains the fast path, while the stored inbox protects the offline interval.
Its limitation is equally concrete: it isn't the best fit when a team needs the richer native client behavior of an established Ably, Pusher Channels, or PubNub deployment. In that case, keep the incumbent transport and add the same durable inbox boundary.
There is no universal winner. Test the scenario that can embarrass the product: issue scoped room access, disconnect the student, move the room, publish, wait, reconnect, and verify that the unread record appears exactly once in the UI. Then repeat around a presence transition. The vendor that passes that test with the smallest maintainable adapter is the sensible choice.
Further reading
- W3C, WebRTC 1.0: https://www.w3.org/TR/webrtc/
- Ably, Message history: https://ably.com/docs/storage-history/history
- Ably, Connection state recovery: https://ably.com/docs/connect/states
- Pusher Channels, Cache channels: https://pusher.com/docs/channels/using_channels/cache-channels/
- Pusher Channels, Subscribing to channels: https://pusher.com/docs/channels/using_channels/channels/
- PubNub, Message persistence: https://www.pubnub.com/docs/general/storage
- PubNub, Presence: https://www.pubnub.com/docs/general/presence/presence-overview
Top comments (0)