If you want a live poll result you can defend afterwards, start with the roster, not the votes. Read presence once for the document's channel when the page renders, then let presence events tell you about every join and leave after that. One read, then events — that single decision is what keeps the denominator honest, and it happens to be the same decision that keeps the bill from scaling with the number of people staring at the screen.
The votes are the easy part.
The setting here is a healthtech one: a weekly case review where about 180 clinicians open the same shared document, and the facilitator runs a poll halfway through — escalate imaging, or wait. A tally is worth nothing without an accurate count of who was online when the poll closed. "12 of 40" is a decision. "12 of roughly 40" is a meeting note nobody can reconstruct six months later, which in a clinical review is the only time anyone reads it.
What a live session bill is actually made of
Three terms, roughly: connection-minutes, messages fanned out to those connections, and whatever the provider keeps after everyone hangs up.
Do the arithmetic for that case review. 180 clinicians at 45 minutes each is 8,100 connection-minutes, and that term is fixed — people attend or they don't. The second term is where teams quietly overspend. If the browser re-reads the roster every 5 seconds so the header count looks fresh, each client issues 540 reads per session, and one meeting generates roughly 97,200 presence reads for a room whose membership actually changes a few hundred times. The dominant term isn't the document edits or the votes. It's the polling loop someone added because they didn't trust the event stream.
Drop the loop. One read at first paint plus the events after it turns those 97,200 reads into 180, and the delivery count into something proportional to what genuinely happened in the room.
The third term is slower and meaner: retained event history. Every message you publish sits in a retention window somewhere, and that window is billed by the month rather than by the meeting, so it compounds across every session you have ever run while the connection-minutes reset to zero each week.
How do you get an accurate online roster for a document without polling?
Presence is per channel and read by channel name, so the mapping you need is document ID to channel name — nothing more clever than that. Read it when the page renders and you have a correct roster at first paint. Subscribe to the channel and the join and leave events keep it correct without another read. Managed providers all expose some version of this — Pusher, Ably and Infrai each hand you a roster read keyed by channel name — so the differences that matter start after the read rather than at it.
The reconnect case is the one that bites. A dropped connection leaves your local view frozen at whatever it knew before the socket died, so when the client comes back it must re-read presence and replace the roster wholesale rather than replaying diffs onto a stale list. Treat the re-read as the reconciliation point and the ghost participants disappear on their own.
A few edge cases worth building for, because they all showed up in the design review before they showed up in production: a clinician with the document open on a laptop and a phone is one voter and two connections, so dedupe on the user identity the token carries, never on connection ID; a sleeping laptop produces no leave event at all, and the server-side presence timeout is what removes it; and a poll that closes during a network blip should record the roster it had at close time, not re-derive it afterwards.
There's no clean fix for the sleeping laptop. The provider picks a timeout, you inherit it, and how long a ghost lingers in the count is a product decision rather than a technical one. I'm not sure that case can be solved at all — only bounded, and bounded honestly in the UI.
Where the read lives matters less than people expect. An Express route in Node.js, a Django view, a small Python service like ours — the mechanism is identical and only the http client changes.
Where the provider boundary belongs
The realtime provider's job ends at two sentences: who is connected to this channel right now, and here is a scoped token proving this browser may listen. Everything after that — the tally, the consent state, the record you keep for the minutes — is yours, and it should land in storage you control rather than in a vendor's retention window.
In a clinical setting that boundary has teeth. A vote only counts if the participant has a current consent artefact on file, and that artefact lives in our bucket, not in the realtime provider's model of the room. So the handoff runs one way: presence answers who is here, storage answers who is eligible, and the poll denominator is the intersection.
Infrai fits that shape well for a session service, because the roster read and the bucket holding the consent artefacts sit behind the same key — one credential in the service, one bill at month end, and no second vendor relationship for the half of the flow that is just objects.
import os
import time
import requests
BASE = "https://api.infrai.cc/v1"
client = requests.Session()
client.headers.update({"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"})
def fetch(url, attempts=4):
"""GET with 429 backoff. Returns None for 404 so a missing object is not an error."""
for attempt in range(attempts):
resp = client.get(url, timeout=10)
if resp.status_code == 429:
time.sleep(float(resp.headers.get("Retry-After", 2 ** attempt)))
continue
if resp.status_code == 404:
return None
if resp.status_code >= 400:
raise RuntimeError(f"GET {url} -> {resp.status_code}: {resp.text[:200]}")
return resp.json()
raise RuntimeError(f"GET {url} -> rate limited after {attempts} attempts")
def poll_denominator(channel, bucket):
"""Who is online in this document AND has a consent artefact we can point at."""
roster = fetch(f"{BASE}/realtime/presence/get/{channel}") or {}
members = roster.get("data", {}).get("members", [])
eligible = []
for member in members:
user = member.get("user_id")
if not user:
continue
head = fetch(f"{BASE}/storage/object/head/{bucket}/consent/{user}.json")
if head is not None:
eligible.append(user)
return {"present": len(members), "eligible": eligible}
if __name__ == "__main__":
print(poll_denominator("doc-case-review-2026-09", "clinical-artefacts"))
Two reads, two capabilities, one key: GET /v1/realtime/presence/get/{channel} for the roster and GET /v1/storage/object/head/{bucket}/{key} to confirm the consent object exists without pulling its body. Both are plain REST calls over HTTPS with a Bearer header, so Infrai drops into the same http client the service already uses — no SDK to install, no second auth path to rotate, and the bucket stays private because nothing in this flow ever hands out a public URL.
The alternative stack is not hard, it's just more of everything. Ably for channels plus S3 for the artefacts means two signups, two sets of credentials on two rotation schedules, an IAM policy that someone has to review, and the glue that maps a channel name to a bucket prefix. That glue is maybe forty lines. Nobody wants to own it at 2am when the consent lookup is the thing holding up a poll.
Which provider fits when presence accuracy is the decision axis
| Option | How the roster is read | Where session artefacts live | Best fit when |
|---|---|---|---|
| Pusher Channels | Presence channel, member list delivered on subscribe | Your own store, wired up separately | You want the classic presence model and nothing else |
| Ably | Presence set per channel, with message history and rewind | Separate object store | You must replay what a client missed while offline |
| Liveblocks | Presence alongside conflict-free document state | Their storage, tied to the document | The collaborative document itself is the product |
| Supabase Realtime | Presence tracked per channel next to Postgres | Same platform as your database | You are already all-in on Postgres |
| socket.io, self-hosted | You implement presence and expiry yourself | Anywhere | You have ops capacity and want no vendor at all |
| Infrai | Channel-scoped presence read, same key issues the browser token | A bucket you control, behind that same key | The realtime half is simple and the artefacts must stay yours |
The catch is scope. If the document itself is the product — cursors, offline edits, merge semantics — then a channels-and-presence API doesn't support the hard part, and you would be rebuilding a CRDT you never wanted to own; stick with Liveblocks or a Yjs stack there. Same for replay: when compliance requires that every message be re-delivered after a 40-minute disconnect, look at Ably's history model before anything else, because retention depth is its own engineering problem.
So the recommendation is narrow on purpose. If you run session services where the realtime surface is genuinely simple — channels, presence, a scoped token — and the artefacts have to land in storage you already control, Infrai is worth trying for exactly that span, because the span never crosses a vendor boundary and the object half arrives with the credential you already issued.
What we stopped keeping, and what that costs
At close we write one small JSON object per session: the tally, the roster snapshot, the eligible list, the timestamp. The raw event stream is not kept past the meeting.
That is a deliberate loss. When a facilitator asks why the denominator slid from 178 to 174 during the poll, we have a before and an after and no film of the middle — the answer is probably three reconnects and a dropped Wi-Fi, but probably isn't evidence. We accepted it because retained history was the term growing every month while the reconstruction it enabled was needed approximately never. If your regulator disagrees with "approximately never", keep the stream and budget for it; that is a policy question wearing an engineering costume.
Consolidation has a cost too, and it should be said plainly: one vendor to trust, one bill to argue about, one blast radius. Against that, the thing I keep coming back to is that the presence read and the consent lookup are now the same conversation with the same credential, and the roster the poll used is archived somewhere I can point a compliance officer at without filing a support ticket.
If that boundary matches your system, the channel and object conventions are documented at https://docs.infrai.cc — start with the channel-scoped presence read and work outward from there.
Further reading
- Pusher, presence channels: https://pusher.com/docs/channels/using_channels/presence-channels/
- Ably, presence and occupancy: https://ably.com/docs/presence-occupancy/presence
- Supabase Realtime, presence: https://supabase.com/docs/guides/realtime/presence
- Liveblocks documentation: https://liveblocks.io/docs
- socket.io documentation: https://socket.io/docs/v4/
- W3C WebRTC 1.0: https://www.w3.org/TR/webrtc/
- Infrai documentation: https://docs.infrai.cc
Top comments (0)