DEV Community

FlorianBlake3536
FlorianBlake3536

Posted on

WebSockets over Server-Sent Events: Node.js API Boundaries for Revoked Auction Tokens

Short answer: choose WebSockets for a live auction dashboard when the server must revoke a token and fan out that decision quickly; keep the authorization boundary on the server, make reconnect recovery explicit, and use Server-Sent Events when clients only receive a one-way stream.

An auction bid is not a chat message. It changes a shared price, has an actor, and can arrive twice after a reconnect. The useful design question is therefore where a revoked token is enforced and how a client rebuilds state when delivery is interrupted, rather than which protocol has the nicer demo.

How should a live auction dashboard handle realtime revoked tokens at API boundaries?

Start with two responsibilities. The server owns token validation, revocation, sequence assignment, and the decision to accept a bid. The browser owns presentation, a reconnect loop, and reconciliation by stable identifiers. A browser may ask to reconnect; it must not decide that an expired credential is still good because its local clock says so.

When a token is revoked, stop accepting writes immediately at the command boundary, close the affected connection, and make the next connection perform authentication again. The stream should carry an event identifier and an auction version (for example, auction_id plus revision) so the client can tell a duplicate bid from a newer one. Reconnects, expiry, duplicate delivery, and partial failures are normal states to model and test, not exceptional branches to hide.

Revoke first.

Consider a bidder whose socket drops at revision 184 while another bidder's event 185 is already in flight. On reconnect, the client presents its last applied revision and the server compares it with the retained window. If 185 and 186 are available, replay them in order and let the client ignore any repeated event_id; if the cursor has fallen outside retention, send a fresh snapshot at revision 190 and mark the cursor there. A token revoked between those two requests still fails authorization, even if the browser cached a valid-looking connection object. This is why the server, not the transport library, decides both admission and recovery, and why stable identifiers matter more than optimistic reconnect speed.

The recovery contract needs to be written down before an endpoint is selected:

  1. A revoked credential receives an authorization response from the command API and cannot publish another bid.
  2. The client discards its stale socket, obtains a fresh credential through the normal login flow, and reconnects.
  3. The server returns enough stable identifiers for the client to request or receive the current auction snapshot, then resumes events after the known revision.
  4. A duplicate event is harmless because applying an event is idempotent for (auction_id, event_id).

That contract is more important than whether the transport is WebSockets or SSE. Without it, a fast socket only fails faster.

WebSockets and SSE: the boundary trade-off

WebSockets provide a bidirectional channel, which fits a dashboard that both receives price changes and sends bid commands. They also make connection lifecycle explicit: the server can close a socket after revocation, while the client treats close code and authorization state as separate signals. The cost is operational discipline around heartbeats, backpressure, and reconnect storms.

SSE is an HTTP response carrying server-to-client events. It is a good fit for spectators, audit feeds, and admin screens that never submit commands over the same channel. Browsers reconnect automatically, but that convenience does not solve authorization: the server still has to reject a revoked Last-Event-ID session and the client still needs a fresh credential. Bid submission then travels over a separate HTTPS endpoint, so there are two boundaries to reason about.

Here is the comparison I would put in a design review:

Concern WebSockets Server-Sent Events Long polling
Direction Two-way Server to client Repeated client requests
Revocation reaction Server can close the authenticated socket Server ends the stream; client reconnects Next request observes revocation
Fan-out overhead One connection per active client One HTTP stream per active client Request churn during bursts
Browser ergonomics Requires explicit reconnect and heartbeat code Built-in reconnect semantics Straightforward, but latency varies with poll interval
Best auction role Bidders and operators Read-only spectators and audit views Small, low-frequency control panels
Managed alternatives Ably, Pusher, PubNub, Socket.IO Ably, Pusher, PubNub Socket.IO fallback over HTTP

My default is WebSockets for bidders and SSE for spectators, with one authorization service behind both. I would not force one protocol onto every screen just to make the architecture diagram symmetrical. Ably and Pusher are sensible managed choices when hosted fan-out and presence matter; PubNub is useful when its global messaging model matches your tenancy needs. Socket.IO is familiar to Node.js teams and adds fallbacks, while Supabase Realtime suits a stack already centered on Supabase Postgres. Those are real trade-offs, not interchangeable badges.

A minimal publish boundary

The publish API should receive an already-authorized command, not a token that the browser expects the realtime layer to interpret. The example below uses the documented realtime publish route and an application-generated idempotency key. It intentionally does not send the platform authorization header anywhere except the API request.

import os
import time
import uuid
import requests


API_KEY = os.environ["INFRAI_API_KEY"]
BASE_URL = os.environ["INFRAI_BASE_URL"]


def publish_bid(auction_id: str, bidder_id: str, amount: int, revision: int) -> dict:
    event_id = str(uuid.uuid4())
    payload = {
        "channel": f"auction:{auction_id}",
        "event": "bid.accepted",
        "data": {
            "auction_id": auction_id,
            "bidder_id": bidder_id,
            "amount": amount,
            "revision": revision,
            "event_id": event_id,
        },
    }
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
        "Idempotency-Key": event_id,
    }
    for attempt in range(4):
        response = requests.post(
            f"{BASE_URL}/realtime/publish",
            json=payload,
            headers=headers,
            timeout=10,
        )
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)
            continue
        if not response.ok:
            raise RuntimeError(f"publish failed ({response.status_code}): {response.text}")
        return response.json()
    raise RuntimeError("publish rate limit persisted after retries")
Enter fullscreen mode Exit fullscreen mode

The route is only the delivery step. Authorization belongs before this function, where the bid is checked against the current token status and auction revision. If that check fails, return the real authorization reason to the caller and publish nothing. A retry can then safely repeat the same event because the client-supplied key is stable.

Infrai is one reasonable implementation when a team wants one key and one bill across backend capabilities while keeping a plain REST surface; that reduces credential and integration sprawl, but it does not remove the need to design token revocation or client reconciliation. Its realtime discovery includes publish operations, so the integration can stay HTTP-based without installing a protocol-specific SDK. The platform is not a substitute for the auction's authorization policy.

Cost and retention are coupled

The dominant bill in a live auction is usually fan-out volume: number of recipients multiplied by event bytes and retention duration. A bid event sent to 2,000 spectators costs more in transport and retained history than the authorization check that admitted it. Measure that term first. Compressing a JSON payload, avoiding redundant presence events, and retaining only the revisions needed for recovery move the number that matters.

Retention is a deliberate trade. Keep a short event window plus a durable auction snapshot, and you get bounded replay storage with a clear recovery path. Keep every raw event forever, and forensic replay is easier, but storage and privacy obligations grow with every bid. I would stop retaining ephemeral cursor pings and presence heartbeats; when an incident requires them, the loss is diagnostic detail, not the authoritative auction state.

That choice also affects reconnect behavior. A client whose cursor is older than the retained window must receive a snapshot and a new cursor, not an invented partial replay. Test this path with realistic latency, duplicate delivery, revoked credentials, and a subscriber joining during a burst. Your mileage may vary with network topology; the contract should still be deterministic.

When another choice is better

WebSockets are not suitable when a corporate proxy only permits ordinary HTTP streams or when the audience is entirely read-only and operational simplicity outweighs bid latency. Stick with SSE for spectator-heavy dashboards, and keep bid commands on a separately authenticated HTTPS API. Choose a managed WebSocket service when connection lifecycle and regional fan-out are the main staffing constraint; choose a self-hosted broker when data residency and direct control dominate.

Do not select a provider from a price column alone. Check its revocation hooks, replay semantics, maximum connection lifetime, observability, and failure behavior under duplicate delivery. I am not sure any single vendor's defaults match your auction's legal retention policy, so write that policy down and verify it in a load test before launch.

Further reading

Top comments (0)