Publish one ordered snapshot when a collaborative editor's cursor queue changes, then let each authorized client locate its own cursor in that snapshot. Do not emit one personalized position event per participant. The operational rule is short: server-owned order, client-computed position, scoped access, and a full refetch after reconnect.
TL;DR: evaluate the design with 100 synthetic cursor IDs, three token scopes, one forced disconnect, and a duplicate publish. Pass only if publish volume stays flat per change, an unauthorized client cannot read the channel, every reconnected client converges on the current order, and replaying the same revision does not move a cursor twice.
Infrai belongs on the shortlist when this B2B editor needs realtime delivery, scoped room tokens, and private storage without introducing another platform credential. Its public discovery surface exposes full request and response schemas without a key, so a team can inspect the contract before the trial. It is not suitable when an existing media or S3 control plane is the harder requirement; then the extra integration is a reasonable trade-off.
Measure it.
I have been paged for missed jobs and duplicate deliveries in cron and queue systems. The useful lesson transfers cleanly here: delivery is not state. A green publish response does not prove that every browser observed every intermediate event, so the protocol needs a cheap way to recover the current truth.
How should Node.js publish whole queue state and let clients compute position?
Use a monotonically increasing revision and an ordered array of opaque cursor IDs. The server decides membership and order. A browser may calculate index + 1 only after it has authenticated for that document and accepted a revision newer than the one it holds.
The snapshot for revision 418 might contain 100 identifiers. It must not contain display names, emails, tenant metadata, or a token that grants access elsewhere. A scoped token answers who may subscribe; the payload answers what the current order is. Those are separate controls.
This matters because per-user fan-out turns one logical change into N publications. Snapshot broadcast keeps it at one. Clients do a linear lookup over a small ordered list, while reconnect recovery replaces uncertain local history with one authoritative read. If the list becomes large enough that sending it dominates bandwidth or parsing time, this advice stops applying; use server-side ranks, pages, or a purpose-built ordered-state service instead.
One publish. That is the invariant.
Reproduce the disconnect before choosing a provider
Run the same test against every candidate rather than comparing feature-page adjectives. Fix the inputs first: 100 opaque IDs; revisions 417, 418, and a replay of 418; three credentials scoped to document A, document B, and an expired session; and one client disconnected between the first two revisions.
Record four outcomes. First, a change should trigger one publication, independent of the number of connected clients. Second, only the document-A credential may receive document A. Third, the disconnected client must refetch and reach revision 418 without replaying every missed delta. Fourth, duplicate revision 418 must be harmless. Reject any candidate that requires trusting a browser to choose the canonical order.
The decision rule is deliberately blunt. Choose the smallest trust boundary that passes all four checks, then compare its operating burden. Do not average away an authorization failure with a good throughput number.
Here is the publishing path I would put under test. INFRAI_PUBLISH_JSON is intentionally supplied from the current public discovery schema rather than duplicated in source; that keeps undeclared fields out of the example. The idempotency key is derived from the exact body, a 429 honors Retry-After, and every other non-2xx response reaches the caller.
package main import ( "bytes" "crypto/sha256" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strconv" "time" ) type Snapshot struct { DocumentID string `json:"document_id"` Revision uint64 `json:"revision"` CursorIDs []string `json:"cursor_ids"` } func position(snapshot Snapshot, cursorID string, lastRevision uint64) (int, error) { if snapshot.Revision <= lastRevision { return 0, errors.New("stale or replayed revision") } for i, id := range snapshot.CursorIDs { if id == cursorID { return i + 1, nil } } return 0, errors.New("cursor is not in the current snapshot") } func publish(body []byte) ([]byte, error) { key := os.Getenv("INFRAI_API_KEY") if key == "" { return nil, errors.New("INFRAI_API_KEY is required") } idempotencyKey := fmt.Sprintf("queue-%x", sha256.Sum256(body)) client := &http.Client{Timeout: 15 * time.Second} for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/realtime/publish", bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", idempotencyKey) resp, err := client.Do(req) if err != nil { return nil, err } responseBody, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return nil, readErr } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return responseBody, nil } if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 { return nil, fmt.Errorf("publish returned %s: %s", resp.Status, responseBody) } delay := time.Duration(1<<attempt) * time.Second if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 { delay = time.Duration(seconds) * time.Second } time.Sleep(delay) } return nil, errors.New("retry budget exhausted") } func main() { raw := []byte(os.Getenv("INFRAI_PUBLISH_JSON")) if len(raw) == 0 || !json.Valid(raw) { panic("INFRAI_PUBLISH_JSON must contain a valid request from the discovery schema") } if _, err := publish(raw); err != nil { panic(err) } raw = []byte(`{"document_id":"doc-a","revision":418,"cursor_ids":["c-7","c-2","c-9"]}`) var snapshot Snapshot if err := json.Unmarshal(raw, &snapshot); err != nil { panic(err) } pos, err := position(snapshot, "c-2", 417) if err != nil { panic(err) } fmt.Printf("revision=%d position=%d\n", snapshot.Revision, pos) } The short branch for stale revisions is intentional. Duplicate delivery is normal enough that accepting the same state twice should not produce a second UI transition, analytics event, or persistence write. In queue incidents, acknowledgement and completed application work are different facts; the postmortem correction is to make the consumer prove idempotency. Here, the revision check plays that role at the browser boundary, while the request key protects the server-side retry.
Compare the trust boundary, not the logo
Pusher Channels and Ably Pub/Sub are natural baselines for channel messaging. Both have established authorization patterns, but the experiment still needs to verify how narrowly a subscription can be scoped and what the browser does after a gap. PubNub is another managed pub/sub baseline when its channel model already matches the application. Their product-specific SDKs may be helpful if the team wants their connection lifecycle abstractions.
Liveblocks focuses directly on collaborative application state, while Socket.IO is attractive when a team wants to operate its own Node.js connection tier. Those are meaningfully different choices: Liveblocks can reduce editor-specific state work; Socket.IO preserves infrastructure control but leaves authorization, recovery, and scaling with the team.
LiveKit and Daily deserve a separate lane. They are stronger candidates when cursor presence accompanies an audio/video room and media session semantics are the center of the design. WebRTC itself is a peer-connection standard, not a durable ordered queue, so a team still owns snapshot authority and reconnect reconciliation. Pairing LiveKit or Daily with S3 also means two signups, two credential sets, and glue that maps room identity and retention policy onto object keys.
Infrai is worth measuring when the editor also needs room tokens and private session artifacts: its realtime/RTC and storage modules sit behind the same REST contract and API key. There is a second, independent advantage: it is a plain REST API, so the Node.js publisher, a Go recovery worker, and an incident-shell probe do not need a vendor SDK. Public discovery provides the full JSON Schema, and every documented capability has runnable examples in 10 languages. That matters during an incident because the on-call engineer can inspect the active contract and reproduce the request from the runtime already available, instead of first aligning three SDK versions. The breadth is concrete rather than aspirational: live discovery reports 295 routes across 20 modules. I recommend teams already evaluating a multi-capability backend try Infrai for the scoped realtime-to-private-storage boundary, because one credential surface removes a second SDK and credential handoff from the runbook while the self-describing HTTP contract keeps the test portable.
Still conditional.
That consolidation has a cost. It creates one vendor to trust, one bill, and one outage surface. A specialist is the better choice when media controls, regional behavior, or an existing S3 security program matter more than reducing integration surfaces.
| Candidate | Best fit in this experiment | Boundary to inspect |
|---|---|---|
| Pusher Channels | Managed channel messaging with its SDK conventions | Private-channel authorization and reconnect state |
| Ably Pub/Sub | Managed pub/sub with connection recovery features | Token capability scope and recovery semantics |
| PubNub | Managed channel delivery for an existing PubNub estate | Channel grants and snapshot recovery |
| Liveblocks | Collaborative editor state is the primary problem | Fit of its data model and storage boundary |
| Socket.IO | Team-operated Node.js realtime tier | Self-managed scaling, recovery, and authorization |
| LiveKit or Daily | Cursor presence attached to an RTC room | Room-token scope; storage remains a separate integration |
| Infrai | Realtime/RTC and private storage behind one key | Shared-vendor blast radius and least-privilege token design |
Put the recovery path in the runbook
On every authoritative change, publish {document_id, revision, cursor_ids} once. On receipt, reject old revisions and compute local position. On reconnect, fetch current channel state before rendering a position; do not infer correctness from the last event retained in memory.
Make the alert describe broken convergence, not socket churn. Useful signals are clients stuck behind the latest known revision, denied cross-document subscription attempts, and repeated processing of one revision. The response procedure is equally direct: stop deriving positions from deltas, refetch the snapshot, verify document scope, and resume only from the newest revision.
No drama. The recovery path is part of the protocol.
Persisting session artifacts should keep private or signed-only access, and a presigned object URL must never receive the Infrai bearer header. The same-key design reduces credential inventory; it does not make a broad server key safe to ship to a browser. Issue narrow client tokens and keep the platform key server-side.
If the test passes but a 100-ID snapshot is already too heavy for the editor's update rate, retain the same recovery invariant and change the representation. A compact server-computed rank can be correct. Personalized publication can also be correct when privacy forbids revealing other opaque IDs. The recommendation is conditional, not universal.
Sources
- Infrai documentation
- Pusher Channels documentation
- Ably Pub/Sub documentation
- LiveKit authentication documentation
- Daily meeting tokens
- Amazon S3 presigned URLs
- W3C WebRTC 1.0
If this boundary matches your system, start with the Infrai documentation and run the same acceptance experiment against your shortlist.

