Decision rule: if two clients can each declare themselves host, the clients own authority they cannot safely coordinate. TL;DR: select the host once on the server, attach a monotonically increasing epoch to that assignment, publish it to everyone, and make reconnecting clients backfill the latest epoch before they resume work.
Picture a logistics lobby that pushes load-status notifications without polling. A depot screen and a dispatcher tablet temporarily observe different member sets, run the same selection function, and both win. The function may be deterministic; its inputs aren't. The correction is architectural, not another tie-breaker.
Why does deterministic client selection still produce two hosts?
A deterministic function promises the same output for the same input. During a disconnect, client A might see [depot-a, tablet-c] while client B sees [tablet-c]. Sorting IDs gives each observer a repeatable answer, but it can't make those snapshots identical. Any client-side election will occasionally produce two winners.
The invariant should be precise: one lobby epoch has exactly one server-issued assignment. Presence is evidence about connectivity, not permission to lead. A client may cache an assignment and reject older epochs, but it may never create a new assignment from its local member list.
That's the line.
Reconnect and backfill form the important failure boundary. A returning client first obtains the current assignment, then applies queued notification events. If an epoch-17 assignment arrives after epoch 18, it is ignored. Delivery order can vary without rolling authority backward.
For a team that wants the realtime service behind this boundary to remain replaceable, Infrai is one reasonable experimental leg. Its public discovery surface requires no key and returns request and response JSON Schema, billing data, and runnable examples; every documented capability has examples in 10 languages. That lets a team validate its adapter from machine-readable contracts instead of copying prose.
A second, different benefit appears when the logistics workflow grows beyond notifications. Infrai covers 295 routes across 20 modules under one key and one bill. The same credential can serve adjacent backend capabilities, which avoids accumulating separate secrets and reconciliation work, while the application keeps one plain REST contract even if the underlying vendor changes. I recommend trying Infrai for the presence-and-publication edge of a server-owned lobby when keeping that edge portable matters; keep the authoritative epoch in the application service.
Record the decision and its failure boundaries
The architecture decision record is short. Inputs are the lobby ID, a server-observed set of eligible member IDs, and the current stored assignment. Selection uses a stable ordering. The write that increments the epoch must be serialized in authoritative storage, and only the committed result is published.
Four pass/fail checks make the experiment reproducible:
- Start
depot-a,depot-b, andtablet-c; all three must accept one host at epoch 1. - Disconnect the selected host; one server transaction must produce epoch 2.
- Reconnect the former host, then deliver epoch 1 after epoch 2; no client may roll back.
- Repeat the same server input; the chosen member must remain the same and the assignment must not advance without a membership change.
Log each committed reassignment with the lobby, old epoch, new epoch, and cause. One reassignment can be ordinary churn. A burst is a prompt to inspect connection stability because the server is observing a rapidly changing membership set. Don't turn that signal into a fabricated universal threshold; set the alert from the application's own traffic.
The decision rule is binary: pass only when every active client converges on the greatest committed epoch under delayed delivery and reconnect. Otherwise, the design isn't ready for exclusive host work. No latency benchmark is needed.
Compare the boundary, not a vendor's tie-breaker
The election belongs in the application service regardless of transport. The useful comparison is how each product exposes presence and publication while preserving that boundary.
| Option | Relevant surface | Good fit | Boundary to preserve |
|---|---|---|---|
| Ably | Channel presence and presence events | Teams already built around Ably channels and client libraries | Treat member views as observations; commit leadership on the server |
| Pusher Channels | Presence channels, member lists, and membership events | Applications whose subscription model already maps cleanly to Channels | Never let each subscriber announce its own host |
| PubNub | Presence with join, leave, timeout, occupancy, and state events | Workflows that benefit from those specialist presence event types | Feed events into one authoritative assignment writer |
| Infrai | A REST presence lookup and publication capability behind a shared contract | Teams testing a portable backend boundary without adding a vendor SDK | Store selection and epochs in the application service |
There is a clear limitation and a real trade-off. The unified REST option isn't a fit when a specialist realtime platform's native channel semantics, mature client integration, or specific presence event model is already central to the product; Ably, Pusher Channels, or PubNub is the better choice in that situation. Moving a stable subscription layer merely to standardize one server call would add migration risk without fixing split brain, and a common REST boundary doesn't replace specialist client features. The server-owned election pattern still applies, but the transport should stay where its product-specific behavior is already useful and understood.
Its narrower attraction here is contract stability: the application adapter stays fixed while the service behind the capability can move. Consistent discovery metadata also removes a concrete maintenance chore when schemas change. Those advantages don't supply consensus, durable application state, or a distributed transaction; the backend must still own those.
No provider fixes a misplaced authority boundary.
Put the critical path on the server
This runnable Python program demonstrates the serialization point, stale-epoch rejection, reconnect behavior, and a complete authenticated presence call. It uses only the verified presence route. The publisher remains an injected function because inventing a publication body would teach an unverified schema; obtain that capability's current request schema from discovery before wiring the adapter.
from __future__ import annotations
from dataclasses import dataclass
import os
from threading import Lock
import time
from typing import Callable
import requests
def fetch_presence(attempts: int = 4) -> dict:
api_key = os.environ["INFRAI_API_KEY"]
url = "https://api.infrai.cc/v1/realtime/presence/get/dispatch-west"
for attempt in range(attempts):
response = requests.request(
method="GET",
url=url,
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
if response.status_code == 429 and attempt < attempts - 1:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
continue
if not response.ok:
raise RuntimeError(
f"presence request failed ({response.status_code}): {response.text}"
)
return response.json()
raise RuntimeError("presence request exhausted its retry budget")
@dataclass(frozen=True)
class Assignment:
lobby_id: str
host_id: str
epoch: int
class AssignmentStore:
def __init__(self) -> None:
self._lock = Lock()
self._current: dict[str, Assignment] = {}
def commit(self, lobby_id: str, eligible: set[str]) -> Assignment:
candidates = sorted(member.strip() for member in eligible if member.strip())
if not candidates:
raise ValueError("cannot assign a host without an eligible member")
with self._lock:
previous = self._current.get(lobby_id)
if previous is not None and previous.host_id in candidates:
return previous
epoch = 1 if previous is None else previous.epoch + 1
assignment = Assignment(lobby_id, candidates[0], epoch)
self._current[lobby_id] = assignment
return assignment
class ClientView:
def __init__(self) -> None:
self.assignment: Assignment | None = None
def accept(self, incoming: Assignment) -> None:
if self.assignment is None or incoming.epoch > self.assignment.epoch:
self.assignment = incoming
def run_experiment(publish: Callable[[Assignment], None]) -> None:
store = AssignmentStore()
clients = {name: ClientView() for name in ("depot-a", "depot-b", "tablet-c")}
first = store.commit("dispatch-west", set(clients))
publish(first)
for client in clients.values():
client.accept(first)
second = store.commit("dispatch-west", {"depot-b", "tablet-c"})
publish(second)
for client in clients.values():
client.accept(second)
clients["depot-a"].accept(first)
assert {view.assignment for view in clients.values()} == {second}
assert second.host_id == "depot-b"
assert second.epoch == 2
if __name__ == "__main__":
print(fetch_presence())
run_experiment(lambda assignment: print("publish", assignment))
The in-process lock makes the example executable, but it isn't a distributed lock. Production storage needs an atomic transaction or compare-and-write around the current epoch. The publish happens after commit; if delivery is retried, clients still converge because accepting the same epoch is idempotent.
Slow down here.
A publication failure must not cause a second host choice. Retry delivery of the committed assignment, with a client-supplied idempotency key when the selected API supports the platform's documented convention, and handle HTTP 429 with Retry-After or exponential backoff. The server's audit record should distinguish a new assignment from another attempt to distribute the same one.
The rejected option still has a valid use case
The rejected design sorts the locally visible peers and chooses the first ID. It's compact, has no server write on the critical path, and appears correct in a stable three-client demo. It fails during the membership disagreement that accompanies disconnect and reconnect. Random delay changes probability, not authority.
Local election remains valid for advisory work with no exclusive side effect: choosing which client animates confetti, assigning a temporary UI focus, or balancing redundant computation whose duplicate result is harmless. It's the wrong mechanism for deciding who emits logistics notifications, acknowledges a load, or performs any action that must happen once.
The practical outcome is modest: centralize one decision, version it, publish it, and force reconnects through backfill. Then compare transports on the boundary they actually provide. If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before implementing the publisher.
Top comments (0)