TL;DR: Move a game room dashboard from polling to push when measured poll work exceeds event fan-out and connection overhead, not when a slogan says you have "a handful" of viewers. Keep token minting on a trusted server, bind every token to one room and role, and authorize both the video join and dashboard stream from those claims. The browser should receive authority, never create it.
For a live game, this choice is mostly about trust and duplicated work. Video can travel over WebRTC while scores, moderation state, and room occupancy use a small server-sent event stream. One authenticated event enters the backend, the room state changes once, and authorized viewers receive the projection allowed by their role.
How should the trust boundary work?
Treat the game client as hostile input. It may request a room token, but it must not choose trusted claims such as role: "moderator", extend its own expiry, or substitute another room ID. An authenticated backend derives those values from server-side membership data, signs a short-lived token, and the receiving service verifies the signature, audience, expiry, room, and permitted action before joining a video room or opening a dashboard stream.
The room claim is the important constraint. A valid signature without a resource boundary creates a bearer credential with more reach than the task requires. Use separate roles for publishing media, viewing state, and moderation; do not infer dashboard authority from a room name supplied by the browser. A token identifier also gives logs a correlation key without logging the token itself.
The flow is plain: the browser asks the application backend for access, the backend checks membership and issues scoped claims, the media service authorizes the WebRTC join, and the dashboard endpoint independently authorizes its stream. Game events update canonical room state on the server. The server then projects safe state into each connected viewer stream.
Build the narrow path first
This server uses only Node APIs. It signs a compact HMAC token, verifies the token before opening one stream, and broadcasts a deliberately small dashboard view. In production, load the secret from a secret manager, use TLS, rotate keys with identifiers, and replace the in-memory connection set with an adapter appropriate to the deployment topology.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer, ServerResponse } from "node:http";
type Role = "player" | "spectator" | "moderator";
type Claims = {
sub: string;
room: string;
role: Role;
aud: "game-room";
exp: number;
jti: string;
};
const secret = Buffer.from(process.env.ROOM_TOKEN_SECRET ?? "", "utf8");
if (secret.length < 32) throw new Error("ROOM_TOKEN_SECRET must be at least 32 bytes");
const encode = (value: object): string =>
Buffer.from(JSON.stringify(value)).toString("base64url");
function sign(claims: Claims): string {
const body = encode({ alg: "HS256", typ: "JWT" }) + "." + encode(claims);
const signature = createHmac("sha256", secret).update(body).digest("base64url");
return `${body}.${signature}`;
}
function verify(token: string, expectedRoom: string): Claims {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("malformed token");
const [header, payload, supplied] = parts;
const body = `${header}.${payload}`;
const expected = createHmac("sha256", secret).update(body).digest();
const actual = Buffer.from(supplied, "base64url");
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
throw new Error("invalid signature");
}
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Claims;
const now = Math.floor(Date.now() / 1000);
if (claims.aud !== "game-room" || claims.exp <= now || claims.room !== expectedRoom) {
throw new Error("invalid scope");
}
return claims;
}
const viewers = new Map<string, Set<ServerResponse>>();
function broadcast(room: string, state: { round: number; activePlayers: number }): void {
const frame = `event: room-state\ndata: ${JSON.stringify(state)}\n\n`;
for (const response of viewers.get(room) ?? []) response.write(frame);
}
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://localhost");
const match = url.pathname.match(/^\/rooms\/([a-zA-Z0-9_-]+)\/events$/);
if (!match) { response.writeHead(404).end(); return; }
try {
const room = match[1];
const token = request.headers.authorization?.replace(/^Bearer /, "") ?? "";
const claims = verify(token, room);
const allowed: Role[] = ["player", "spectator", "moderator"];
if (!allowed.includes(claims.role)) throw new Error("role cannot view state");
response.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache, no-transform",
connection: "keep-alive"
});
const roomViewers = viewers.get(room) ?? new Set<ServerResponse>();
roomViewers.add(response);
viewers.set(room, roomViewers);
response.write(": connected\n\n");
request.on("close", () => {
roomViewers.delete(response);
if (roomViewers.size === 0) viewers.delete(room);
});
} catch {
response.writeHead(401).end();
}
});
server.listen(8080);
// Call this only after a validated game event commits canonical state.
broadcast("final-lobby", { round: 3, activePlayers: 8 });
void sign; // Token issuance belongs in an authenticated application handler.
The browser example uses fetch rather than EventSource because the native EventSource constructor does not expose a general option for setting an Authorization header. This keeps the bearer token out of the URL. The compact parser fits this known stream format; use a tested SSE parser if the protocol grows to include multiline data, event IDs, or retry fields.
type RoomState = { round: number; activePlayers: number };
export async function watchRoom(
room: string,
token: string,
onState: (state: RoomState) => void,
signal: AbortSignal
): Promise<void> {
const response = await fetch(`/rooms/${encodeURIComponent(room)}/events`, {
headers: { authorization: `Bearer ${token}` },
signal
});
if (!response.ok || !response.body) throw new Error(`stream rejected: ${response.status}`);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let pending = "";
while (true) {
const { value = "", done } = await reader.read();
if (done) return;
pending += value;
const frames = pending.split("\n\n");
pending = frames.pop() ?? "";
for (const frame of frames) {
const data = frame.split("\n").find((line) => line.startsWith("data: "));
if (data) onState(JSON.parse(data.slice(6)) as RoomState);
}
}
}
Keep media authorization separate from media transport. WebRTC defines peer connections and media or data transport behavior; it does not make an application-supplied room claim trustworthy. The application still owns identity, authorization, signaling policy, and what dashboard fields each role may see.
How does pushing beat polling above a handful of viewers?
There is no universal five-viewer threshold. Model one room over a fixed window. Let V be concurrent dashboard viewers, I the polling interval in seconds, and E the state changes per second. Let R be measured server cost per poll, D cost per pushed delivery, K per-connection cost per second, and B the fixed cost of ingesting and projecting one event.
Polling work per second is approximately V * R / I. Push work is approximately E * B + V * (E * D + K). Push has lower modeled work when V * (R / I - E * D - K) > E * B. If the term in parentheses is zero or negative, adding viewers does not make push win under this model.
Measure again.
This is why pushing beats polling above a handful of viewers only under specific workloads: each added viewer repeats a poll, while the fixed event projection is shared. The cost explanation changes when delivery work or connection overhead dominates.
A game that updates a score twice per minute behaves differently from one emitting pointer positions many times per second. Coalesce rapid state changes, send the latest state that the dashboard can render, and keep high-rate gameplay or media data off the administrative dashboard stream. Do not pay to deliver frames nobody can perceive or use.
This approach has limits. Polling is the better trade-off for a rarely opened administrative screen, a room whose state changes less often than its permitted staleness, or an environment that cannot hold long-lived responses reliably. Push also does not fit an audit consumer that must replay every event exactly; use durable storage and a cursor-based reader for that job. Conversely, a busy room with many spectators and relatively sparse score changes is where shared projection and fan-out can remove repeated reads. The choice follows the measured workload, not an architectural preference.
Use counters from the same load test: authenticated polls, committed room events, delivered frames, open connections, bytes, reconnects, authorization failures, and p95 end-to-end state age. Convert those counters into infrastructure cost using the deployment's own metering units. Published unit prices change; the event and request counts remain the durable part of the decision.
This TypeScript calculator makes the assumption visible. The units can be CPU milliseconds, normalized request units, or money, provided every input uses the same basis.
type Inputs = {
viewers: number;
pollIntervalSeconds: number;
eventsPerSecond: number;
pollCost: number;
ingestCost: number;
deliveryCost: number;
connectionCostPerSecond: number;
};
function compare(input: Inputs): { polling: number; push: number } {
const polling = input.viewers * input.pollCost / input.pollIntervalSeconds;
const push = input.eventsPerSecond * input.ingestCost
+ input.viewers * (
input.eventsPerSecond * input.deliveryCost
+ input.connectionCostPerSecond
);
return { polling, push };
}
console.log(compare({
viewers: 12,
pollIntervalSeconds: 2,
eventsPerSecond: 0.2,
pollCost: 1,
ingestCost: 3,
deliveryCost: 0.08,
connectionCostPerSecond: 0.01
}));
Those numbers are sample inputs, not a benchmark. Replace every one with observations from a representative room, including idle periods and reconnect bursts.
Operate the room beyond the happy path
Deploy the stream behind a proxy that supports long-lived responses and disables response buffering for this route. Set explicit idle and maximum connection lifetimes, send comment heartbeats only as often as the network path requires, and reconnect with backoff plus jitter. On reconnect, fetch or include a current snapshot before applying new events; otherwise a disconnected spectator may display an internally consistent but stale round.
Backpressure needs a policy. For a dashboard, dropping superseded intermediate states and sending the newest snapshot is often more useful than growing an unbounded per-viewer queue. Record dropped updates. If every event must be retained for audit, persist an ordered event log and let the live view remain a projection. An open socket is not durable storage.
Token expiry also has to work during a long match. Reject expired credentials at connection time, cap stream lifetime, and require reauthorization before reconnecting. A server may close existing streams when membership is revoked, but short expiry limits exposure if that signal is delayed. Never print bearer tokens in application logs.
Before release, test two users with different roles against two rooms, then attempt cross-room joins, expired tokens, altered payloads, reconnects after missed state, a slow reader, and a process restart. Watch state age as well as server latency. Fast delivery of stale canonical state is still wrong. Finally, run the cost model on quiet rooms and bursty tournament rooms separately; an average hides the condition most likely to exhaust connection or fan-out capacity.
The decision rule stays compact: use scoped server-issued credentials for every transport, choose push when representative measurements cross the model's threshold, and keep the dashboard projection coarse enough that its value exceeds its delivery cost. Viewer count is an input. It is not the answer.
Sources
References:
Top comments (0)