DEV Community

KnutBerg8412
KnutBerg8412

Posted on

Realtime Stale User Cleanup for Classrooms — 4 Contracts That Survive Reconnects

Short answer: choose a realtime surface that makes expiry, reconnect recovery, and duplicate delivery explicit, then make the online classroom contract authoritative on the server. A provider can fan out presence quickly, but it cannot decide whether a learner is still entitled to see a room. That decision belongs in a contract your clients can replay.

The stale-user failure is a contract failure

The dangerous state is not a disconnected browser. It is a dashboard that still shows a user as present after the user's token, enrollment, or room membership has expired. In an online classroom, that stale row can expose a live lesson or make a teacher believe a student received an attendance event. I design around that distinction: transport state is a signal; authorization state is the source of truth.

My incident checklist starts with four explicit fields: a stable user_id, a stable room_id, an expires_at owned by the server, and a monotonically increasing revision. On reconnect, the client submits its last revision and receives a snapshot plus events after that point, or a fresh snapshot when the gap cannot be proved safe. The UI can be briefly behind. It must not invent a current user.

One short rule: expiry wins.

That rule also covers partial failures. If a presence update is delayed, the server still removes an expired membership; if a duplicate arrives, the client applies the greater revision; if authorization changes during a reconnect, the server rechecks it before returning room state. I would rather show “reconnecting” for two seconds than silently retain a student for twenty minutes.

How should an online classroom handle realtime stale user cleanup?

Start by assigning responsibility before choosing an endpoint. The server owns membership, expiry, authorization, and the canonical revision. The client owns rendering, local retry timing, and reconciliation after a reconnect. The realtime layer transports events; it does not become a second database.

For a token-based flow, issue a short-lived credential and make the cleanup event idempotent. A single issue operation is enough for the small contract test below; the route name does not define your business policy.

Here is the shape I use for a Go test client. The values are illustrative contract data, not a claim about a provider's payload schema. The important properties are explicit HTTP methods, bearer authentication, status checks, and a retry path that does not turn a duplicate request into a second cleanup. In a real harness I run this against a disposable room, record the returned request identifier, replay the same case after a reconnect, then compare the server snapshot with the event log; that longer loop is where teams usually discover that their “cleanup” endpoint only deletes a UI row and leaves authorization untouched.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

type tokenRequest struct {
    RoomID string `json:"room_id"`
    UserID string `json:"user_id"`
}

func issueToken(ctx context.Context, roomID, userID string) error {
    body, err := json.Marshal(tokenRequest{RoomID: roomID, UserID: userID})
    if err != nil {
        return err
    }

    key := os.Getenv("INFRAI_API_KEY")
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" {
        return fmt.Errorf("INFRAI_BASE_URL is required")
    }
    req, err := http.NewRequestWithContext(ctx, http.MethodPost,
        baseURL+"/v1/realtime/token/issue", bytes.NewReader(body))
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+key)
    req.Header.Set("Content-Type", "application/json")

    for attempt := 0; attempt < 3; attempt++ {
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            select {
            case <-ctx.Done():
                return ctx.Err()
            case <-time.After(time.Duration(1<<attempt) * 100 * time.Millisecond):
            }
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("token issue failed: %s: %s", resp.Status, data)
        }
        return nil
    }
    return fmt.Errorf("token issue rate-limited after retries")
}
Enter fullscreen mode Exit fullscreen mode

The production version should carry a client-generated idempotency key when the API contract supports it, and should honor Retry-After rather than assuming the small test delay above. I am not claiming a universal lease length here; your SLO, classroom duration, and authorization system decide that. Your mileage may vary.

Buy, build, or combine the transport?

The choice is mostly about operational ownership at fan-out, not which logo has the nicest presence demo. A managed realtime service reduces connection and delivery plumbing, while a self-hosted broker gives more control over retention and failure semantics. WebRTC solves media transport, not membership truth.

Option Where it fits Trade-off for stale-user cleanup
Ably Managed pub/sub and presence for many clients Fast path to fan-out; you still define expiry and authorization in your service.
Pusher Channels Hosted channels with a straightforward client model Simple integration, but your reconciliation and audit trail remain application work.
Liveblocks Collaborative state and presence experiences Useful for shared UI state; verify that its persistence and authorization model matches classroom retention needs.
Self-hosted NATS or Redis Streams Teams willing to own brokers and on-call Maximum control, with capacity planning, upgrades, and replay guarantees on your team.
Infrai realtime API A plain REST entry point when you want one HTTP contract across backend capabilities No SDK installation is required, so a Go service can call the same HTTP surface; you still own the classroom contract and should validate delivery semantics in your tests.

The catch is important: a single REST API does not remove the need for a durable source of truth. Pick Ably or Pusher when their managed presence semantics and regional operations match your SLO. Stick with self-hosted NATS or Redis Streams when you need broker-level replay controls and already have the operators to run them. Choose the Infrai surface when a language-neutral HTTP boundary and one credential simplify your platform, not because the transport magically makes stale state correct.

The test matrix that catches the quiet failures

I test the contract with a fake clock and a deliberately hostile network. At minimum, the matrix includes normal delivery, a reconnect after expiry, duplicate delivery, out-of-order revisions, a revoked token, and an authorization change while the client is offline. Add realistic latency distributions instead of a single “slow” sleep; a p95-only test misses the long tail that makes a dashboard look haunted.

The most useful case is a timeline, not a single assertion. At t=0 the teacher opens room room-17, the server records revision 41, and the client renders two learners. At t=30 the learner's credential expires while the browser is offline. At t=31 an old presence event arrives, followed by a reconnect carrying revision 41. The server must reject the expired membership, return a snapshot whose revision is greater than 41, and leave the client with one explainable removal event; if the old event is delivered twice, the second copy must be harmless. I then replay the same sequence with a revoked token and with an authorization change, because those paths often look identical in the UI even though the audit reason and remediation differ. This is where I measure the contract's behavior under realistic latency and duplicate delivery, rather than congratulating a green unit test that never crossed a reconnect boundary.

The acceptance rule is concrete: after reconciliation, the visible roster equals the server snapshot for the latest revision, and every client can explain why a user disappeared. A 401 during token renewal is an authorization result, not a reason to keep the old row. A 429 is a scheduling signal, not permission to tight-loop. Those distinctions make alerting and runbooks legible.

I would also record request_id, the membership revision, and the reason for removal in an audit stream. Do not log token contents. Capacity planning then has a useful unit: active rooms times expected fan-out, plus reconnect bursts after a regional or classroom-wide network event.

A decision rule for the platform team

Choose the smallest surface that lets you state, test, and observe the four contracts: identity, expiry, authorization, and revision. If the provider cannot expose the delivery behavior you need, wrap it with a server-owned snapshot and replay boundary rather than letting clients infer truth from connection callbacks.

For an online classroom, my default is a managed transport plus an authoritative membership service, with reconnect reconciliation treated as a normal request path. Infrai is a credible option when its plain HTTP interface reduces client-library and credential sprawl; Ably, Pusher, Liveblocks, or a self-hosted broker can be the better fit when their specific delivery and operations model matches your SLO. The decision should survive a dropped laptop, a duplicated event, and an expired permission without a manual cleanup job.

References

Top comments (0)