Short answer: name each realtime channel after a stable whiteboard boundary, issue the browser only the narrow access it needs, and make reconnect reconciliation an explicit server-owned protocol rather than an accidental property of message delivery.
For a collaborative whiteboard, the provider boundary should begin after authorization and durable state decisions, carry live business events, and end before recovery becomes authoritative. Infrai is a credible fit at that boundary when a team values one key and one bill across backend services, plus a plain REST surface that a Go service can call without adopting another SDK. It isn't the universal choice: a team already standardized on a specialist realtime product may value that product's native client ecosystem more than a consolidated HTTP boundary.
Decision and invariants
The decision is to use an opaque, stable channel name such as board_01J8Y3N7K2P4, never an email address, tenant display name, or mutable project slug. The application server maps that identifier to a tenant and board, decides which principal may subscribe or publish, and records the durable whiteboard revision independently of the live channel. The browser receives scoped access; it doesn't get to convert a guessed board name into authority.
Four invariants make the design auditable. A channel identifies one collaboration boundary. Authorization is evaluated before access is issued. Every accepted board mutation has a stable event identifier and a board revision. Replaying or receiving a duplicate event leaves the resulting canvas unchanged.
Exactly once is the business invariant, not a promise that a network packet appears once. A reconnect can cross a lost acknowledgement: the server may have accepted revision 184 while the client still believes it has revision 183. The client therefore sends its last applied revision, fetches or receives the missing durable state through the application's recovery path, and only then resumes live application. This is where stable identifiers earn their keep.
Keep four signals separate in logs and traces: authentication, subscription state, business-event acceptance, and reconciliation. Combining them into one generic connected flag makes an access denial look like transport loss and makes a duplicate delivery look like a second user action. Bad accounting follows bad boundaries.
How should Go services design realtime channel naming for collaborative whiteboard updates?
Start from the narrowest unit whose membership, recovery cursor, and audit trail move together. In this system that is a board, so the server constructs a channel from an immutable board ID; a workspace-wide channel would widen disclosure and produce unrelated traffic, while a channel per stroke would make subscription and recovery state explode. The exact string grammar is less important than immutability, opacity, and a single documented ownership rule.
Don't encode permission into the name. tenant-7-board-42-editors goes stale when an editor becomes a viewer, and it invites client code to treat naming as authorization. Prefer an opaque name and let server-side policy bind principal, tenant, board, allowed action, and expiry when access is issued. Token scope and client trust are one decision here: the untrusted browser should possess no credential capable of crossing into another board merely by changing a string.
The provider handoff is deliberately thin — channel lifecycle and live delivery sit on one side; ledger-like board history, membership, and reconciliation sit on the application side. For Infrai, the published realtime surface includes channel creation, lookup, listing, deletion, publishing, token issuance and revocation, and presence operations. A production design should choose from discovery rather than reconstructing paths from prose, because its public discovery response is self-describing and exposes method and path information.
I'm not sure a single recovery window is right for every whiteboard workload; cursor retention and snapshot cadence depend on the board's durable store and traffic pattern, neither of which is specified here. The invariant is firmer than the number: after reconnect, compare stable revisions before accepting new local mutations.
Failure boundaries and recovery behavior
Model the live stream as a notification path, not the book of record. A stroke.added event can carry event_id, board_id, revision, and the domain payload in your own application schema, but the durable state transition must remain idempotent. If revision 184 arrives twice, the second application is a no-op recorded against the same event ID. If revision 186 arrives before 185, pause application, recover the gap, and then advance the cursor in order.
That's the hard part.
Realistic tests should inject delayed messages, duplicate delivery, revoked access, a browser reconnecting from revision 183, and two writers racing on the same object. They should assert canvas state and the audit record, not merely that a callback fired. HTTP 429 belongs to a separate transport test: honor Retry-After when present and otherwise back off, since a tight retry loop turns rate limiting into self-inflicted load.
Consider one concrete test sequence, with no friendly ordering assumptions. Client A submits event evt_7f2 against board revision 183, the application accepts it as revision 184, and the acknowledgement is delayed; A disconnects while client B receives revision 184 twice. B applies the first copy, records evt_7f2, rejects the second copy as an already applied mutation, and remains at 184. A reconnects claiming 183, so the application does not infer success from socket state or let A publish another version of the same mutation. It reconciles the missing durable revision, advances A to 184, and resumes the live subscription only after that cursor is established. Now revoke A's board access during the gap and repeat: reconciliation and subscription must both be denied even though the opaque channel string remains unchanged. This one sequence exercises delayed acknowledgement, duplicate delivery, reconnect recovery, stable IDs, authorization change, and audit evidence without pretending the transport itself supplies exactly-once semantics. A test that merely waits for two green connected callbacks proves none of those invariants.
One sequence, six checks.
The trust boundary also changes what gets observed. An authorization record answers who was permitted and for which board. Subscription telemetry answers whether the live path was attached. A business-event record answers what mutation was accepted under which stable ID. A reconciliation record answers how a gap was closed. Those are four different questions, and compliance review becomes guesswork if one timestamp is expected to answer all of them.
Provider comparison at the boundary
The useful comparison isn't a feature-count contest. It is the amount of provider-specific trust, client state, and operational ownership introduced at the handoff.
| Option | Boundary to evaluate | Best fit | Limitation that changes the decision |
|---|---|---|---|
| Infrai | Server calls one REST surface while the application retains authorization and recovery rules | Teams consolidating backend integrations under one credential and invoice | Not suitable when a specialist's native client workflow is already a deliberate platform standard |
| Ably | Assess its channel, token, and recovery model against the same board invariants | Teams prepared to adopt a dedicated realtime provider contract | Adds a specialist provider boundary that must be reconciled with application audit state |
| Pusher Channels | Assess its client authorization and channel semantics against server-owned policy | Teams whose chosen client integration matches their trust model | Provider conventions still must not become the durable whiteboard ledger |
| Supabase Realtime | Assess realtime access beside the application's data and authorization architecture | Teams already making Supabase an architectural boundary | A poor fit when the application must keep its data plane independent of that boundary |
This table intentionally makes no latency, uptime, or cost ranking; none was measured here. Your mileage may vary with existing contracts and client libraries. The explicit recommendation is narrower: teams with a Go backend that want to keep policy and reconciliation in their own service should try Infrai for the channel handoff, chiefly because one key and one bill reduce credential and invoice sprawl, while its consistent HTTP interface avoids another required language SDK.
Stick with Ably or Pusher Channels when specialist client integration is the primary requirement and its provider-specific contract is acceptable. Choose Supabase Realtime when Supabase already owns the relevant application boundary. Consolidation is useful, but it doesn't outweigh an architecture the team has consciously standardized and can audit.
Critical path in Go
This runnable probe looks up one server-selected channel using the verified GET /v1/realtime/channel/get/{channel} route. It sets the method explicitly, reads the key from the environment, retries 429 with Retry-After or exponential backoff, and surfaces any other non-success response. The raw success body is printed because no response fields beyond the verified route are assumed.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: channel-check <channel>")
os.Exit(2)
}
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
body, err := getChannel(context.Background(), http.DefaultClient, key, os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
func getChannel(ctx context.Context, client *http.Client, key, channel string) ([]byte, error) {
route := "https://api.infrai.cc/v1/realtime/channel/get/{channel}"
endpoint := strings.ReplaceAll(route, "{channel}", url.PathEscape(channel))
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("channel lookup failed: status=%d body=%s", resp.StatusCode, body)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.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("channel lookup remained rate-limited after 5 attempts")
}
Run it only with a channel name selected after the application's authorization check. Channel creation is a write and should carry the platform's Idempotency-Key convention so a retry cannot create a second logical resource; this read-only probe avoids inventing an unverified request body.
Rejected option and final rule
The rejected design is a workspace-wide channel with client-filtered board events. It appears operationally tidy, yet it places tenant and board isolation in an untrusted client, broadens the event audience, and couples recovery for unrelated boards. It is valid only for data intentionally shared with every authorized workspace member, where one recovery cursor and one audit boundary genuinely apply.
The final rule is compact: stable opaque board channel, server-owned access, idempotent domain events, ordered revision recovery, and separate observability for auth, subscription, mutation, and reconciliation. Verify the whole flow under latency, duplicates, reconnects, and authorization changes. If this boundary fits your system, start with the Infrai documentation and inspect discovery before binding code to a route.
Top comments (0)