Short answer: for an online classroom or shared workspace, issue short-lived RTC tokens from a versioned contract, and treat presence as an expiring claim rather than a socket fact.
That rule keeps the hard part in view: presence accuracy. A user who closed a laptop ten minutes ago should not remain “online” because one browser tab never sent its goodbye. Token issuance is the admission check; the presence contract is the clock that decides whether the admission is still believable.
The decision matrix
| Design | Presence signal | Reconnect behavior | Best fit |
|---|---|---|---|
| Lease-based RTC token | Heartbeat renews a bounded lease | New token carries an incremented session epoch | Shared workspace with visible collaborators |
| Room membership only | Join and leave events | Server guesses after disconnect timeout | Small classes where stale presence is tolerable |
| Client-pushed status | App decides “online” and broadcasts it | Client must replay state after every reconnect | Prototypes and low-consequence indicators |
Choose the lease. It gives the UI a useful answer with a known error bound. A 30-second lease can be wrong for at most the lease plus detection slack, while a permanent membership row can lie forever. The exact interval belongs in your product contract, not in a random client constant.
The catch is operational: a lease adds heartbeat traffic, a timer, and a clock policy. If your classroom only needs a green dot for a few people and stale state has no consequence, membership events may be enough. Stick with the simpler option when accuracy is not a user promise.
How should realtime RTC token issuance shape an online classroom data contract?
Start with one envelope that both the token service and the presence service understand. Keep identity, room, permissions, and freshness explicit. Do not make the client infer a role from a display name or infer freshness from a WebSocket object.
Keep it boring.
Here is a compact TypeScript shape. It is deliberately boring; boring contracts survive SDK swaps.
type RtcGrant = {
subject: string;
room: string;
role: "teacher" | "student" | "observer";
sessionEpoch: number;
issuedAt: string;
expiresAt: string;
};
type PresenceLease = {
subject: string;
room: string;
sessionEpoch: number;
leaseUntil: string;
state: "online" | "away";
};
sessionEpoch is the small field that prevents a nasty reconnect race. Suppose a student opens a second tab, receives epoch 8, then the delayed disconnect from epoch 7 arrives. A compare-and-swap on the epoch lets the server ignore the old event. Without it, an old tab can erase a new presence record.
Use UTC timestamps serialized as RFC 3339 strings. The server owns issuedAt, expiresAt, and leaseUntil; clients may display them, but they do not get to extend them. The WebRTC recommendation describes the peer connection state machine, but it does not define your classroom identity or authorization schema, so those fields remain application responsibilities.
The token endpoint should be a state transition, not a blob factory
The issuance handler needs enough context to make a decision and enough output for the next reconnect. A minimal request carries the room and the caller's authenticated identity. The response returns a token plus the contract version and session epoch.
type IssueTokenRequest = {
room: string;
clientSessionId: string;
reconnect: boolean;
};
type IssueTokenResponse = {
token: string;
grant: RtcGrant;
contractVersion: "presence.v1";
};
async function issueToken(
req: IssueTokenRequest,
identity: { subject: string; role: RtcGrant["role"] },
): Promise<IssueTokenResponse> {
const previous = await loadSession(identity.subject, req.room, req.clientSessionId);
const sessionEpoch = (previous?.sessionEpoch ?? 0) + 1;
const issuedAt = new Date();
const expiresAt = new Date(issuedAt.getTime() + 5 * 60_000);
const grant: RtcGrant = {
subject: identity.subject,
room: req.room,
role: identity.role,
sessionEpoch,
issuedAt: issuedAt.toISOString(),
expiresAt: expiresAt.toISOString(),
};
await saveSession({ ...grant, clientSessionId: req.clientSessionId });
return { token: signRtcGrant(grant), grant, contractVersion: "presence.v1" };
}
The five-minute example is a contract value, not a universal recommendation. Your threat model may require shorter credentials, and your reconnect path may need a grace window. Test both values against the real classroom rhythm: a student switching Wi-Fi, a teacher reopening a laptop, and a tab sleeping in the background.
I once assumed a reconnect was a single event. It isn't. Mobile browsers can suspend the heartbeat, restore the page, and replay buffered UI actions in a different order. The fix was to make every write carry the epoch and to make reads expose leaseUntil; the server could then reject stale writes without guessing what the browser meant.
Measuring presence accuracy after deployment
Presence accuracy is a measurable contract. Log the time of the last accepted heartbeat, the lease expiry, the token epoch, and the reason a session changed state. Avoid logging raw tokens. A useful dashboard has two distributions: false-online duration after a client disappears, and false-offline duration during a reconnect.
Make the dashboard answer one uncomfortable question: how long did the product claim someone was online after they had gone away? That number is more useful than an average heartbeat latency because it maps directly to the promise made to teachers and collaborators. Keep a sample of reconnect traces with the old and new epoch, the browser visibility state, and the server decision. When an incident arrives, those fields tell you whether the failure was transport loss, a late event, or a contract violation; without them, engineers tend to patch the client timer and recreate the race in the next release.
Use a deterministic test clock for unit tests, then run a small integration matrix with real timers. Include duplicate heartbeats, delayed disconnects, two tabs for one identity, and a token that expires while the peer connection remains open. The WebRTC connection can stay technically connected while your application lease has expired; those are separate states.
Pusher, Ably, and PubNub are reasonable comparison points when you evaluate hosted realtime transport. Their APIs and retention models differ, so compare the presence semantics you can enforce, not the number of SDK methods. A transport that gives you convenient channel events is still a poor fit if your product needs an auditable lease and epoch rule. A self-hosted WebSocket layer may be preferable when you need to own that state machine and its data residency.
The lease contract is the wrong tool when “online” is merely decorative, when clients cannot send periodic heartbeats, or when your team cannot operate a clock and expiry policy. In those cases, use room membership with an explicit stale marker and tell users that it is approximate. That is more honest than presenting a crisp green dot backed by guesses.
No guesswork.
It is also a poor fit for a broadcast-only lecture with no collaboration surface. A one-way stream can use a separate viewer count and avoid pretending that every viewer has an interactive RTC session. Your mileage may vary with background-tab policies; measure the browsers your students actually use.
The practical decision rule is short: promise accuracy, then pay for a lease; do not promise it, and keep the data model small. Either way, version the envelope before the first client ships. Changing a token claim and changing a presence meaning are different migrations, and combining them is how classroom reconnects become support tickets.
Top comments (0)