Short answer: model presence as short-lived, purpose-bound facts, then publish bidder notifications from a server-owned contract that reveals the minimum state. A browser should never infer auction privacy from a socket room name or from a peer connection that happens to be open.
For an auction system, the useful question is not “who is online?” It is “what may this bidder learn, from which event, and for how long?” That shift prevents a common leak: a bidder sees that another bidder is active, learns the size of a room, or receives a read receipt for an auction they cannot inspect.
How should realtime presence privacy shape bidder notification contracts?
Start with a contract that separates identity, authorization, and liveness. Identity belongs to the authenticated account. Authorization belongs to the auction policy. Liveness is a timestamped observation, not a fact about a person’s behavior. Keep those meanings separate in storage and on the wire.
type BidderAlert = {
eventId: string;
auctionId: string;
kind: "typing_started" | "typing_stopped" | "receipt";
recipient: { bidderId: string };
observedAt: string;
expiresAt: string;
visibility: "recipient-only" | "auction-staff";
payload: { messageId?: string; state?: "started" | "stopped" };
};
There is no onlineUsers array. There is no peer identifier. The recipient is explicit, and the event has an expiry. A notification router can reject a message when the recipient is not entitled to the auction, while clients discard stale presence without guessing.
The server validates this shape before fan-out. A client may request “typing started”; it may not choose visibility, another bidder’s ID, or an arbitrarily long expiry. Treat every client-supplied field as a proposal.
Before and after: presence as a leak versus a scoped signal
The risky design broadcasts a generic status event to everyone subscribed to auction:42. It feels convenient. It also turns subscription membership into an information channel. A silent bidder can count active connections, correlate reconnects with high-value lots, and retain a status event long after the auction closes.
The safer design computes an audience for each event. The notification service checks the auction ACL, maps the event to recipient IDs, and sends a per-recipient payload. Staff may receive a redacted operational signal, while bidders receive only their own typing or read state. Presence remains useful, but it is no longer a public scoreboard.
That is the whole change.
Fan-out is now a policy decision, not a transport detail. Log the decision with a correlation ID, event kind, recipient class, and reason code. Do not log message text or a full participant list. Metrics can count accepted, rejected, expired, and duplicate events without exposing identities.
A copyable TypeScript gate for fan-out
This pure function is intentionally boring. Boring policy code is easy to test. It takes a server-side decision and returns only an allowed notification.
type Audience = { bidderId: string; canViewAuction: boolean };
type PresenceInput = {
auctionId: string;
senderBidderId: string;
kind: "typing_started" | "typing_stopped" | "receipt";
messageId?: string;
now: Date;
};
function makeAlert(input: PresenceInput, audience: Audience): BidderAlert | null {
if (!audience.canViewAuction) return null;
if (audience.bidderId !== input.senderBidderId && input.kind !== "receipt") return null;
const expiresAt = new Date(input.now.getTime() + 10_000);
return {
eventId: crypto.randomUUID(),
auctionId: input.auctionId,
kind: input.kind,
recipient: { bidderId: audience.bidderId },
observedAt: input.now.toISOString(),
expiresAt: expiresAt.toISOString(),
visibility: "recipient-only",
payload: {
messageId: input.messageId,
state: input.kind === "typing_started" ? "started" : "stopped",
},
};
}
The ten-second window is an example policy, not a universal value. Your auction rules may require a shorter TTL. The important invariant is that expiry is server-generated and tested with a clock you control. I once treated a reconnecting browser as proof that a bidder was still present; the resulting stale indicator lasted through an entire lot. The fix was a TTL plus an explicit typing_stopped event, with expiry as the backstop.
Use idempotency at delivery. eventId should be durable enough for the consumer’s deduplication window. A client can receive an event twice after a reconnect; it should render the same state, not create two audit entries. For receipts, prefer monotonic message sequence numbers over client timestamps. Clock skew is normal.
What breaks first in real auction notification systems?
The first failure is usually an authorization race. A bidder loses access to a private lot, but a queued event still reaches the old connection. Re-check authorization at enqueue time and again immediately before delivery when the queue can outlive a session. If the check fails, drop the event and increment a reason-coded metric.
Here is the race in slow motion. At 09:14:02.110, bidder A is authorized for lot 42 and starts typing. At 09:14:02.140, the seller changes the lot visibility and the policy service revokes A’s access. At 09:14:02.190, a worker drains the queue. If that worker trusts the authorization result captured at enqueue time, the event crosses the boundary after revocation. The payload can be perfectly encrypted and still be a privacy incident. A second check closes the gap; a short expiry limits the damage when a worker is delayed. Record both decisions with the same correlation ID, so an on-call engineer can explain why an event was accepted or discarded without reading private content. This is also why a load test that measures only median latency misses the important failure: the dangerous path is a policy change between enqueue and delivery, not a slow happy-path request.
Measure it.
The second failure is metadata exposure. Even encrypted payloads can leak through channel names, event types, or timing. Use opaque channel identifiers, avoid embedding bidder IDs in URLs, and keep event names coarse enough that a passive observer cannot classify a rival’s activity. Transport encryption protects content; it does not define the contract.
The third failure is unbounded retention. Presence is operational telemetry with a short useful life. Store aggregates for capacity planning, but set a deletion policy for raw observations. A privacy contract that says “we delete it” needs a job, a metric, and an alert when deletion falls behind.
Add contract tests for an unauthorized recipient, a revoked session, duplicate delivery, expired typing state, and a reconnect during fan-out. Then run a two-bidder simulation where one bidder must never learn the other bidder’s presence. One browser is not a test plan.
Choosing transport and observability boundaries
WebRTC data channels can carry low-latency messages between peers, but the signaling and authorization layer remains yours. The W3C WebRTC Recommendation describes browser APIs and their connection model; it does not decide whether an auction participant may see another participant’s presence. Keep policy server-side even when the data path is peer-to-peer.
WebSockets or server-sent events are often simpler for server-mediated alerts because the server retains audience control. A hybrid can work: use a server channel for contracts and receipts, and reserve peer data channels for content that has already passed an authorization check. Document that boundary so a later optimization cannot bypass the privacy gate.
Instrument three layers separately: contract validation, authorization, and delivery. A dashboard showing only connection count cannot tell you whether a privacy rule is enforced. Useful counters include contract_rejected_total, fanout_dropped_total, delivery_duplicate_total, and presence_expired_total. Sample payload fields carefully; your logs are another audience.
The catch is that strict per-recipient fan-out costs more coordination than a broadcast room. It is not suitable when your product genuinely needs public presence for every participant, such as an open livestream chat. In that case, publish a deliberately public contract and retain the same expiry and deletion discipline. Stick with broadcast when disclosure is an explicit product requirement, not because the transport makes it easy.
I’m not sure any single TTL or sequence format fits every auction. Write those choices into the contract, attach an owner, and revisit them when auction rules change. Your mileage may vary.
Top comments (0)