Short answer: realtime tenant isolation needs API boundaries that give every auction event a server-checked scope, keep room tokens short-lived, and replay from a tenant-filtered cursor instead of trusting the browser.
Scope first.
A live auction dashboard has a deceptively small surface: bids arrive, a timer moves, and a room shows who is watching. The dangerous part is the boundary around those events. A tenant can be a seller, a white-label marketplace, or an internal auction desk. Their users may share a process and a WebSocket cluster, but they must never share authority over rooms, bids, or replay history.
This walkthrough uses four tenants and one auction room per tenant. The decision axis is reconnect and backfill. If a bidder loses a mobile connection for eight seconds, the dashboard should catch up with only that tenant's events, in order, without granting a fresh write capability.
Start with an explicit boundary contract
The contract belongs on the server. A token can carry an immutable tenantId, roomId, role, and expiry; the connection handler derives its scope from verified claims and ignores client-supplied tenant fields. That makes a malformed dashboard request boring: it is rejected before it reaches the event fan-out.
Keep the event envelope narrow. tenantId is routing metadata, not a field the browser gets to choose. A monotonic sequence per room gives reconnect logic a stable cursor, while an event ID lets operators trace one bid across logs.
type Role = "seller" | "bidder" | "observer";
type RoomToken = {
tenantId: string;
roomId: string;
role: Role;
expiresAt: number;
};
type AuctionEvent = {
tenantId: string;
roomId: string;
seq: number;
eventId: string;
kind: "bid" | "lot-closed" | "presence";
payload: Record<string, unknown>;
};
function authorize(token: RoomToken, requestedRoom: string, now = Date.now()): void {
if (token.expiresAt <= now) throw new Error("token_expired");
if (token.roomId !== requestedRoom) throw new Error("room_scope_mismatch");
}
function publish(event: AuctionEvent, token: RoomToken): AuctionEvent {
authorize(token, event.roomId);
if (event.tenantId !== token.tenantId) throw new Error("tenant_scope_mismatch");
return event;
}
The useful test is negative: take a valid token for tenant t-04, ask for t-03's room, and assert that no subscription and no replay query is created. Test this at the connection boundary, not just in a UI integration test.
How should realtime API boundaries handle reconnect and backfill?
Treat reconnect as a read with a cursor, followed by a live subscription. The client sends its last applied sequence. The server checks the token again, loads events after that sequence for the same (tenantId, roomId), then attaches the socket to the live stream. A cursor from another room is not meaningful and must not be accepted.
Here is a small service shape. The storage interface is deliberately generic so the policy can sit over Postgres, a log, or a managed stream.
type Scope = Pick<RoomToken, "tenantId" | "roomId">;
interface EventStore {
after(scope: Scope, seq: number, limit: number): Promise<AuctionEvent[]>;
latest(scope: Scope): Promise<number>;
}
async function backfill(
store: EventStore,
token: RoomToken,
requestedRoom: string,
lastSeq: number,
): Promise<AuctionEvent[]> {
authorize(token, requestedRoom);
const scope = { tenantId: token.tenantId, roomId: token.roomId };
const events = await store.after(scope, lastSeq, 500);
return events.filter((event) =>
event.tenantId === scope.tenantId && event.roomId === scope.roomId,
);
}
The filter is defense in depth, not the primary authorization check. I once treated a cursor as harmless metadata and discovered that a cross-tenant cache key could return an otherwise valid event list. That single key sat behind three code paths: the reconnect handler, the admin export, and a “show latest bid” widget. Each path had passed its own unit tests because each used a valid event; none had tested that the tenant in the key matched the tenant in the token. The fix was to include both scope fields in the storage key, assert them again at serialization, and add a denial test that starts with a valid token for t-04 and a cursor from t-03. The bug was a boundary mistake, not a WebSocket problem.
When the gap exceeds the retained window, return a resync_required result and send a tenant-scoped snapshot. Do not silently start at the latest event; that makes a bidder see a plausible but incomplete price history. Your mileage may vary on the retention window because auction duration, audit rules, and storage cost differ, but the behavior should be explicit and observable.
Make fan-out and caches tenant-aware
A shared broker is fine when topic names are derived from verified scope. Use a canonical key such as auction:{tenantId}:{roomId} and never accept a raw topic from the browser. The same rule applies to presence counters, rate limits, snapshots, and cache entries.
Partitioning choices have trade-offs. One topic per room gives clean isolation and simple replay, but thousands of short auctions can create broker metadata overhead. One topic per tenant reduces topic count, yet every consumer must filter room IDs correctly. A single global topic is the easiest to provision and the hardest to audit. I prefer tenant partitions with room-level sequence numbers when the tenant count is stable; a high-churn marketplace may prefer a bounded topic pool plus a strict scope filter.
The catch is operational: a tenant-aware key does not prevent an operator from granting a broad service credential. Keep administrative replay separate from bidder tokens, log every scope expansion, and make support tooling require an explicit tenant and room selection.
Test the failure modes before load testing
Load tests tell you how many sockets you can hold. Isolation tests tell you whether the system is allowed to hold them together. Run both.
For four tenants, generate interleaved bids with sequences 1..N per room. Reconnect each client at random gaps, including zero, one, and a gap larger than retention. Assert ordering, duplicate handling, and that every received event has the token's tenant and room. Expire tokens during a reconnect and verify that the client receives a fresh authorization decision, not an automatic replay.
Use metrics that expose the boundary: rejected scope mismatches, backfill duration, resync counts, replay rows scanned, and the age of the oldest retained event. Log tenantId, roomId, eventId, and a request correlation ID; avoid logging bid payloads if they contain personal data. WebRTC's specification is a useful reminder that realtime transports have state machines and permission surfaces, even when your auction data rides on ordinary WebSockets.
Choose the least complicated deployment that fits
For a small marketplace, a single API service, durable event table, and one broker can be enough. Add a dedicated replay worker when backfill competes with live fan-out, and add regional routing only when measured latency or data residency requires it. Keep the authorization contract identical across those stages so a topology change does not become a security rewrite.
This design is not suitable when you need global, sub-second ordering across independent tenants or a full media room with negotiated tracks; use a system designed for those guarantees and keep the auction scope at its edge. Stick with a simpler request/response feed when auctions are short, reconnects are rare, and an occasional page refresh is acceptable. The boundary work still pays off because it makes that decision visible.
Before shipping, walk the operational checklist in prose: verify claims, derive every key from claims, cap replay size, define the resync response, expire tokens, test cross-tenant denial, and alert on unexpected scope changes. Then replay a recorded auction through a staging broker and compare the final state with a fresh snapshot. If those two views disagree, fix the cursor or projection before adding capacity.
Top comments (0)