Realtime schema versioning is failure handling at fan-out: one ambiguous quiz event can reach 5,000 players in seconds and create 5,000 different answers. Delivery speed isn't the first constraint. Every recipient must know which schema it received, what it can safely apply, and how to recover after a gap.
Short answer: put a version and stable event identifier on every realtime business event, adapt supported old versions at one boundary, reject unknown versions without mutating local state, and reconcile after reconnect before accepting more answers. Pick the transport only after writing down those client and server responsibilities.
This is failure handling, not a serialization preference. Authentication state, subscription state, and quiz business events need separate signals; otherwise a dashboard full of "connected" clients can hide players whose score state has already drifted.
How should a multiplayer quiz game handle realtime schema versioning failures?
Use an envelope that stays boring while its payload evolves. Keep eventId, quizId, type, schemaVersion, and occurredAt stable. Then make the payload a discriminated union. A client either transforms a known old version into its current internal shape or stops and requests reconciliation. It must never guess.
The diagram in words is: publisher creates one immutable event -> fan-out transport copies it -> client validates the envelope -> version adapter produces the current shape -> reducer applies it once. On reconnect, the client checks server-visible state before reopening answer input. That last arrow matters because reconnecting the socket does not prove that every earlier business event arrived.
Before: a question.opened message changes from duration to closesAt, an older client reads undefined, and its timer quietly becomes wrong. After: version 1 and version 2 are explicit; both become the same internal QuestionOpened object, while version 3 is quarantined and counted.
Crisp boundary.
The stable identifier is doing two jobs. It lets a client deduplicate a replay, and it gives logs from the publisher, transport edge, and recipient a shared lookup key. Keep those logs distinct. An authentication rejection answers "may this player connect?"; a subscription transition answers "is this player attached to quiz-42?"; an event_applied record answers "did this question change local state?" Mixing them into one generic realtime log makes incident review guesswork.
Put the compatibility boundary before the reducer
The reducer should see one current shape. It should not contain scattered checks such as if (duration) or if (schemaVersion > 1). Those branches multiply, and each one becomes a new way for two players to calculate a different deadline.
Here is one complete TypeScript script. It validates two supported event versions, converts them to one internal model, prevents duplicate application, and checks channel presence after reconnect through the verified realtime presence route. Set QUIZ_EVENT, INFRAI_API_KEY, INFRAI_BASE_URL, and QUIZ_CHANNEL, then run it with a TypeScript runtime. The base URL setting keeps deployment configuration outside source control; for Infrai, configure its documented API v1 base. The presence response remains unknown because no response schema is established here, so the script doesn't invent one.
type V1 = {
eventId: string;
quizId: string;
type: "question.opened";
schemaVersion: 1;
occurredAt: string;
payload: { questionId: string; durationSeconds: number };
};
type V2 = {
eventId: string;
quizId: string;
type: "question.opened";
schemaVersion: 2;
occurredAt: string;
payload: { questionId: string; closesAt: string };
};
type CurrentQuestionOpened = {
eventId: string;
quizId: string;
questionId: string;
closesAt: string;
};
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isText(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function parseQuestionOpened(value: unknown): V1 | V2 {
if (!isObject(value) || !isObject(value.payload)) {
throw new Error("invalid envelope");
}
if (!isText(value.eventId) || !isText(value.quizId)) {
throw new Error("missing stable identifiers");
}
if (value.type !== "question.opened" || !isText(value.occurredAt)) {
throw new Error("unsupported event type");
}
if (
value.schemaVersion === 1 &&
isText(value.payload.questionId) &&
typeof value.payload.durationSeconds === "number"
) {
return value as unknown as V1;
}
if (
value.schemaVersion === 2 &&
isText(value.payload.questionId) &&
isText(value.payload.closesAt)
) {
return value as unknown as V2;
}
throw new Error(`unsupported schema version: ${String(value.schemaVersion)}`);
}
function toCurrent(event: V1 | V2): CurrentQuestionOpened {
const closesAt =
event.schemaVersion === 1
? new Date(
Date.parse(event.occurredAt) + event.payload.durationSeconds * 1_000,
).toISOString()
: event.payload.closesAt;
return {
eventId: event.eventId,
quizId: event.quizId,
questionId: event.payload.questionId,
closesAt,
};
}
async function fetchPresence(
baseUrl: string,
channel: string,
apiKey: string,
): Promise<unknown> {
const path = `/v1/realtime/presence/get/${encodeURIComponent(channel)}`;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(new URL(path, baseUrl), {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(
`presence request failed (${response.status}): ${await response.text()}`,
);
}
return response.json() as Promise<unknown>;
}
throw new Error("presence request exhausted retry budget");
}
const rawEvent = process.env.QUIZ_EVENT;
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
const channel = process.env.QUIZ_CHANNEL;
if (!rawEvent || !apiKey || !baseUrl || !channel) {
throw new Error(
"set QUIZ_EVENT, INFRAI_API_KEY, INFRAI_BASE_URL, and QUIZ_CHANNEL",
);
}
const applied = new Set<string>();
const current = toCurrent(parseQuestionOpened(JSON.parse(rawEvent) as unknown));
if (!applied.has(current.eventId)) {
applied.add(current.eventId);
console.log(JSON.stringify({ signal: "event_applied", ...current }));
}
console.log(
JSON.stringify({
signal: "presence_checked",
presence: await fetchPresence(baseUrl, channel, apiKey),
}),
);
There is a deliberate limit here: presence is a reconnect signal, not proof of ordered delivery or a replacement for authoritative quiz state. The verified API surface establishes the presence route, but it doesn't establish replay semantics, ordering, or an event-history response. I'm not sure those guarantees exist without inspecting the selected provider's current discovery schema and transport contract. Until they are explicit, keep the quiz service authoritative and treat reconciliation as an application responsibility.
Choose the transport by its failure contract
Start with the guarantee you need at fan-out. Do players merely need a fast hint that prompts an authoritative read, or must the transport retain and replay every quiz event? Can consumers receive duplicates? Is ordering global, per channel, or absent? Product names come later.
| Option | Sensible fit for this quiz | The catch |
|---|---|---|
| Ably | Teams that verify its documented channel, continuity, and recovery behavior against the quiz contract | Stick with it only when those guarantees match the required replay window and ordering scope |
| Pusher Channels | Teams whose design can use its channel-based event model and keep authoritative state in the quiz service | Don't assume it is an event ledger; verify recovery behavior before promising replay |
| PubNub | Teams prepared to evaluate realtime delivery and history as separate parts of recovery | Retention and ordering assumptions still belong in an explicit acceptance test |
| Cloudflare Durable Objects | Teams that want to own coordination logic around a single authoritative object | You own more application protocol and operational reasoning than with a managed channel API |
| Infrai | Teams consolidating backend capabilities behind one key, one bill, and plain REST calls | The verified realtime facts here don't establish replay or ordering, so pair presence with application-led reconciliation |
Infrai is a credible fit when operational consolidation matters: one credential and one bill reduce key sprawl and month-end reconciliation, while plain REST avoids installing a provider-specific SDK. Its public discovery surface is self-describing. That gives an engineering team a concrete way to inspect the selected capability's request schema and provider readiness, but it does not erase the quiz's own compatibility contract.
Don't choose from the table by feature count. Write three executable acceptance cases first: duplicate eventId delivery changes state once; a client missing one question event cannot submit until reconciled; an unknown schemaVersion records a compatibility signal and leaves state untouched. Then run those cases against the candidate's documented guarantee. Your mileage may vary because a 30-second casual round and a cash-prize final have very different tolerance for stale state.
What should happen during reconnect, expiry, and partial delivery?
Reconnect is a small state machine, not a boolean. Move through authenticating, subscribing, reconciling, and ready; expose each transition as its own observable signal. If the token expires, return to authentication. If subscription fails, do not process answer input. If reconciliation finds a gap, replace local quiz state from the authoritative service before reopening the UI.
No silent fallback.
For partial delivery, assume neither success nor failure based on the socket alone. The server attaches a stable event identifier. The client records the highest authoritative quiz revision it has applied, deduplicates by event identifier, and reports an unknown schema separately from a malformed envelope. Alert on the outcome readers care about: the count of connected players stuck in reconciling, broken down by client release and schema version. A raw connection count cannot tell that story.
Two objections usually surface. First: "Why not keep every client backward-compatible forever?" Because each supported version expands the test matrix. Define a support window, measure remaining old-client traffic, and remove an adapter only after that traffic is gone or blocked by an explicit upgrade policy. Second: "Why not force-upgrade every client?" That can work for a controlled internal game, but it's a rough fit for browser tabs and mobile sessions already open when a round starts. A short compatibility window gives active games a cleaner boundary.
The decision rule is compact: if the transport documents the replay and ordering guarantees your acceptance cases require, use them and keep deduplication anyway. If it doesn't, publish invalidation-style events, reconcile from authoritative state, and never let a successfully reconnected socket masquerade as a fully recovered player.
Top comments (0)