DEV Community

SilasFletcher5853
SilasFletcher5853

Posted on

Sequence-Numbered Bid Broadcasts After Transaction Commit for Live Auctions

Short answer: accept a bid through the normal application API, assign the next sequence number on the server, commit the new high bid, and only then publish that accepted state. Message arrival never decides the winner. The sequence number lets every client spot a missing update and recover from an authoritative snapshot.

For a live healthtech auction, that distinction matters. Imagine clinics bidding for a limited equipment slot: the screen must move quickly, but an animation is not a transaction record. I would spend complexity on one ordering authority and gap recovery, then outsource fan-out. That keeps the weekly shipping cadence intact without asking a realtime provider to settle bids.

How should Node.js broadcast an accepted high bid sequence?

Two valid bids can cross in flight. A subscriber in Boston may receive bid B before bid A while another subscriber sees the reverse. Realtime delivery is useful transport, but receipt order is not business order. The application server must compare the bid, accept or reject it, and advance a sequence within the same serialized write.

The event should describe committed state rather than an instruction to mutate state. A useful envelope is small: auction ID, accepted amount, bidder-safe display data, and sequence. Clients replace their view when the sequence is exactly one higher. They ignore an older sequence. If they see a jump, they fetch or receive a fresh authoritative snapshot before applying more events.

Commit first. Publish second. Publishing before commit creates the ugliest failure mode: the room sees a winning bid that the database later rejects. Publishing after commit can instead leave a recoverable gap, which the sequence exposes. In production I would close that gap with a transactional outbox, but the ordering rule stays the same.

The sequence is the receipt.

The smallest working implementation

This compact TypeScript server owns ordering and uses WebSockets only for fan-out. It has one HTTP write route. A new socket receives the current snapshot, so reconnecting repairs a gap without polling. The in-memory state is deliberate for a runnable example; it is not the production persistence recommendation.

import express from "express";
import { createServer } from "node:http";
import { WebSocketServer } from "ws";

type BidState = {
  auctionId: string;
  amountCents: number;
  bidderLabel: string;
  sequence: number;
};

const app = express();
app.use(express.json());

const server = createServer(app);
const sockets = new WebSocketServer({ server, path: "/auction-events" });
const state: BidState = {
  auctionId: "clinic-equipment-42",
  amountCents: 100_000,
  bidderLabel: "Opening bid",
  sequence: 0,
};

async function publish(event: BidState, attempt = 0): Promise<void> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");
  const baseUrl = process.env.INFRAI_API_BASE_URL;
  if (!baseUrl) throw new Error("INFRAI_API_BASE_URL is required");

  const response = await fetch(`${baseUrl}/realtime/publish`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `accepted-bid-${event.auctionId}-${event.sequence}`,
    },
    body: JSON.stringify({
      channel: `auction:${event.auctionId}`,
      event: "bid.accepted",
      data: event,
    }),
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("Retry-After"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : Math.min(500 * 2 ** attempt, 8_000);
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return publish(event, attempt + 1);
  }
  if (!response.ok) {
    throw new Error(`Publish failed (${response.status}): ${await response.text()}`);
  }
}

sockets.on("connection", (socket) => {
  socket.send(JSON.stringify({ type: "auction.snapshot", data: state }));
});

app.post("/auctions/:auctionId/bids", async (request, response) => {
  const amountCents = Number(request.body.amountCents);
  const bidderLabel = String(request.body.bidderLabel ?? "Anonymous clinic");

  if (request.params.auctionId !== state.auctionId) {
    response.status(404).json({ error: "Auction not found" });
    return;
  }
  if (!Number.isSafeInteger(amountCents) || amountCents <= state.amountCents) {
    response.status(409).json({ error: "Bid must exceed the current high bid" });
    return;
  }

  // This synchronous assignment is the commit boundary in this single-process demo.
  state.amountCents = amountCents;
  state.bidderLabel = bidderLabel;
  state.sequence += 1;
  const accepted = { ...state };

  await publish(accepted);
  response.status(202).json(accepted);
});

server.listen(3000);
Enter fullscreen mode Exit fullscreen mode

The browser-side rule is equally important. Do not quietly render sequence 19 after sequence 17. Mark the local view stale, reconnect, and wait for the snapshot.

type AuctionState = {
  auctionId: string;
  amountCents: number;
  bidderLabel: string;
  sequence: number;
};

let current: AuctionState | undefined;
const socket = new WebSocket("ws://localhost:3000/auction-events");

socket.addEventListener("message", (message) => {
  const event = JSON.parse(String(message.data)) as {
    type: "auction.snapshot" | "bid.accepted";
    data: AuctionState;
  };

  if (event.type === "auction.snapshot") {
    current = event.data;
    render(current);
    return;
  }

  if (!current || event.data.sequence !== current.sequence + 1) {
    socket.close();
    window.setTimeout(() => window.location.reload(), 500);
    return;
  }

  current = event.data;
  render(current);
});

function render(value: AuctionState): void {
  const node = document.querySelector("#high-bid");
  if (node) node.textContent = `${value.bidderLabel}: $${(value.amountCents / 100).toFixed(2)}`;
}
Enter fullscreen mode Exit fullscreen mode

This demo has a sharp boundary: one process serializes bids. That is enough to make the mechanism visible, not enough for multiple application replicas.

What I would change at production scale

Move bid acceptance into a database transaction. Lock the auction row, verify that the auction is open and the amount is higher, increment its sequence, and insert an outbox record in that same transaction. A worker publishes the outbox record after commit. Give that record a stable ID so worker retries cannot create a second logical event.

Then make consumers idempotent. A client that already rendered sequence 38 should ignore 38 when it arrives again. A client that receives 40 while holding 38 should stop applying deltas and recover. These two rules make duplicate delivery and missing delivery visible rather than mysterious.

I would also keep sensitive bidder identity out of the broadcast. The event can carry a display label while the transaction retains the authenticated account. Authorization belongs both on bid submission and channel access; a guessed auction ID must not grant access to clinic activity.

The production checklist is short because every extra moving part takes time away from the product:

  • Serialize acceptance in the database, not in the fan-out layer.
  • Allocate the sequence inside the same transaction as the accepted bid.
  • Write an idempotent outbox record before commit, then publish it afterward.
  • Return a conflict for a bid that no longer clears the committed high bid.
  • Reconnect and load a snapshot on any sequence gap.
  • Track outbox age and reconnect frequency; those reveal delayed publication and unstable clients.
  • Load-test one hot auction, not just many quiet auctions.

Choosing the fan-out layer

The decision axis is delivery behavior at fan-out, not which SDK produces the shortest demo. Ably documents message ordering and delivery semantics. Pusher Channels provides hosted channel-based WebSocket delivery. Supabase Realtime combines Broadcast, Presence, and database-change delivery. Infrai is a reasonable fit when a solo operator wants realtime beside many other backend modules through one REST API and one key: its discovery surface lists 295 routes across 20 modules. That breadth reduces integration ownership, but it does not move auction ordering out of the application.

Option Useful fit Boundary to keep
Ably Teams that want a dedicated realtime platform with documented ordering and delivery semantics The bid transaction still assigns sequence and authority
Pusher Channels Straightforward hosted channel fan-out with familiar client libraries Channel arrival must never choose the winner
Supabase Realtime Products already centered on Supabase and wanting Broadcast, Presence, or database changes together Database-change delivery is not a substitute for an explicit accepted-bid contract
Infrai Small teams consolidating backend capabilities behind a consistent REST surface Application code still owns commit order, recovery, and bidder authorization

There is a real limitation to consolidation. It is not a fit when the team needs a specialist's documented delivery controls, established client libraries, or deep presence features; choose Ably, Pusher Channels, or Supabase Realtime when that product-specific capability outweighs a shared backend contract. Self-hosting can be sensible when deployment control dominates. It also makes connection capacity, regional routing, reconnect storms, and operational coverage your problem.

For a one-person SaaS, that trade-off must earn more revenue per hour than the feature delayed by it. Usually it does not. I would outsource the undifferentiated connection layer and keep the bid rules portable.

That is the line I would hold.

No provider can promise that every device renders every event exactly once. Networks disconnect. Tabs sleep. Processes retry. The durable design is server-authoritative ordering plus detectable gaps, with fan-out treated as a fast projection of committed truth.

References

Top comments (0)