Realtime Presence Contracts: WebRTC Rules for Stale Users in an Online Classroom Explained
Short answer: make presence a leased, versioned data contract, and remove a user only after the lease expires or the browser closes cleanly. In an online classroom, that distinction protects client trust: a dropped tab should not look like a student who deliberately left, and a reconnect should not resurrect an old session.
The useful mental model is a small state machine, not a row in a users table. A browser owns a session lease. It renews that lease with a heartbeat, and every event carries a session identifier and monotonic version. The cleanup worker can then make a boring decision from evidence instead of guessing from a last-seen timestamp.
What should realtime stale user cleanup contracts guarantee?
The contract needs four fields at minimum: session_id, user_id, epoch, and expires_at. session_id distinguishes two tabs for the same person. epoch changes whenever a new connection supersedes an old one. A heartbeat may extend expires_at, but it must never lower the epoch or overwrite a newer lease.
I keep the wire shape explicit because notebooks hide ordering problems. Here is a small FastAPI-style service boundary; the storage calls are deliberately generic so the same contract can sit over Redis, Postgres, or an in-memory test double.
from datetime import datetime, timedelta, timezone from typing import Any from uuid import uuid4 LEASE_SECONDS = 30 def utc_now() -> datetime: return datetime.now(timezone.utc) def open_session(user_id: str, clock: datetime | None = None) -> dict[str, Any]: now = clock or utc_now() return { "session_id": str(uuid4()), "user_id": user_id, "epoch": 1, "expires_at": (now + timedelta(seconds=LEASE_SECONDS)).isoformat(), "state": "active", } def renew_session(contract: dict[str, Any], epoch: int, clock: datetime | None = None) -> dict[str, Any]: if epoch != contract["epoch"] or contract["state"] != "active": raise ValueError("stale_session") now = clock or utc_now() contract["expires_at"] = (now + timedelta(seconds=LEASE_SECONDS)).isoformat() return contract def reap_if_expired(contract: dict[str, Any], clock: datetime | None = None) -> bool: now = clock or utc_now() expires = datetime.fromisoformat(contract["expires_at"]) if contract["state"] == "active" and expires <= now: contract["state"] = "expired" return True return False The production handler should perform the compare-and-set atomically: update the record only when its stored epoch still equals the event epoch. The cleanup job can run every few seconds, but its interval is not the contract. The lease duration is. That separation makes tests deterministic and lets operations tune load without changing meaning.
How do WebRTC events and Python cleanup stay ordered?
WebRTC supplies media and data channels, not an application presence database. The W3C recommendation describes connection and ICE state transitions; your server still needs to translate those transitions into contract events. Treat disconnected as a signal to shorten the lease, not as proof that the user is gone. A mobile network can produce that state during a perfectly ordinary handoff.
Every event should include session_id, epoch, sequence, and observed_at. The server accepts a sequence only if it is greater than the stored sequence. Equal or lower values are harmless duplicates and should be acknowledged without changing state. This is the point where many realtime demos fail: two valid callbacks arrive in a different order, and the older callback wins.
For an online classroom, keep poll answers separate from presence. A student who times out may still have a durable answer accepted before the timeout. Presence controls who receives the next update; it should not rewrite attendance, grades, or poll history.
How can an online classroom test stale user cleanup before a live poll?
Start with time, not sockets. Inject a clock and test a heartbeat at 29 seconds, an expiry at 30 seconds, and a late heartbeat at 31 seconds. Then repeat the sequence with a reconnect that increments the epoch. The old tab must be unable to renew the new tab's lease.
That ordering is the whole test.
For a concrete fixture, open epoch 1 at 09:00:00, renew it at 09:00:10, and let the clock advance to 09:00:41. The reaper marks it expired. At 09:00:42, a delayed epoch-1 heartbeat arrives and is rejected; at 09:00:43, epoch 2 opens and becomes active. If a queued removal for epoch 1 is delivered after that point, the consumer compares epochs and leaves epoch 2 alone. This longer path catches a race that a happy-path WebRTC demo never exercises: a worker can observe the old lease, pause for a network retry, and publish after the browser has already reconnected.
I also test duplicate cleanup runs. The first run marks expired; the second returns false and emits no second removal event. That tiny property matters when a worker restarts halfway through a batch.
Three short cases catch most surprises:
- A laptop sleeps for two minutes, then reconnects with a new
session_id. - A browser closes cleanly, sending a leave event after the lease was already reaped.
- A delayed data-channel message from epoch 1 arrives after epoch 2 is active.
The expected result is the same in all three: current state is authoritative, old events are ignored, and durable classroom records remain intact. Your mileage may vary with proxy timeouts, so measure the path between browser, signaling service, and worker instead of assuming the heartbeat interval is the only delay.
Choosing boundaries for trust, cost, and recovery
Short leases reduce ghost users but increase heartbeat traffic. Long leases save messages but leave a stale name on the roster. I usually start with a 30-second lease and a 10-second heartbeat, then verify it against measured mobile reconnect times and the classroom's tolerance for a ghost participant.
The catch is that this contract is not suitable when presence itself is a legally binding attendance record. Use a separate attendance workflow with explicit user actions and an audit trail. Stick with a durable event log when you need replay, forensic ordering, or cross-region reconciliation; a key-value lease alone cannot provide those guarantees.
Keep observability tied to the contract: count active leases, expiry age, rejected epochs, duplicate sequences, and reconnect delay. Redact user names from logs; session_id is enough to trace a path. In an eval harness, assert state transitions and emitted events, then run the same fixtures against the real clock adapter before shipping from notebook to production.
Document the schema and its version beside the Python model. Pin the server clock to UTC, define the compare-and-set operation, and make expiry idempotent. Emit one removal event with the session identifier, and let clients discard events for a different epoch. Load-test the heartbeat path separately from poll submission so a busy lesson cannot starve cleanup. Finally, rehearse a broker restart and a browser sleep; recovery behavior is part of the data contract, not an afterthought.
References
- W3C, WebRTC: Real-Time Communication in Browsers: https://www.w3.org/TR/webrtc/
- MDN, WebRTC API: https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API