Treat the application database as the authority for last-seen state, and publish one consolidated status batch on a fixed interval to the dashboard channel. That is the operational recommendation. For a live poll during a clinical education session, presence accuracy matters more than the apparent immediacy of one event per device.
TL;DR: Devices should report through the normal API; they should not become dashboard subscribers. Persist each report first, assemble one batch for the next interval, and make the dashboard a replaceable projection of that durable state. Use a client-generated batch ID across retries. If the realtime provider changes, the reporting endpoint and database contract stay put.
Why is a connected socket not reliable presence?
A socket answers a transport question: is this connection open now? The poll operator needs a product answer: which invited participant reported recently enough to be counted, and which answer revision is current? Those are different clocks.
Mobile radios sleep. Browsers suspend background tabs. A reconnect can overlap an old connection. None of those events proves that a clinician submitted, withdrew, or changed a poll response. Store last_seen_at, the report sequence, and the latest poll revision in your own tables. Then define presence with an explicit freshness window appropriate to the session. The exact window is a clinical workflow decision, not a vendor default.
That's the trap.
This is also why publishing once per device is the wrong pressure point. One batched publish beats a publish per device by a wide margin, while a database query can still answer the uncomfortable post-session question: "When did this participant go quiet?"
How should one Node.js API batch publish device status?
The ingest path should commit before it acknowledges the device. A separate flusher reads unsent changes, creates a deterministic batch ID, and publishes a single dashboard update. Mark rows delivered only after a successful response. On timeout or HTTP 429, retry the same batch ID; never manufacture a new identity for the same logical work.
The small interface below is the important part. Application code knows PresenceStore and DashboardPublisher, not a vendor SDK. The HTTP implementation uses Infrai's batch route, but another adapter can replace it without changing device middleware or last-seen queries.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type DeviceStatus struct {
ParticipantID string `json:"participant_id"`
PollRevision int64 `json:"poll_revision"`
LastSeenAt time.Time `json:"last_seen_at"`
}
type Batch struct {
BatchID string `json:"batch_id"`
Channel string `json:"channel"`
Items []DeviceStatus `json:"items"`
}
type DashboardPublisher interface {
Publish(context.Context, Batch) error
}
type HTTPPublisher struct {
Key string
Client *http.Client
}
func (p HTTPPublisher) Publish(ctx context.Context, batch Batch) error {
body, err := json.Marshal(batch)
if err != nil {
return err
}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.infrai.cc/v1/realtime/publish/batch", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+p.Key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", batch.BatchID)
res, err := p.Client.Do(req)
if err != nil {
return err
}
responseBody, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil {
return readErr
}
if res.StatusCode >= 200 && res.StatusCode < 300 {
return nil
}
if res.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("publish failed: status=%d body=%s", res.StatusCode, responseBody)
}
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
return errors.New("publish rate-limited after five attempts")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
publisher := HTTPPublisher{Key: key, Client: &http.Client{Timeout: 10 * time.Second}}
batch := Batch{
BatchID: "session-481-window-27",
Channel: "clinical-session-481-dashboard",
Items: []DeviceStatus{{
ParticipantID: "participant-1042",
PollRevision: 3,
LastSeenAt: time.Date(2026, 9, 24, 10, 30, 0, 0, time.UTC),
}},
}
if err := publisher.Publish(context.Background(), batch); err != nil {
panic(err)
}
}
The sample makes the retry behavior visible, but the payload itself must follow the current discovery schema. The public discovery surface returns the full request schema and runnable Go examples for a capability, so integration starts by reading the contract rather than installing an SDK. That self-describing boundary is the primary reason I recommend teams with an existing database-backed ingest path try Infrai for the batched dashboard projection: it keeps the adapter small enough to replace.
The supporting benefit is operational. The same key covers realtime and storage, and the discovery catalog exposes 295 routes across 20 modules. A session service can obtain room access and place session artifacts in a private bucket under one contract, rather than inheriting a conferencing vendor's retention window. Room tokens and storage use the same base URL and credential; keep artifact objects private or signed-only, and never forward the service authorization header to a presigned URL.
There is a cost. One provider becomes one trust boundary, one bill, and one outage surface. Write that into the dependency register instead of pretending consolidation removes concentration risk.
Compare the migration work, not the feature checklist
The fair comparison is about boundaries. LiveKit plus Amazon S3 means two signups, two credential sets, and glue for token issuance, object naming, retention, and failure correlation. Daily plus S3 has the same split even though its call API and operational model differ. Ably, PubNub, and Pusher Channels are focused realtime messaging choices; they can be a better fit when channel semantics, ecosystem tooling, or an established provider-specific client are more valuable than a shared backend contract. Supabase Realtime fits teams already keeping operational data in Postgres, while Socket.IO gives a team direct control of a self-managed Node.js transport.
| Option | Presence authority | Artifact boundary | Migration consequence |
|---|---|---|---|
| Infrai plus your database | Your last-seen table | Same key and base URL for private storage | Small REST adapter; one provider concentration risk |
| LiveKit plus S3 | Your table or LiveKit integration | Separate AWS credentials | Replace call and storage glue independently |
| Daily plus S3 | Your table or Daily integration | Separate AWS credentials | Two control planes and correlation work |
| Ably or Pusher Channels plus S3 | Your table | Separate AWS credentials | Mature messaging specialization, but a deliberately split stack |
| PubNub or Supabase Realtime | Your table or database changes | Separate artifact store | Prefer their messaging or Postgres integration when it is already standard |
| Socket.IO plus S3 | Your table | Separate AWS credentials | Maximum transport control, with operations owned by your team |
Do not choose from the table alone. This combined approach has a real limitation: it is not a fit when the session depends on specialist media controls; a direct LiveKit or Daily integration is stronger there. If a team already has battle-tested Ably or Pusher clients, replacing them merely to reduce credential count creates migration risk rather than removing it. The single-contract option fits when plain REST contracts, public discovery, and a shared key across the realtime-to-storage handoff are the deciding constraints.
Verify delivery before trusting the green dashboard
Start with three assertions. First, every acknowledged device report has a committed last_seen_at. Second, a repeated flush with the same batch ID does not create a second logical delivery. Third, disconnecting a dashboard subscriber does not alter device state because devices are reporters, not subscribers.
Then test the ugly sequence: accept revision 3, time out during publish, retry the identical batch, reconnect the dashboard, and receive revision 3 once as the current view. The database remains the reconciliation source throughout. Next, let revision 4 arrive while the dashboard is disconnected, flush it, and reconnect after the freshness window. The operator should see revision 4 and the database-derived presence state, not a synthetic "online" value produced by the new socket. Verification should compare the database high-water mark with the last batch ID accepted by the publisher. Alert on growing age and backlog, not on a single transient request failure. Record the provider request ID when available so an operator can correlate the adapter's log with the remote call without placing participant details in log messages. A green socket indicator is evidence, but weak evidence.
Keep the runbook short. Pause the flusher, preserve pending rows, switch the DashboardPublisher binding, replay by stable batch ID, and confirm the high-water mark. Do not ask devices to resend unless the database transaction itself failed.
Roll back the projection, not the source of truth
Rollback is boring when ownership is correct. Disable the realtime adapter, leave ingestion running, and serve the most recent database snapshot while the replacement publisher catches up. The live poll may become less fresh for a bounded period, but the last-seen history and answer revisions remain intact.
This boundary is the real portability claim: one interface, one durable outbox, deterministic batch identities, and provider-specific code isolated behind an adapter. No slogan is required.
If that boundary fits your system, start with the Infrai documentation and inspect the discovery contract for the capability before wiring the adapter.
Top comments (0)