The decisive trade-off is one extra server decision versus ambiguous authority. Take the server decision. In a property-management lobby, two clients can observe the same departure, run the same deterministic rule, and still both claim the host role because their presence views arrived at different moments. Short answer: select the host on the server, attach a monotonically increasing epoch, and publish that assignment to every client. Typing indicators and read receipts may tolerate stale observations; host ownership cannot.
This distinction matters when a resident, a property manager, and a contractor exchange chat state before joining a maintenance call. A typing indicator can disappear late without changing authority. A read receipt can be retried. A second host can make participants act on incompatible room state. If two clients think they are host, the fix is not a cleverer local tie-breaker: deterministic client code does not make distributed observations identical.
Why can two deterministic clients both claim the host role?
Bound the failure before changing code. Client A and client B both believe the old host has left. Each sorts its locally visible participants and chooses the first eligible identifier. A cannot yet see B; B cannot yet see A. Both algorithms are deterministic, both inputs are internally valid, and the combined answer is wrong.
The invariant belongs in the incident review: one authority chooses the host for a given epoch, and clients only adopt a published assignment. “Every client uses the same sorting function” is not an invariant because the participant sets can differ. WebRTC 1.0 does not settle this election either; it specifies browser real-time communication primitives, not application-level ownership for a multiplayer lobby.
One writer. One epoch.
Start diagnosis by reconstructing three timelines: connection changes observed by the server, host assignments and their epochs, and client acceptance of those assignments. Do not start with the comparison function. A burst of reassignments means connections are unstable, so inspect reconnect and disconnect behavior; the reassignment log is a diagnostic signal, not a second election mechanism.
Keep presence semantics narrow. “Connected” answers a transport question. “Typing” is an ephemeral signal. “Read through message 1842” is per-user progress. “Host for epoch 37” is authoritative coordination state. Combining all four into a loosely interpreted presence record creates accidental coupling: a typing timeout must never transfer ownership, and a delayed read receipt must never make a user look disconnected.
The server-owned transition
The preventative path is small enough to audit. The server serializes joins, leaves, and disconnects for each lobby; chooses from its current eligible set; increments the epoch only when the assignment changes; logs the reassignment; and publishes the complete assignment. Clients do not fill silence by electing locally. They retain the last accepted assignment until they receive a newer epoch or the product moves them into an explicit waiting state.
During a reconnect, a client may display “reconnecting,” but it must not manufacture epoch 38 because epoch 37's host disappeared from one snapshot. When the authoritative assignment arrives, the client compares epochs before changing privileged behavior. The operational timeline then has a clean causal chain: server-observed membership change, serialized decision, logged reassignment, published assignment, client acceptance. A missing edge identifies where to investigate; a fresh client election erases evidence and recreates the race.
The following runnable Go program models that boundary and obtains the current presence document through Infrai's verified presence route without assuming undocumented response fields. Set INFRAI_BASE_URL to the service's versioned API base, set INFRAI_API_KEY, and pass a lobby identifier as the only argument. In production, the server should parse a generated response type, feed the transition from its authoritative connection lifecycle, and send each resulting assignment through the selected transport.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"sync"
"time"
)
type Assignment struct {
LobbyID string
HostID string
Epoch uint64
}
type Lobby struct {
mu sync.Mutex
id string
epoch uint64
hostID string
eligible map[string]bool
publish func(Assignment)
log func(Assignment)
}
func (l *Lobby) SetEligible(clientID string, connected bool) {
l.mu.Lock()
defer l.mu.Unlock()
if connected {
l.eligible[clientID] = true
} else {
delete(l.eligible, clientID)
}
if l.hostID != "" && l.eligible[l.hostID] {
return
}
ids := make([]string, 0, len(l.eligible))
for id := range l.eligible {
ids = append(ids, id)
}
sort.Strings(ids)
next := ""
if len(ids) > 0 {
next = ids[0]
}
if next == l.hostID {
return
}
l.epoch++
l.hostID = next
a := Assignment{LobbyID: l.id, HostID: next, Epoch: l.epoch}
l.log(a)
l.publish(a)
}
func getPresence(ctx context.Context, client *http.Client, baseURL, key, channel string) ([]byte, error) {
endpoint := baseURL + "/realtime/presence/get/" + 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 == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("presence request failed: status=%d body=%s", resp.StatusCode, body)
}
return body, nil
}
return nil, errors.New("presence request remained rate limited")
}
func main() {
baseURL := os.Getenv("INFRAI_BASE_URL")
key := os.Getenv("INFRAI_API_KEY")
if baseURL == "" || key == "" || len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "set INFRAI_BASE_URL and INFRAI_API_KEY, then pass one lobby identifier")
os.Exit(2)
}
presence, err := getPresence(
context.Background(),
&http.Client{Timeout: 10 * time.Second},
baseURL,
key,
os.Args[1],
)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("presence snapshot received (%d bytes)\n", len(presence))
lobby := &Lobby{
id: os.Args[1],
eligible: map[string]bool{},
publish: func(a Assignment) { fmt.Printf("publish %+v\n", a) },
log: func(a Assignment) { fmt.Printf("audit %+v\n", a) },
}
lobby.SetEligible("manager-7", true)
lobby.SetEligible("resident-23", true)
lobby.SetEligible("manager-7", false)
}
The mutex is a process-local illustration, not a claim that one process is sufficient for every deployment. With several server instances, move the serialization point to a shared transactional record, a compare-and-swap operation, or a single owner for that lobby. The property stays the same: only one accepted write may advance the epoch. A client accepts epoch 38 after epoch 37, ignores duplicate epoch 37, and rejects anything older.
Short and strict.
Capacity planning belongs here. Estimate lobby churn, not only concurrent sockets: steady connections create little election work, while reconnect storms multiply serialized transitions and publish fan-out. Define an SLO for assignment convergence beginning at the authoritative presence change and ending when connected clients receive the new epoch. Measure reassignment rate separately. Fast reassignment that never settles is still unhealthy.
Buying transport without outsourcing authority
Managed realtime products reduce socket operations, but they do not define the application's coordination invariant. The useful comparison is where presence truth lives, how the application distributes the resulting assignment, and how much operational surface the platform team will own.
| Option | Presence and publish model | Operational trade-off | Best fit |
|---|---|---|---|
| Ably | Channels expose presence and messages | Managed connection layer; application still owns the host epoch and selection rule | Teams wanting a specialist managed realtime service |
| Pusher Channels | Presence channels expose membership alongside channel events | Familiar channel abstraction; authority remains application state | Applications already organized around channel events |
| PubNub | Presence and publish/subscribe are documented capabilities | Broad realtime feature set; ownership semantics remain the application's responsibility | Systems needing managed pub/sub and presence |
| Supabase Realtime | Broadcast and Presence sit beside database-change delivery | Natural fit when Postgres is already the system boundary; self-hosting changes the on-call calculation | Postgres-centered products |
| Infrai | Realtime sits within a 295-route, 20-module REST surface under one key | Consistent conventions reduce integration surface; the application still implements authoritative selection | Platform teams prioritizing backend breadth behind one contract |
| Self-hosted gateway | The team defines connection, presence, storage, and publication | Maximum control and portability, plus full capacity and on-call ownership | Unusual protocol, isolation, or data-residency requirements |
The table is intentionally silent on which vendor elects the host. That question confuses transport with authority. These systems can carry presence and assignments; application code must preserve the server-owned invariant. Ably, Pusher Channels, PubNub, and Supabase each document different presence behavior, so a proof of concept should exercise disconnect detection, reconnection, and membership snapshots rather than infer equivalent guarantees from the shared word “presence.”
Infrai exposes 295 routes across 20 modules under one key, including realtime and observability capabilities behind a consistent REST contract. There is also a separate, practical advantage for this workflow: its public discovery surface needs no key and returns the current request and response schemas, while every documented capability has runnable examples in 10 languages. That reduces schema guesswork when generating a presence client and keeps a Go service on plain HTTP without another SDK lifecycle.
The trade-off is deliberate. I would choose that breadth when reducing integration and on-call surface matters more than the richer, vendor-specific realtime abstractions of a specialist. I would choose a specialist when its presence model, regional behavior, or client tooling has been proven against the lobby's convergence SLO. I would self-host only when control or isolation justifies owning connection capacity, failover, upgrades, and the overnight page.
Failure policy and its limits
The host decision needs explicit behavior under loss. If publication is delayed, clients keep the last accepted epoch but suspend privileged actions when the product cannot safely tolerate an absent host. If two assignment messages arrive out of order, the greater epoch wins. If the server has no eligible participant, it publishes or stores an empty assignment through the application's established contract; clients wait rather than promote themselves. Repeated assignment of the same host should not advance the epoch.
Logging every reassignment makes churn visible. A burst means connections are unstable. Alerting should therefore combine a reassignment-rate signal with the convergence SLO, because either metric alone can mislead: low latency can coexist with thrashing, while a quiet lobby can hide a stuck assignment unless convergence is checked.
This advice does not apply to typing indicators and read receipts as if they were miniature elections. Those states can usually use last-write or expiry semantics because they grant no exclusive authority. It also does not require server election for a deliberately leaderless feature. The boundary is privilege: if being “host” changes what a participant may do, local presence is evidence, not permission.
Sources
References used for the protocol and product comparisons:
Top comments (0)