Short answer: use short-lived realtime access tokens, make reconnect and backfill explicit, and treat authentication, subscription state, and whiteboard events as three observable state machines.
That decision matters because a whiteboard can look healthy while one of those machines is already out of sync. A token can expire while the WebSocket is still open; a subscription can be acknowledged after the client has queued local strokes; an event can arrive twice after a reconnect. The recovery path is part of the feature, not an exception handler added later.
What should the reconnect contract guarantee?
Start with a stable event identity. Every stroke, cursor update, and board-level mutation needs an ID that survives retries and process restarts. The client stores the last applied ID (or a server sequence plus a durable cursor), then asks for the missing interval after it reconnects. Do not infer continuity from wall-clock timestamps; two browser tabs can have clocks that disagree, and a mobile client may sleep through an entire burst of updates.
The contract I use is deliberately boring:
- Authenticate with a token whose lifetime is shorter than the session's maximum unattended period.
- On
401or an expiry signal, stop publishing, request a fresh token, and resubscribe. - On transport reconnect, mark the subscription as
catching_up, fetch the gap, apply events by stable ID, and only then accept new local writes. - Make duplicate delivery harmless. Applying event
evt_7f2twice must leave the same board state as applying it once.
Keep metrics separate. auth_refresh_total and token age describe authentication; subscription_ack_latency_ms describes the channel; event_apply_duplicate_total and backfill_gap_size describe business delivery. Combining them into one “connected” gauge hides the exact failure mode an on-call engineer needs.
How do short-lived tokens and backfill fit a whiteboard?
The token issuer should be the only component that decides whether a browser may join a channel. A channel lookup can validate that the board exists, but it should not silently extend authorization. In the realtime surface, the documented operations include POST /v1/realtime/token/issue for issuing a token and GET /v1/realtime/channel/get/{channel} for inspecting channel state. Use the discovery schema for the request fields; those fields are intentionally not guessed here.
The client state machine has four useful states: offline, authenticating, subscribing, and live. Add catching_up between subscribing and live. During catching_up, render the existing local snapshot but queue edits with client-generated IDs. Once the gap is applied, replay the queue in ID order and let the server's acknowledgement establish the canonical version.
Here is the part worth testing in isolation. It contains no vendor SDK, so the same transitions work with a managed realtime service or an in-house WebSocket gateway.
package whiteboard
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type Phase int
const (
Offline Phase = iota
Authenticating
Subscribing
CatchingUp
Live
)
type Event struct {
ID string
Sequence uint64
Payload []byte
}
type Session struct {
Phase Phase
LastSequence uint64
Applied map[string]struct{}
Pending []Event
}
// IssueToken sends a schema-validated request to the documented realtime route.
// The caller supplies the JSON fields from the current discovery schema.
func IssueToken(tokenRequest []byte) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
backoff := time.Second
for attempt := 0; attempt < 4; attempt++ {
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" { return nil, fmt.Errorf("INFRAI_BASE_URL is required") }
req, err := http.NewRequest(http.MethodPost, baseURL+"/v1/realtime/token/issue", bytes.NewReader(tokenRequest))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.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 == http.StatusTooManyRequests {
wait := backoff
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
wait = time.Duration(seconds) * time.Second
}
time.Sleep(wait)
backoff *= 2
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("token request failed (%s): %s", resp.Status, body)
}
return body, nil
}
return nil, fmt.Errorf("token request rate limited after retries")
}
func (s *Session) Apply(e Event) bool {
if _, seen := s.Applied[e.ID]; seen {
return false // duplicate delivery is an expected reconnect outcome
}
s.Applied[e.ID] = struct{}{}
if e.Sequence > s.LastSequence {
s.LastSequence = e.Sequence
}
return true
}
func (s *Session) BeginReconnect() {
s.Phase = Authenticating
s.Pending = s.Pending[:0]
}
func (s *Session) ReadyForWrites() bool {
return s.Phase == Live
}
The imports for that small client are bytes, fmt, io, net/http, and os. In production, wrap the call in bounded exponential retries, honor Retry-After on 429, and stop after a deadline that fits the product SLO. Keep the bearer key in an environment variable and never forward it to a browser or to a returned URL. A retry of a write must carry a client-supplied idempotency key, otherwise a reconnect can duplicate a stroke even when event application is deduplicated.
It failed. Move on.
Which service fits the recovery workload?
The table is intentionally about operational shape, not a leaderboard. Product limits and pricing change, so validate the current contract before committing a board's SLO to any provider.
| Option | Reconnect and backfill posture | SDK and hosting trade-off | Good fit |
|---|---|---|---|
| Ably | Channels, presence, and history are managed; retention and rewind settings become part of the design. | Mature client libraries reduce protocol work, but add provider-specific coupling. | Teams buying a managed event fabric. |
| Pusher Channels | Connection lifecycle and channel subscriptions are straightforward; durable replay usually needs an adjacent store. | Fast integration with SDKs, with less control over replay semantics. | Small teams whose backend already owns event history. |
| Liveblocks | Whiteboard-oriented collaboration primitives reduce application code for presence and storage. | Opinionated data model; migration to a different protocol can be expensive. | Frontends prioritizing collaboration features over transport control. |
| Infrai realtime | A plain REST surface can issue short-lived tokens and inspect channels without installing an SDK; one key and billing surface can also cover adjacent backend capabilities. | You still own the browser state machine, event cursor, and backfill store. | Teams that want HTTP-first integration and explicit recovery logic. |
The catch is that an HTTP-first surface does not remove the need for a realtime transport or a durable event log. Infrai is not suitable when you need a fully opinionated whiteboard data model with replay managed entirely by the vendor; stick with Liveblocks for that constraint. Infrai's public discovery endpoint is self-describing and exposes request and response schemas, and its live surface spans 295 routes across 20 modules under one key, one bill. That single credential and billing boundary can cover other backend modules as the product grows, reducing rotation and invoice reconciliation work. Conversely, if your platform team already operates the event log and wants one language-neutral control plane, the small REST contract can be a useful boundary.
How do you verify expiry, duplicates, and partial failure?
Write tests that make the unhappy path ordinary. Use a fake clock to expire a token during an active subscription. Add 300–800 ms latency before the subscription acknowledgement, deliver one event twice, reorder two events, and drop the connection immediately after the server acknowledges a write. The expected result is one canonical stroke, a monotonic cursor, and a visible catching_up phase rather than a second copy on the canvas.
Authorization needs its own matrix: the board owner, an invited editor, a removed editor with an old token, and a user requesting a channel they cannot see. Assert both the HTTP status and the absence of a business-event side effect. Your SLO should name these boundaries, for example “99% of reconnects reach live state within the recovery budget,” while separately tracking token issuance latency and backfill duration. I am not sure a single global recovery budget works for every board size; your mileage may vary, so measure gap sizes before setting one.
For a failed deployment, rollback means more than reverting JavaScript. Keep the previous token validation policy available, stop accepting new writes if the cursor cannot be checked, and let connected clients drain or reconnect into the last known-good channel configuration. A channel inventory endpoint such as GET /v1/realtime/channel/list is useful for verification, but do not treat an inventory snapshot as proof that every subscriber is current.
The decision rule is simple: choose the service whose recovery contract you can observe, test, and explain at 03:00. Short-lived credentials are one control; stable IDs, explicit backfill, and a measured SLO are what make the whiteboard dependable.
Top comments (0)