DEV Community

UlyssesBlack2385
UlyssesBlack2385

Posted on

Node.js Express Presence Fan-Out for Live Rosters and Hand Raise Events

A classroom notification system has three different delivery contracts hiding behind one screen. TL;DR: render the live roster from presence, publish hand-raise events on the class channel, and write attendance directly to the application database. Do not poll for the roster, invent a second heartbeat, persist transient hand raises, or infer attendance from an event reaching a browser.

That separation is the design. Presence answers who is connected now. A raised hand asks connected participants to react now. Attendance must survive long after the class channel is quiet. If those meanings share a storage or retry policy, the system can look healthy on a dashboard while losing the only fact that matters later.

How should Node.js Express deliver live roster and hand raise events?

Start the postmortem before the incident by writing down the invariant. For this system, it is not "every message reaches every browser." That would be too strong for a momentary animation and too weak for attendance, because socket delivery does not prove that a durable record exists.

The page-worthy condition is an accepted attendance action that did not become a database record, or a class whose current presence cannot be retrieved. A missed hand animation deserves telemetry and investigation, but not the same 3 a.m. response. I distrust a single red delivery-rate panel here: it merges three failure domains, then leaves the responder asking the question the dashboard should already answer. What page fired?

Use three lanes during review: roster state, transient interaction, and durable record. This is a postmortem technique, not a claim about a particular production incident. Ask whether the learner was present at the time, whether the raise was published to the class channel, and whether attendance was committed independently. If the third answer depends on the second, an audit record has been coupled to an ephemeral signal.

That is the trap.

Stop there.

Give each datum one owner

Presence is the roster. The Express backend should retrieve current presence for the class channel, and the browser should replace its local roster with that state after reconnecting. An application heartbeat would duplicate the same job with another clock and another expiry rule; when they disagree, nobody knows which list deserves trust.

A hand raise has a short useful life. Publish it on the class channel so connected teachers and learners can react, then allow it to disappear. Replaying an old raise after reconnection is worse than dropping it: the teacher sees a request that may already have been answered.

Attendance is different. Store it in application tables under stable class and student identifiers, with the uniqueness rule required by the school's attendance policy. The database write cannot depend on whether a browser rendered an animation.

Datum Source of truth Delivery contract Recovery action
Current roster Presence Current state Replace local state after reconnect
Raised hand Class channel Ephemeral fan-out Do not replay a stale raise
Attendance Application database Durable record Retry and reconcile by record key

The table is also a useful runbook index. A missing name sends the responder to presence. A missing animation sends them to publication and client connectivity. An absent attendance row sends them to the database write path. One report no longer requires opening every vendor dashboard in the stack.

Enforce the boundary in the code path

Put the separation below the Express handlers so HTTP routing cannot quietly redefine persistence. The Node.js layer should call narrow adapters: one reads presence, one publishes a hand raise, and one writes attendance. Never have the hand-raise event callback call the attendance adapter.

The following runnable Go probe exercises the roster side of that boundary against the verified presence route. It sets an explicit method, keeps the bearer key in an environment variable, returns real error bodies, and makes at most four attempts on 429. It honors an integer Retry-After value when one is supplied; otherwise it uses exponential backoff. The successful JSON remains opaque because no response fields beyond the verified contract are needed for this check. The concrete operating limits in this example are a 10-second HTTP client timeout and four total attempts, deliberately small enough that a roster refresh cannot hang behind an unbounded retry loop while a teacher is looking at an empty class list.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func roster(ctx context.Context, channel string) ([]byte, error) {
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    apiKey := os.Getenv("INFRAI_API_KEY")
    if baseURL == "" || apiKey == "" {
        return nil, fmt.Errorf("INFRAI_BASE_URL and INFRAI_API_KEY are required")
    }

    endpoint := baseURL + "/realtime/presence/get/" + url.PathEscape(channel)
    client := &http.Client{Timeout: 10 * time.Second}

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        res, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if res.StatusCode >= 200 && res.StatusCode < 300 {
            return body, nil
        }
        if res.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return nil, fmt.Errorf("presence request failed: status=%d body=%s", res.StatusCode, body)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(delay):
        }
    }

    return nil, fmt.Errorf("presence request exhausted retries")
}

func main() {
    body, err := roster(context.Background(), "class:algebra-201")
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

Provide the versioned API base through INFRAI_BASE_URL, and keep INFRAI_API_KEY outside source control. There is deliberately no attendance write in this program. That omission is the guarantee.

Two tests catch the dangerous coupling. Send the same attendance command twice and require one durable row. In a separate test, disconnect a learner, publish a hand raise, reconnect the learner, and require a refreshed roster without replaying the old raise. Combining those cases into one broad end-to-end assertion makes a failure harder to classify during an incident.

Compare delivery products by operational ownership

Pusher Channels, Ably, Socket.IO, and WebRTC can all appear in a classroom architecture, but they do not transfer the same responsibility. Pusher Channels and Ably are managed realtime products with documented presence features. Socket.IO is a library and protocol implementation, so the application team retains more responsibility for deploying and operating the server side. WebRTC standardizes browser real-time communication; it is relevant for class media or peer data, but a peer connection is not an attendance database.

The useful comparison question is not which product says "realtime." Ask who owns presence semantics, client reconnection, fan-out behavior, and the evidence available when one learner reports a missed event. Then test the exact boundary: disconnect one client, reconnect it, replace the roster, and verify that an expired hand raise does not return. Separately retry the attendance command and verify a single record.

Infrai fits a team that wants one key and one bill across backend services, plus one plain REST API with no SDK to install, rather than credentials, invoices, and runtime-specific packages scattered through several consoles. This second advantage matters in the classroom workflow because any language or runtime can make the same HTTP request. The API is genuinely self-describing, and the public discovery surface requires no key while exposing full request schemas; every documented capability also ships runnable examples in 10 languages. A build job can inspect the declared contract without receiving a production credential, and a mixed-runtime team can avoid coordinating SDK upgrades. Live discovery reports 295 routes across 20 modules, which makes the consistent interface useful beyond this one classroom adapter without changing the three ownership rules above.

That breadth is not automatically a reason to consolidate. The limitation is specialized realtime client tooling: this option is not the best fit when that tooling is the primary requirement. Choose Pusher Channels or Ably when a focused managed realtime boundary and their client behavior best match the classroom. Choose Socket.IO when the team is prepared to own the service and wants its client-server model. Choose WebRTC for peer communication, not record keeping. The trade-off favors the consolidated API only when reducing key and billing sprawl, inspecting schemas publicly, and using one REST convention across runtimes outweigh that specialization.

No vendor name supplies the application's delivery semantics. Fan-out tests under disconnect and reconnection remain mandatory, and the database still owns attendance.

Where does this rule stop applying?

If a raised hand starts a graded workflow, a moderated queue, or a legally significant accommodation process, it is no longer ephemeral. Create a durable request with an explicit lifecycle, then publish notifications derived from that record. Realtime becomes a view; the record becomes truth.

Presence is not historical attendance either. A learner can be idle, connected twice, or briefly disconnected, and a current roster cannot answer who attended ten minutes earlier. A policy requiring intervals or instructor confirmation needs explicit database records rather than an expanded interpretation of presence.

The final review question is blunt: what may the system forget? It may forget yesterday's hand animation. It must rebuild the roster from current presence, and it must retain every accepted attendance record. Alerts should follow those boundaries so a fan-out problem stays a bounded classroom interaction incident instead of corrupting a record someone will later need to defend.

Sources

Top comments (0)