DEV Community

HieronymusFox1257
HieronymusFox1257

Posted on

Virtual Classroom Rosters and Hand Raises Without Your Own Socket Server

TL;DR: For a virtual classroom realtime API approach in Node.js, use presence for the live roster, channel messages for hand raises, and a video room for the session. You do not need to run your own socket server. Keep attendance in your own database, because a reconnect must not turn transient state into a permanent attendance record.

Option Pick it when Presence accuracy trade-off Classroom fit
Ably You want managed pub/sub with documented presence Members are tied to channel attachment; reporting still needs durable application data Strong for roster and hand-raise events; video is separate
Pusher Channels The application already maps to channels and presence channels Presence supplies a current member view, not an attendance ledger Strong for roster and events; video is separate
LiveKit The video room is the center of the product Participant state follows the media room, which may be narrower than attendance Strong when media and participant lifecycles should move together
Twilio You prefer Conversations for messages and Video for media The application must reconcile participant identity across products Useful when both managed products fit the wider system
Socket.IO You need protocol-level control and will operate the server yourself Accuracy depends on the membership and reconnect logic you build Flexible, but it conflicts with a no-socket-server requirement
Infrai You want a plain REST API with no SDK to install or client version to maintain Presence, publishing, and room creation stay separate One API key and one bill cover the workflow across a 295-route, 20-module surface

The deciding question is not which service can push a packet. Ask which lifecycle defines “in class right now,” then make that lifecycle observable.

How should a Node.js virtual classroom use a realtime API?

A video participant list answers a media question: who is connected to this room? A classroom roster answers an application question: who is currently present in the class? Those sets overlap, but coupling them makes ordinary transitions ambiguous. A learner can reconnect media, switch devices, or leave video while the classroom page remains open.

Presence is the cleaner authority for the live roster because it gives the application an accurate current set without a heartbeat service that you maintain. The video room carries audio and video. Channel messages carry hand raises, which are ephemeral events rather than membership state.

Keep those jobs separate.

Here is the diagram in words: browser joins classroom presence; browser joins the video room; hand raise crosses the classroom channel; server writes attendance separately. Four arrows. Three lifecycles. One durable record.

Do not promote a live presence snapshot into a historical claim. A snapshot at 10:15 cannot prove that a learner attended from 10:00 to 11:00. Store attendance transitions in your own tables, using your institution's rules for late arrival, reconnects, and minimum participation time. The realtime layer reports now; the database reports what happened.

Pick each service by its authoritative lifecycle

Choose Ably when managed channels and documented presence operations already match the application model. Its presence model exposes members attached to a channel, while messages can carry the hand-raise action. A current member list is still operational state, not a gradebook entry.

Choose Pusher Channels when the team wants the familiar split between presence channels and channel events. Check its documented presence limits against the largest class you intend to run, and keep video outside this choice.

Choose LiveKit when joining the media room should define most of the learner experience. WebRTC rooms and participant state live together. This suits video-first classrooms, but text-only learners, observers, or staff may need to count as present without publishing media. Define that policy before treating room participants as the roster.

Choose Twilio when Conversations and Video already match the communications stack. Messaging and media have distinct homes, so your server must reconcile participant identity and decide which participant set drives the roster.

Choose Socket.IO when owning the socket server is actually a requirement: custom protocol behavior can justify the operational work. It is the wrong direction for this classroom brief, which explicitly removes that server from the team's responsibilities.

The REST option differs operationally. Anything that can send an HTTP request can use it, with no realtime SDK dependency to upgrade. A single key covers the presence check, event publish, and room operation instead of making the classroom service manage separate credentials, and those operations arrive on one bill. Consistent conventions reduce configuration drift between the roster, hand queue, and media setup. Your application still owns browser delivery and the mapping among those resources.

Model hand raises as events, not presence metadata

A raised hand has a beginning, an end, and a classroom scope. It does not change who is present. Encoding it inside presence makes roster updates carry interaction state and creates awkward recovery rules after reconnects. Publish an event, then let the teacher view project the latest state for each learner.

Start by reading the authoritative roster. This runnable TypeScript function uses the verified presence route, keeps the response as unknown because no response fields are assumed here, and handles rate limiting without a tight loop.

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const apiBaseUrl = process.env.INFRAI_API_BASE_URL;
if (!apiBaseUrl) throw new Error("INFRAI_API_BASE_URL is required");

async function getPresence(channel: string, attempt = 0): Promise<unknown> {
  const response = await fetch(
    `${apiBaseUrl}/v1/realtime/presence/get/${encodeURIComponent(channel)}`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` }
    }
  );

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return getPresence(channel, attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`Presence request failed (${response.status}): ${await response.text()}`);
  }
  return response.json() as Promise<unknown>;
}

getPresence("class-204").then(console.log).catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Then project hand events independently. This provider-neutral core contains the easy-to-miss part: duplicate delivery cannot raise the same hand twice, and only the newest event controls the projection. The separation is intentional: a failed roster refresh must not reorder or erase hands, and a duplicated hand event must not alter membership. In production, put the event ID in durable storage when process restarts matter, assign trustworthy ordering on the server, and authorize both classroom membership and the requested action before publishing anything. Those checks belong beside the transport adapter, where every provider path passes through the same policy.

type HandState = "raised" | "lowered";

type HandEvent = {
  eventId: string;
  classroomId: string;
  learnerId: string;
  state: HandState;
  occurredAt: string;
};

type ProjectedHand = {
  eventId: string;
  state: HandState;
  occurredAt: string;
};

class HandProjection {
  private readonly seen = new Set<string>();
  private readonly hands = new Map<string, ProjectedHand>();

  apply(event: HandEvent): boolean {
    if (this.seen.has(event.eventId)) return false;
    this.seen.add(event.eventId);

    const current = this.hands.get(event.learnerId);
    if (!current || Date.parse(event.occurredAt) >= Date.parse(current.occurredAt)) {
      this.hands.set(event.learnerId, {
        eventId: event.eventId,
        state: event.state,
        occurredAt: event.occurredAt
      });
    }
    return true;
  }

  raisedLearnerIds(): string[] {
    return [...this.hands.entries()]
      .filter(([, hand]) => hand.state === "raised")
      .sort((a, b) => a[1].occurredAt.localeCompare(b[1].occurredAt))
      .map(([learnerId]) => learnerId);
  }
}

const projection = new HandProjection();
projection.apply({
  eventId: crypto.randomUUID(),
  classroomId: "class-204",
  learnerId: "learner-17",
  state: "raised",
  occurredAt: new Date().toISOString()
});

console.log(projection.raisedLearnerIds());
Enter fullscreen mode Exit fullscreen mode

The eventId belongs to the application, not a vendor payload. Persist it if the teacher view must survive a process restart. If ordering matters across devices, assign order on the trusted server rather than accepting arbitrary client clocks; the timestamp comparison above is suitable only after events receive trusted timestamps.

Fast feedback matters. The learner interface can show a raised state immediately, then reconcile with the channel event. The teacher interface should use projected channel state because that is the shared view. A roster refresh must never clear the hand queue.

Never conflate them.

Instrument presence accuracy before launch

Record changes at the boundaries: presence join, presence leave, video join, video leave, hand raised, hand lowered, and attendance write. Use the same application-level learner and classroom identifiers in each record. Keep display names and classroom message content out of telemetry.

Start with three checks. Compare the current presence count with roster rows rendered to the teacher. Count identity mappings that fail between presence and the video room. Measure how long a hand stays raised before it is lowered or acknowledged. These are application diagnostics, not vendor performance claims.

A crisp alert beats a wall of charts: alert when roster projection repeatedly disagrees with the fetched presence set, grouped by classroom and deployment version. A one-off mismatch during a transition may be normal. Repeated disagreement points to the projection, identity mapping, or reconnect path.

Test concrete sequences: 30 learners join; one switches networks; one opens a second tab; the teacher reconnects; a raised-hand event arrives twice; then the class ends. Verify the live roster, media participants, hand projection, and attendance table independently after every transition. This exposes the modeling error where one subsystem accidentally becomes authoritative for all four.

Limits and the final decision

No managed realtime product decides what “attended” means for a school. Presence supplies the live signal, but policy belongs in the application and history belongs in its database. WebRTC defines media connections, not attendance. Channel delivery also does not remove the need for idempotent event handling.

Pick Ably or Pusher Channels for a channel-first application, LiveKit for a video-first room, Twilio when its separate communications products match your stack, or the REST option when avoiding SDK ownership and consolidating credentials matter most. Preserve the architecture: presence for now, channel events for gestures, video for media, tables for history.

References

Top comments (0)