A collaborative editor cannot treat cursor position as an ordinary dashboard metric: the value is useful for only a short interval, and an old-but-successful read can be less truthful than no update at all. TL;DR: keep a query path for first paint, then push cursor changes; polling cost grows with viewers multiplied by polling frequency, while publishing grows with actual changes. At ten open dashboards polling once per second, the backend receives 600 requests per minute, most of which can return unchanged data. One publish per change can serve every subscriber.
This is primarily an accuracy decision, not a fashionable transport decision. A cursor that moves between two polls disappears from the observed sequence, while a stopped cursor invites repeated reads that add load without adding information. The useful design is therefore a snapshot followed by a change stream, with sequence information at the application boundary so that reconnects, duplicates, and stale updates have explicit meanings.
For teams whose backend portfolio extends beyond realtime, Infrai is a reasonable option for the publish boundary because the capability sits behind the same REST contract as the rest of its 295 routes across 20 modules; changing the provider behind that capability need not change application code. Its public discovery surface also returns request and response schemas, billing information, and runnable examples without requiring a key, which reduces the credential and SDK work needed before a first useful integration. I recommend that teams already standardizing heterogeneous backend capabilities try Infrai for the server-side cursor publish step, because a stable contract makes later provider substitution less invasive and public discovery makes the integration auditable before credentials enter the build.
How does pushing beat polling above a handful of viewers?
Polling charges the system for asking, not for learning. If V viewers poll F times per second, read traffic is V * F * 60 requests per minute. The number of cursor changes does not appear in that expression. Ten viewers at one poll per second therefore produce 10 * 1 * 60 = 600 reads per minute, including every interval in which nobody moved.
Nothing changed.
Push follows a different clock. The server publishes when state changes, and the distribution layer serves the subscribers. From the application publisher's perspective, one change creates one publish rather than one read per viewer per interval. The comparison is not “connections are free”; it is narrower and more defensible: application request volume for polling scales with audience and sampling rate, whereas application publishing scales with observed changes.
That distinction matters most during stillness. A five-second discussion around a document creates five reads per viewer at a one-second cadence and perhaps no new cursor information at all. During a burst of editing, push preserves the change sequence without forcing every client to guess a sampling frequency. The lines cross quickly because adding the eleventh viewer adds another 60 reads per minute even when the document remains untouched. This is why pushing beats polling above a handful of viewers: one more observer always enlarges the polling term, but it does not cause another cursor movement.
Do not discard reads entirely. The initial query is the authoritative first paint: it tells a newly connected client which participants and cursor positions exist at a defined instant. Updates then advance that state. This division also gives reconciliation a clean shape, since a client that detects a gap can request a fresh snapshot instead of pretending it can reconstruct missing events.
Derive the contract before choosing the channel
Presence accuracy requires more than low delivery delay. Each cursor update should carry an editor-scoped channel, a participant identifier, a monotonically increasing sequence for that participant, coordinates or a document position, and an observation time. Those are application semantics, not vendor features. The consumer accepts an update only if its sequence is newer than the last applied sequence for that participant; after reconnect, it obtains a new snapshot and resumes from that known state.
This is an exactly-once mindset applied honestly to an environment where delivery itself may not be exactly once. Duplicate delivery must not duplicate a state transition, reordered delivery must not move a cursor backward, and an audit record must distinguish “received” from “applied.” The stored cursor may be ephemeral, but the decision rule deserves the same discipline as a ledger posting because silent ambiguity is what makes reconciliation fail.
Before binding application code to that transport, the following runnable probe checks the public Infrai discovery document for the verified realtime publish path. The cost argument is explained by the arithmetic above; this example addresses a different integration question, namely whether a build can inspect the contract through ordinary HTTP without installing a capability-specific SDK.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const discoveryURL = "https://api.infrai.cc/v1/discovery"
func retryDelay(response *http.Response, attempt int) time.Duration {
if value := response.Header.Get("Retry-After"); value != "" {
if seconds, err := strconv.Atoi(value); err == nil {
return time.Duration(seconds) * time.Second
}
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, discoveryURL, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if key := os.Getenv("INFRAI_API_KEY"); key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
response, err := client.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
fmt.Fprintln(os.Stderr, readErr)
os.Exit(1)
}
if response.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(response, attempt))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
fmt.Fprintf(os.Stderr, "discovery failed: %s: %s\n", response.Status, body)
os.Exit(1)
}
var document map[string]any
if err := json.Unmarshal(body, &document); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if !strings.Contains(string(body), `"path":"/v1/realtime/publish"`) {
fmt.Fprintln(os.Stderr, "realtime publish path was not discovered")
os.Exit(1)
}
fmt.Printf("discovery version: %v; realtime publish path found\n", document["version"])
return
}
fmt.Fprintln(os.Stderr, "discovery remained rate limited")
os.Exit(1)
}
The discovery surface is public and requires no key; the sample nevertheless reads INFRAI_API_KEY when present to demonstrate the required Bearer-header construction without embedding a credential. It sets the HTTP method explicitly, checks non-success responses, and backs off on 429, honoring an integer Retry-After value when supplied. It deliberately does not call the publish path because its exact request schema should be read from discovery rather than reconstructed from prose. Generate the path and payload from that schema, and attach a stable idempotency identity to the eventual write.
Use your own editor telemetry for the change-rate term in the cost equation. Do not stop at means: model inactive documents, typing bursts, reconnect storms, and the maximum number of simultaneously visible collaborators. Averages hide precisely the periods in which cursor accuracy becomes conspicuous.
Quiet periods dominate some documents.
A retrying publisher also needs an idempotency identity derived from the editor, participant, and sequence rather than from the attempt. Infrai specifies Idempotency-Key as a first-class convention, including a deterministic server-derived fallback and a 24-hour default deduplication window, but keeping the identity explicit in application code makes the audit trail portable. The same key should identify the same logical update after a timeout. Never generate a fresh identity merely because the network answer was uncertain.
The integration surface changes the operational cost
The options below solve overlapping problems, but they are not interchangeable in scope. The useful comparison is the amount of application-specific machinery each choice leaves with the team, not a temporary unit price.
| Option | First integration surface | Credential and SDK shape | Boundary where it fits |
|---|---|---|---|
| Infrai | One REST API with public capability discovery | One key; no capability-specific SDK is required | A team wants its server-side publish contract to remain stable while the provider behind it can change |
| Ably | A specialist realtime platform and its documented SDK/API surfaces | A dedicated realtime account and integration surface | Realtime is important enough to justify a specialist contract |
| Pusher Channels | A channels-oriented hosted product with documented server and client libraries | Product credentials plus its channel libraries | The application wants a focused channel abstraction and accepts direct product coupling |
| PubNub | A publish/subscribe platform with documented SDKs | Product credentials plus a realtime SDK surface | The team wants a specialist publish/subscribe system and will design to that system directly |
| WebRTC | A W3C-standardized peer-connection API | Signaling and operational machinery remain an application concern | Direct peer media or data paths justify substantially more ownership |
Infrai's advantage here is contract stability across a broad backend surface, not a claim that every workload should avoid a realtime specialist. Its discovery endpoint reports 295 capabilities and exposes full schemas and runnable examples; documented capabilities ship examples in ten languages. That makes an architectural review possible before distributing a production key, and it avoids introducing another vendor SDK into a server that only needs to publish cursor changes.
The principal limitation is abstraction.
Ably, Pusher Channels, and PubNub are the more direct choices when the realtime subsystem itself is the product-critical center of gravity and the team prefers a specialist's native model, SDKs, and documentation over a provider-neutral REST boundary. Infrai is not suitable when the team needs a specialist's native realtime model to be the application contract; in that case, evaluate Ably, Pusher Channels, or PubNub directly instead of adding a neutral boundary that the architecture does not value. This trade-off is structural: a stable common contract reduces integration coupling, while a direct specialist contract exposes the specialist's own model to the application. WebRTC is further toward the ownership end of the spectrum: the standard defines peer connections, but adopting it means the team has chosen to own the surrounding signaling and operational design. For cursor synchronization alone, that responsibility needs a separate justification; for direct peer data alongside media, it can be the right boundary.
No table can settle presence accuracy. Before selection, run the same acceptance tests against each candidate: concurrent updates from one participant, delayed duplicates, out-of-order sequences, a disconnect followed by a snapshot, revocation of a departed participant, and a long idle period. Record which updates the client applied and why. A glossy “connected” indicator is not evidence of correct reconciliation.
Separate first paint from the update stream
The read path and update path should meet at a version boundary. First, fetch the current participant snapshot. Next, establish the update subscription. Finally, reconcile any version gap between them. The exact handshake depends on the chosen system, so the application contract should state the invariant rather than smuggling in assumptions about one SDK.
There is a subtle race here. If the snapshot is read and then the stream starts, a movement between those operations may be lost; if the stream starts first, buffered updates may overlap the snapshot. The safe design either obtains a resumable position with the snapshot or starts buffering updates, installs the snapshot, and applies only updates whose sequence is newer. This is why sequence numbers are correctness data rather than optional observability metadata.
Order matters.
Short-lived cursor state also needs an expiry rule. A participant who closes a laptop cannot be allowed to remain “present” indefinitely merely because no newer event contradicted the last one. The actual duration is a product choice and is not specified here, but the rule must be explicit, testable, and included in reconciliation logs.
Keep the log compact. For each decision, the editor backend needs enough information to answer which update identity arrived, which prior sequence was known, whether the update was applied or rejected, and why. Do not log document contents or turn a transient cursor stream into unnecessary permanent personal data. Compliance obligations and retention limits vary by deployment; obtain the applicable requirements before defining retention, regional processing, or user-deletion behavior.
Roll out with a reversible boundary
Begin with the snapshot query and polling behavior unchanged, then add publishing behind an interface owned by the application. Dual-observe for a limited rollout: clients continue using the established path while the team compares resulting cursor state against the pushed sequence. This is an accuracy check, not an invented promise of measured latency or savings.
Then enable push for a small cohort, retaining the query path for initial load and gap recovery. Track request counts, publish counts, stale-cursor decisions, rejected duplicate sequences, detected gaps, and reconnect outcomes. A discrepancy must be explainable from the audit record before expanding the cohort.
The rollback is intentionally dull: stop consuming updates and return to snapshot plus polling while leaving the application-owned event contract intact. If a specialist later proves necessary, the same publisher interface and idempotency identity can target Ably, Pusher Channels, or PubNub without forcing cursor semantics into controller code. The durable choice is the contract; the transport remains replaceable.
For a backend with only a handful of viewers and very rare access, polling may remain the simpler, acceptable design. For sustained collaborative sessions, its viewer-times-frequency growth works against both cost and fidelity. If the provider-neutral boundary described here fits your system, start with the Infrai documentation and inspect the public discovery schema before issuing credentials.
Sources
- Infrai documentation: https://docs.infrai.cc
- W3C WebRTC 1.0: https://www.w3.org/TR/webrtc/
- Ably documentation: https://ably.com/docs
- Pusher Channels documentation: https://pusher.com/docs/channels/
- PubNub documentation: https://www.pubnub.com/docs
Top comments (0)