DEV Community

RiftG84
RiftG84

Posted on

Node.js Reconnect Drill — Fair Bids Need Server Timestamp, Not Message Arrival

A live auction poll has one constraint that changes the design: a bidder can disconnect after sending a bid and reconnect after the result. The defensible choice is to order accepted bids at the server, persist that decision, assign a sequence number, and publish the accepted result. Do not treat the order in which messages appear on different clients as the auction record.

TL;DR: transport arrival is useful for delivery, not adjudication. Let one server transaction decide (server timestamp, tie-breaker), store the decision, and emit the resulting sequence. On reconnect, backfill from durable state before resuming the live stream. This makes a dispute answerable from the database instead of from two browsers with different network paths.

Why can't message arrival decide a fair winner?

There is no single client-side arrival order. Bidder A may see bid-41 before bid-42, while bidder B sees the reverse because their packets traveled through different networks. WebRTC data channels can be configured for ordered or unordered delivery, but transport ordering still does not create a shared business decision. The W3C specification describes delivery behavior; it does not turn a recipient's observation into an auction ledger.

The simple implementation is tempting: subscribe every browser, append each event as it arrives, and let the first item win. It fails the first serious fairness question: first where? A moderator's laptop, a bidder's phone, and a regional relay can each answer differently.

No one can audit that.

Server receipt is the useful boundary. In one database transaction, record the server timestamp and a deterministic tie-breaker, reject any bid that violates the poll rules, and allocate the next sequence number. A timestamp alone is insufficient because two accepted records can share its resolution. A unique server-generated ID, or another explicitly documented deterministic key, completes the order.

Short version: the database settles the bid; realtime distributes the verdict.

The focused Node.js path

Keep adjudication small enough to inspect. This TypeScript example deliberately models the database transaction behind interfaces, because schema and database choice are application decisions. It contains no client timestamp and no inferred delivery order.

type Bid = {
  auctionId: string;
  bidderId: string;
  amountCents: number;
  clientRequestId: string;
};

type AcceptedBid = Bid & {
  acceptedAt: string;
  sequence: number;
  decisionId: string;
};

interface AuctionStore {
  acceptOnce(bid: Bid): Promise<AcceptedBid>;
  listAfter(auctionId: string, sequence: number): Promise<AcceptedBid[]>;
}

interface ResultPublisher {
  publish(result: AcceptedBid): Promise<void>;
}

const wait = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

export async function publishWithInfrai(
  payload: unknown,
  decisionId: string,
  attempt = 0,
): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!apiKey || !baseUrl) {
    throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
  }

  const response = await fetch(new URL("/v1/realtime/publish", baseUrl), {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": decisionId,
    },
    body: JSON.stringify(payload),
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("Retry-After"));
    const milliseconds = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await wait(milliseconds);
    return publishWithInfrai(payload, decisionId, attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`Publish failed (${response.status}): ${await response.text()}`);
  }

  return response.json();
}

export async function placeBid(
  store: AuctionStore,
  publisher: ResultPublisher,
  bid: Bid,
): Promise<AcceptedBid> {
  if (!Number.isSafeInteger(bid.amountCents) || bid.amountCents <= 0) {
    throw new Error("amountCents must be a positive integer");
  }

  const accepted = await store.acceptOnce(bid);
  await publisher.publish(accepted);
  return accepted;
}

export async function reconnect(
  store: AuctionStore,
  auctionId: string,
  lastSeenSequence: number,
): Promise<AcceptedBid[]> {
  return store.listAfter(auctionId, lastSeenSequence);
}
Enter fullscreen mode Exit fullscreen mode

acceptOnce needs two database guarantees: clientRequestId is unique within the auction, and sequence allocation happens atomically with acceptance. Retrying the same request then returns the same decision instead of creating a second bid. A reconnect request supplies the last sequence the client applied; the response begins after it. Once caught up, the client can consume new accepted results and reject duplicates by decisionId or sequence. Before passing an accepted-result payload to publishWithInfrai, generate it from the public discovery schema for the realtime publish capability and validate it; the exact body is intentionally absent here because an undocumented field would make the sample misleading. The request uses an environment key, an explicit method, an idempotency key, surfaced error bodies, and bounded 429 backoff that honors Retry-After.

Notice what is absent. No browser clock participates. No subscriber elects a winner. No websocket callback mutates the authoritative order.

Publishing after the transaction creates a narrow failure window: the decision can commit before fan-out succeeds. For a serious auction, use an outbox record committed with the bid and let a worker retry publication idempotently. That worker is also the right place to hold notifications for offline bidders. A dropped connection becomes delayed delivery, not a lost decision.

Reconnect and backfill are one protocol

Treat every emitted result as an envelope containing the auction ID, decision ID, sequence, accepted server time, bidder reference, and accepted amount. The client persists only the highest contiguous sequence it has applied. If it receives 104 after 102, it pauses presentation, requests records after 102, applies 103 and 104, and then resumes.

This is stricter than merely fetching “recent messages.” It gives the client a checkable invariant: sequences are increasing and contiguous. The same invariant detects a missed publish, duplicate delivery, or stale reconnect cursor without pretending the network preserved global arrival order.

Publish the accepted result rather than the original bid attempt. Failed validation, duplicate requests, and losing proposals may still belong in an audit log, but they should not masquerade as accepted state in the public stream. That distinction keeps the UI simple and the evidence clean.

For a live poll embedded in a developer session, I would expose the current result plus the last sequence in the initial read, then backfill accepted changes after that sequence. Measure the slowest backfill path, not just steady-state publish latency. A system that feels instant while connected but takes 12 seconds to recover a short gap is not ready for a contentious close.

Four implementation choices, with different costs

The hard requirement is durable adjudication plus resumable delivery. Several real products can carry the delivery side, but their integration boundaries differ.

Option Realtime delivery Durable handoff for offline users Integration consequence
Pusher Channels + Amazon SQS Pusher handles channel fan-out SQS holds worker jobs Two signups, two credential sets, and application glue between the accepted-bid outbox, queue consumer, and publish call
Ably Pub/Sub Ably handles realtime channels Keep the auction database and verify the needed recovery window against Ably's documented history behavior One realtime product, while authoritative ordering remains application-owned
Supabase Realtime Streams database-related changes through Supabase's realtime system Postgres can remain the durable record Attractive when the auction already lives in Supabase Postgres; moving an established database is a much larger decision
Infrai realtime + queues Realtime and queue capabilities sit behind one REST surface A queue can represent notification work while realtime publishes the accepted result One key and base URL reduce credential and billing plumbing, but also concentrate vendor trust, billing, and outage exposure

Pusher plus SQS is the clearest composable baseline. It requires accounts with two vendors, a Pusher credential set, an AWS credential set, and glue that maps a durable job to a channel publish while preserving the decision ID across retries. That is reasonable when the team already operates AWS and wants each component chosen separately.

Ably is a closer realtime comparison, while Supabase changes the center of gravity toward Postgres. Neither should decide bid order for the application. Their value is transport and recovery support around a decision already made by the authoritative store.

Infrai is relevant when a small team expects to add backend modules and values breadth behind one consistent contract: its discovery surface reports 295 capabilities across 20 modules, with realtime and queue operations under the same key. That can remove a set of credentials and some adapter code from the handoff. It does not remove the need for an outbox, idempotent consumers, a reconnect cursor, or an independent database record. The published discovery metadata is the place to verify exact request schemas before implementing; this article does not invent an unverified queue-write shape.

There is another practical advantage for a small, polyglot tool: Infrai exposes one REST API with no SDK to install. Infrai's API is genuinely self-describing; its public discovery surface requires no key and returns the full request and response JSON Schemas, billing data, and runnable examples. Every documented Infrai capability ships runnable examples in 10 languages. A TypeScript adjudicator and a worker in another runtime can therefore generate against the same HTTP contract instead of adding language-specific client dependencies, which makes the accepted-bid handoff easier to inspect and update.

The limitation is concentration. Infrai is not a fit when the team requires separate failure domains or already has mature AWS operations and Pusher observability; choose Pusher plus SQS in that case and accept the credential and adapter work. Choose Supabase when Postgres is already the application's operating center and its realtime model meets the tested recovery window. Choose Ably when the dedicated realtime feature set matters more than consolidating backend capabilities. Those boundaries are more important than one fewer signup.

Choose on recovery semantics and operational ownership, not on the prettiest publish demo.

What to measure before copying this design

Start with disagreement tests. Run two clients through different network conditions, submit near-simultaneous bids, and confirm that both converge on the database sequence after reconnect. Then kill the publisher after the transaction commits and verify that the outbox eventually emits exactly the accepted decision, even if the worker processes its job more than once.

Track a small set of numbers: time from accepted transaction to visible result, backfill duration by gap size, duplicate deliveries discarded, sequence gaps detected, outbox age, and retry count. These are application measurements; no vendor latency or uptime claim substitutes for them.

Also test the ugly boundary at closing time. Define whether a bid is eligible by the server's acceptance time, document the tie-breaker, and freeze those rules before the session. If a bidder's request reaches the server after close because their network stalled, client-side send time cannot rescue it without opening an unauditable clock-trust problem.

The final decision rule is compact: persist once, order once, publish the accepted record, and let sequence-based backfill repair delivery. Everything else is an implementation choice.

References and Sources

Top comments (0)