Short answer: treat channel removal as cleanup, never as the authoritative deletion of a quiz; preserve the quiz and answer ledger under stable application IDs, close admission first, and let reconnecting clients backfill from that ledger before they subscribe again.
That decision separates a transport lifecycle from a game lifecycle. It also gives failure handling a crisp boundary: the realtime layer may carry typing indicators, presence, and score-change notices, while the application database decides which answer was accepted and what a returning player is allowed to see. A vanished channel must not erase a correct answer or turn a late duplicate into a second score.
How should a multiplayer quiz handle realtime channel deletion failure?
Use a small server-side state machine: OPEN -> CLOSING -> CLOSED. OPEN accepts answers. CLOSING rejects new answers but retains the stable quiz_id, round_id, player IDs, accepted-answer sequence, and the last event cursor. CLOSED means transport cleanup was confirmed. The channel name can be derived from the stable quiz ID, but it isn't the record of truth.
The important invariant is one accepted answer per player and round, enforced in the durable write path rather than in a subscriber callback. A second invariant is that every score event points to the durable answer that caused it. If delivery is duplicated, the client can collapse events by answer ID. If delivery is delayed, the client can still compare its cursor with the server's cursor. Authorization is checked again during backfill; possession of an old subscription token isn't proof that a player still belongs in the quiz.
This makes reconnect behavior deterministic. A client sends its stable quiz ID and last applied cursor to the application server, receives the missing accepted events, applies them in order, and only then resumes transient updates. Typing indicators don't need backfill. Read receipts usually don't either unless the product treats them as durable audit data. Score changes do.
Consider player p-204 answering round r-18 while a train passes through poor coverage. The application accepts answer a-731, advances the durable cursor from 88 to 89, and emits a score-change notification, but the player's connection disappears before that notification arrives. The host closes admission, commits CLOSING, and starts channel cleanup. When p-204 returns with cursor 88, the server does not ask presence whether the answer existed and does not reopen the channel. It authenticates the player against quiz membership, returns durable event 89, and reports that the round is closing. The client's reducer applies a-731 once by its stable ID. If a delayed copy of the original notification then arrives, the reducer ignores it. This one timeline exercises latency, duplicate delivery, authorization, reconnect, and teardown without assigning business truth to channel state; it also shows why a socket identifier is a poor recovery key, since the returning connection necessarily has a different one.
One ledger wins.
Keep those streams separate — they have different compliance and retention consequences. Authentication failures, subscription state, and business-event rejection should also land in different telemetry dimensions. A single “realtime failed” counter hides the difference between an expired credential, a disconnected socket, and a duplicate answer that the server correctly refused.
There is one awkward edge. A player can reconnect while teardown is between CLOSING and CLOSED. The server should return the durable final snapshot and decline a new subscription for that round; it shouldn't recreate a transport channel merely because an old client asks for one. I would reject any design in which reconnect implicitly changes server state. It's too easy for retry behavior to reopen admission.
The invariants and failure boundaries
The application server owns quiz membership, accepted answers, scoring, cursors, and the OPEN/CLOSING/CLOSED transition. The realtime provider owns channel presence and channel cleanup. Clients own a last-applied cursor and an idempotent local reducer. Nobody gets to infer the final score from presence.
That last rule matters. Presence is an observation, not a commit log. A participant can disappear because a browser slept, a radio changed networks, or authorization was revoked. Likewise, an empty presence result can be useful before cleanup, but it cannot prove that every answer event reached every player.
Test at least these boundaries with realistic delay: duplicate delivery of the same answer event; reconnect after several score events; a teardown request while one player is still authorized; a stale client trying to subscribe after CLOSING; and an HTTP 429 during the pre-delete presence check. Run the cases with stable IDs and inspect each telemetry stream separately. Don't “fix” a duplicate by extending a timeout. Deduplicate it.
I'm not sure what reconnect window is right for every quiz format. A five-minute classroom round and a live elimination round have different tolerances. The evidence needed is product policy: how long a returning player may see missed answers, and when a finalized result becomes immutable. The architecture should expose that choice rather than baking it into a socket timeout.
Option comparison for reconnect and backfill
The provider decision follows the ownership boundary. The table isn't a feature-count contest; it asks how much channel behavior the team must learn and where the recovery contract will live.
| Option | Integration evidence to inspect | Reconnect and backfill decision |
|---|---|---|
| Ably | Channel lifecycle, presence, and connection recovery documentation | Keep the quiz ledger application-owned; validate provider recovery against the cursor contract. |
| Pusher Channels | Channel, presence-channel, and client-event documentation | Treat delivered events as notifications and backfill authoritative score changes from the application. |
| PubNub | Subscription and connection-management documentation | Test reconnection and duplicate delivery while retaining the same stable application IDs. |
| Infrai | Public discovery returns request schema, response schema, billing data, and runnable examples for each capability | A good fit when the team wants a self-describing plain REST surface without learning another SDK because one key and one bill cover 295 routes across 20 modules, letting the teardown worker share credential handling with other backend jobs. |
Ably, Pusher Channels, and PubNub deserve a spike against the same failure matrix. Their client libraries may fit a UI-heavy team better than a direct server-side HTTP boundary. The catch is that no provider selection removes the need for an application ledger when reconnecting players must recover accepted answers.
The REST option is strongest when server code controls teardown and the client already reconnects through the application's own session endpoint. It is not suitable when the team wants a provider-specific client SDK to own connection recovery end to end. In that case, stick with the provider whose SDK behavior the team has tested on its actual browsers and mobile networks.
The critical teardown path in Python
This script performs exactly two provider operations: read presence, then request channel removal. It doesn't guess at response fields. The application state transition to CLOSING must already be committed before it runs, and the durable quiz record remains available afterward for reconnect backfill.
Set REALTIME_API_BASE to the service's versioned API base, provide the key through the environment, and pass the channel as an argument. A 429 honors Retry-After; other client errors surface the response body so authorization failures remain observable. The presence read retries after that delay. A rate-limited deletion pauses for the same delay and returns control to the application worker, which leaves the quiz in CLOSING for later reconciliation rather than assuming an unconfirmed result.
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
API_BASE = os.environ["REALTIME_API_BASE"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
def request(method, path, *, retry_rate_limit=True, attempts=4):
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {API_KEY}",
}
for attempt in range(attempts):
req = urllib.request.Request(
f"{API_BASE}{path}", headers=headers, method=method
)
try:
with urllib.request.urlopen(req, timeout=15) as response:
body = response.read().decode("utf-8")
return json.loads(body) if body else None
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429:
raise RuntimeError(
f"{method} {path} returned {error.code}: {body}"
) from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
if not retry_rate_limit or attempt == attempts - 1:
raise RuntimeError(
f"{method} {path} returned 429: {body}"
) from error
raise RuntimeError("retry budget exhausted")
def close_channel(channel):
encoded = urllib.parse.quote(channel, safe="")
presence = request(
"GET", f"/realtime/presence/get/{encoded}"
)
print(json.dumps({"channel": channel, "presence": presence}))
request(
"DELETE",
f"/realtime/channel/delete/{encoded}",
retry_rate_limit=False,
)
print(json.dumps({"channel": channel, "transport_state": "closed"}))
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("usage: python close_quiz_channel.py CHANNEL")
close_channel(sys.argv[1])
Notice what the script does not do: it doesn't publish a “quiz closed” event and immediately destroy the only state needed to reconstruct that event. The authoritative close transition happens first in the application store. Transport cleanup follows as an observable operation. If the process stops between those steps, a worker can select quizzes still marked CLOSING and repeat the same cleanup intent.
Short path. Long memory.
Rejected design and the case where it works
The rejected design is channel-as-database: keep the current score only in subscriber memory, broadcast the final result, then delete the channel. It is compact, but reconnect has nothing authoritative to backfill, delivery duplicates become scoring hazards, and channel cleanup becomes entangled with retention policy.
That design is valid for disposable signals whose loss has no business effect. A typing indicator in a casual lobby can expire without reconstruction. A read receipt can also stay ephemeral when it is merely UI polish and no audit, support, or compliance workflow depends on it. Once the signal affects ranking, prizes, eligibility, or dispute handling, store the underlying business event and let realtime delivery remain a projection.
The decision record is therefore narrow: durable quiz state first, explicit close state second, observed channel removal third. Test every provider with the same duplicate, authorization, latency, reconnect, and 429 cases. Pick the integration model the team can operate, but don't ask transport presence to certify business truth.
Top comments (0)