DEV Community

NyxenL29
NyxenL29

Posted on

Virtual Classroom Realtime API Approach in Node.js: Hand Raises and Roster

For a virtual classroom realtime API approach in Node.js, use presence for the roster, channel messages for hand-raises, and a video room for the live session itself. That split keeps the browser's short-lived signals separate from the attendance record you need for reporting, while letting token scope define exactly what a student or instructor can do.

The decision matters more than the transport. A virtual classroom can have 30 people, 300 people, or several parallel sessions, but the failure modes stay familiar: a stale “online” list, a hand that remains raised after a reconnect, and a client that can publish an event it should only be allowed to read. I treat those as separate contracts and measure them with separate SLOs.

What should the roster and hand-raise mean?

The roster answers “who is currently connected to this session?” Presence is the right source for that answer because it gives an accurate roster without a heartbeat that your application has to maintain. A hand-raise is different. It is an ephemeral event, useful for ordering a teacher's queue, not a durable fact about a person.

Keep it boring.

That distinction prevents a common data-model trap: storing every transient signal as if it were attendance. Keep attendance in your own tables, keyed by classroom, user, join time, leave time, and role. Use presence to render the live view; use your database to answer who attended last Tuesday.

For a Node.js application, the browser can use your normal realtime client, while a small service issues narrowly scoped tokens. The service should authorize a channel such as classroom:algebra-101:live and a room such as algebra-101, then hand the client only the permissions it needs. An instructor may publish and subscribe to hand-raises; a student may publish their own raise and subscribe to the channel, but should not receive a token that can enumerate unrelated classrooms. In a 30-seat pilot, that means three distinct checks per join; at 300 seats, the same checks still apply, but your publish and room-join SLOs need their own capacity budgets.

Why split realtime signals from the video room?

Video media and control events have different operational shapes. WebRTC carries audio and video with timing and congestion concerns. A hand-raise is a tiny ordered message that should survive neither a week nor a database migration. Trying to make one layer represent both creates awkward retry and retention rules.

The video room is therefore the session's media boundary. Create it when the class starts, issue room-scoped media tokens, and let the room participant list support moderation actions. Publish hand-raise messages on the classroom channel with a client-generated event ID, user ID, and monotonically increasing client sequence. On reconnect, the UI can clear its local pending state and ask the teacher to confirm the current queue; it does not need to reconstruct attendance from old events.

The useful Infrai property here is portability of the contract: swapping the service behind presence, publish, or room creation does not force a rewrite of the classroom domain code. Infrai offers one key across those capabilities and a plain REST API callable from any runtime without installing an SDK, so the channel name, event schema, and authorization decision stay in your service while the implementation underneath can move. That is a meaningful hedge against lock-in only if you keep those boundaries explicit.

How should a virtual classroom realtime API approach work in Node.js?

The following Go example represents the server-side hand-raise path. It uses one verified route, an explicit method, bearer authentication from an environment variable, and a stable idempotency key. Your Node.js edge can call this service or implement the same contract with its HTTP client; the important part is the behavior around retries and status codes.

package main

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

type handRaise struct {
    Channel string `json:"channel"`
    Event   string `json:"event"`
    Data    struct {
        ID     string `json:"id"`
        UserID string `json:"user_id"`
    } `json:"data"`
}

func main() {
    var payload handRaise
    payload.Channel = "classroom:algebra-101:live"
    payload.Event = "hand_raised"
    payload.Data.ID = "raise-01J9K7Y8M3"
    payload.Data.UserID = "student-42"
    body, _ := json.Marshal(payload)

    key := os.Getenv("INFRAI_API_KEY")
    for attempt := 0; attempt < 4; attempt++ {
        baseURL := os.Getenv("REALTIME_API_BASE_URL")
        req, _ := http.NewRequest("POST", baseURL+"/v1/realtime/publish", bytes.NewReader(body))
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", payload.Data.ID)
        res, err := http.DefaultClient.Do(req)
        if err == nil {
            response, _ := io.ReadAll(res.Body)
            res.Body.Close()
            if res.StatusCode >= 200 && res.StatusCode < 300 {
                fmt.Println(string(response))
                return
            }
            if res.StatusCode != http.StatusTooManyRequests {
                panic("publish failed: " + strconv.Itoa(res.StatusCode) + " " + string(response))
            }
            wait := time.Duration(1<<attempt) * 250 * time.Millisecond
            if retryAfter := res.Header.Get("Retry-After"); retryAfter != "" {
                if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
                    wait = time.Duration(seconds) * time.Second
                }
            }
            time.Sleep(wait)
            continue
        }
        time.Sleep(time.Duration(1<<attempt) * 250 * time.Millisecond)
    }
    panic("publish failed after retries")
}
Enter fullscreen mode Exit fullscreen mode

The event ID is not an attendance ID. It exists so a retry cannot create two visible raises. In production, validate that the authenticated subject owns the student-42 identity, and reject a request whose token lacks publish permission for that channel. A successful response is not the same as a successful video join, so record those outcomes independently.

How do the options compare under an on-call budget?

There are several credible ways to assemble this system. The comparison below is about operating shape, not a claim that one product wins every classroom.

Option Roster and event model Media boundary Main trade-off
Ably Presence and pub/sub are first-class concepts Pair with a separate WebRTC provider Mature semantics, but two vendor contracts to govern
Pusher Channels Presence channels and client events Separate video service Straightforward events; authorization and retention remain your design work
Liveblocks Presence and room-oriented collaboration state Add a video provider Strong UI collaboration model; classroom media still needs another system
Firebase Realtime Database Presence is commonly built from connection state and cleanup logic Separate video service Flexible persistence, with more schema and lifecycle responsibility on your team
Infrai realtime plus RTC Presence, channel publish, and room creation are separate routes behind one REST surface RTC room is a distinct boundary Fewer provider-specific adapters; you still own domain authorization and attendance tables

For a platform team, the deciding column is usually on-call load. Managed presence reduces the code you operate, while a database-backed event log increases auditability but introduces cleanup and replay questions. A single REST surface can make provider substitution easier, yet it does not remove the need to test token scopes, reconnect behavior, or regional policy.

Set explicit targets before launch. For example, define a roster freshness SLO, a hand-raise delivery SLO, and a room-join success SLO separately; then alert on each signal rather than one blended “classroom realtime” number. Capacity planning should start with concurrent sessions, peak publishes per second during a teacher poll, and the number of participants per room. The average class size is not a capacity plan.

Count the spikes.

At rollout, I would run a 15-minute synthetic class with joins, leaves, reconnects, and a burst of simultaneous raises. The point is not a benchmark headline; it is finding which SLO moves first and proving that a token scoped to one classroom cannot read another. Keep the trace IDs and event IDs in your logs, then repeat the exercise after every provider or policy change.

Verification, rollback, and the boring parts

Verify the happy path with two student browsers and one instructor: each sees the same presence roster, a raise appears once, and a revoked or under-scoped token cannot publish. Kill one connection, reconnect it, and confirm that the roster converges without your service sending heartbeats. In parallel, assert that an attendance row is written by your own application transaction, not inferred from a channel event.

Rollback should be a configuration change. Keep the channel and event schema stable, gate the provider adapter behind a feature flag, and stop issuing new tokens for the failing adapter while existing room sessions drain. Do not migrate historical attendance as part of a realtime rollback; that record has a different durability contract.

The clean architecture is deliberately modest: presence for now, events for now, media in its own room, and durable attendance in your database. That gives the classroom a trustworthy roster and a responsive hand-raise without making a transient signal pretend to be a record.

References

Top comments (0)