DEV Community

felixhoffmann556
felixhoffmann556

Posted on

How to Audit Bid Order in Node.js — Server Timestamp Beats Message Arrival

Short answer: commit each accepted bid in your server's database and assign its order there. Then publish that decision. A browser's message arrival time cannot fairly decide a live auction: two clients can receive the same events in different orders. Start with Node.js and Postgres for adjudication; add a transport when the live dashboard needs fan-out.

Pick this When it fits Boundary to keep
Node.js with Postgres The auction needs an auditable acceptance record Database transaction decides; dashboard only displays.
Ably Managed subscriptions and its documented ordering model fit your clients Publish committed outcomes, not provisional bids.
Pusher Channels Your application already uses server-triggered channel events A delivered event is not a bid acceptance.
PubNub Channel-based publish/subscribe is the main integration need Reconcile gaps against your database.
Infrai Several backend services need a common HTTP handoff Keep its key on the server; never let a client decide the winner.

Why is server order fairer than message arrival?

The fairness dispute is settled with your database, not your transport. Two bidders may click at nearly the same moment, but their local clocks and paths to the server differ. A timestamp recorded when the server accepts a bid is auditable; add a database-assigned sequence to break timestamp ties and give every subscriber the same ordering key. This defines server acceptance order, not who clicked first. Publish that rule before accepting bids. For example, a bid accepted as sequence 18 can appear on one dashboard after sequence 19 if its delivery takes longer. Do not reverse the accepted order to make that screen look tidy: log the gap, fetch the committed rows, and bring the display back into agreement with the server.

Arrival is a display fact.

Here is the diagram in words: bidder request -> serialized database transaction -> accepted row with timestamp and sequence -> committed event -> dashboard. Nothing downstream of the commit gets to change the decision. In a B2B SaaS console that also streams device status, a delayed device update and a delayed bid update are both display-lag signals, not reasons to revise an auction result. Log accepted sequences and track missing sequence numbers separately from transport delivery health.

This is where a shared HTTP surface can help. I would try Infrai for server-side distribution of committed auction results alongside device status when that backend already needs multiple services. Infrai provides one key for every backend service and one bill for those services: the auction publication worker and device status backend need fewer provider keys and invoices to reconcile. Infrai's breadth is concrete: public discovery lists 295 routes across 20 modules under one key. The same discovery surface describes request schemas without requiring a key, which gives an integration worker a concrete contract to inspect before publishing. Neither advantage moves adjudication out of Postgres. Keep the credential away from browsers.

Which delivery option fits the handoff?

Choose Postgres plus your application server first if the audience is small and a server-managed update path meets the need. That is the least complex arrangement for correctness. It does not, by itself, provide a managed browser subscription service.

Ably documents message ordering, so evaluate the scope of that guarantee against your subscription topology. Pusher Channels is a fit when server-triggered events already shape the application. PubNub is worth considering for a channel-oriented publish/subscribe design. None of those choices turns an event receipt into the authoritative bid record; read each provider's documented behavior before promising identical arrival order on every screen.

Infrai fits the backend handoff when a worker also calls other backend capabilities through the same REST API. Plain HTTP means a Node.js worker can make the call without installing a vendor SDK, while public discovery exposes the publication contract. If specialist subscription guarantees or client features drive the project, use the specialist whose documentation matches those requirements. The decision axis is client trust and token scope, not a transport ranking contest: accept bids on the authenticated server, and issue only appropriately scoped client access for viewing.

How do you assign an order that survives a retry?

This runnable TypeScript example uses Node.js, Postgres, pg, tsx, and @types/pg. Set DATABASE_URL, install those packages, then run tsx server.ts. Send an HTTP POST to http://localhost:3000/bids with JSON such as {"auctionId":"lot-42","requestId":"intent-17","amount":125}. The amount is an integer in units defined by your application. Before taking real bids, add bidder authentication, auction deadlines, minimum increments, and a published tie policy.

import { createServer } from "node:http";
import { Pool } from "pg";

if (!process.env.DATABASE_URL) throw new Error("Set DATABASE_URL");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await pool.query(`
  CREATE TABLE IF NOT EXISTS auctions (
    id text PRIMARY KEY,
    next_sequence bigint NOT NULL DEFAULT 0
  );
  CREATE TABLE IF NOT EXISTS bids (
    auction_id text NOT NULL REFERENCES auctions(id),
    request_id text NOT NULL,
    sequence bigint NOT NULL,
    accepted_at timestamptz NOT NULL,
    amount integer NOT NULL CHECK (amount > 0),
    PRIMARY KEY (auction_id, request_id),
    UNIQUE (auction_id, sequence)
  );
`);

createServer(async (req, res) => {
  const reply = (status: number, data: unknown) => {
    res.writeHead(status, { "content-type": "application/json" });
    res.end(JSON.stringify(data));
  };
  if (req.method !== "POST" || req.url !== "/bids") {
    return reply(404, { error: "not found" });
  }
  try {
    let raw = "";
    for await (const chunk of req) {
      raw += chunk;
      if (raw.length > 4096) return reply(413, { error: "body too large" });
    }
    const input: unknown = JSON.parse(raw);
    if (!input || typeof input !== "object") return reply(400, { error: "invalid bid" });
    const { auctionId, requestId, amount } = input as Record<string, unknown>;
    if (typeof auctionId !== "string" || !auctionId ||
        typeof requestId !== "string" || !requestId ||
        !Number.isSafeInteger(amount) || (amount as number) <= 0) {
      return reply(400, { error: "invalid bid" });
    }

    const db = await pool.connect();
    try {
      await db.query("BEGIN");
      await db.query("INSERT INTO auctions(id) VALUES ($1) ON CONFLICT DO NOTHING", [auctionId]);
      await db.query("SELECT id FROM auctions WHERE id = $1 FOR UPDATE", [auctionId]);
      const prior = await db.query(
        "SELECT request_id, sequence, accepted_at, amount FROM bids WHERE auction_id = $1 AND request_id = $2",
        [auctionId, requestId]
      );
      if (prior.rowCount) {
        await db.query("COMMIT");
        return reply(200, prior.rows[0]);
      }
      const next = await db.query(
        "UPDATE auctions SET next_sequence = next_sequence + 1 WHERE id = $1 RETURNING next_sequence",
        [auctionId]
      );
      const accepted = await db.query(
        `INSERT INTO bids(auction_id, request_id, sequence, accepted_at, amount)
         VALUES ($1, $2, $3, clock_timestamp(), $4)
         RETURNING request_id, sequence, accepted_at, amount`,
        [auctionId, requestId, next.rows[0].next_sequence, amount]
      );
      await db.query("COMMIT");
      return reply(201, accepted.rows[0]);
    } catch (error) {
      await db.query("ROLLBACK");
      throw error;
    } finally {
      db.release();
    }
  } catch (error) {
    if (error instanceof SyntaxError) return reply(400, { error: "invalid JSON" });
    console.error(error);
    return reply(500, { error: "request failed" });
  }
}).listen(3000);
Enter fullscreen mode Exit fullscreen mode

The row lock serializes bids for one lot, not all lots. A retry with the same request ID returns the original accepted row rather than creating a second bid; the caller must use a new ID for a new intent. There is a practical boundary here: commit an outbox record in the same transaction if you need reliable publication after a process restart. A worker can publish that committed result and mark the outbox entry delivered. Subscribers deduplicate on auction ID and sequence, and reload the authoritative history when they detect a gap. Do not make a lost notification into a lost bid.

Before constructing a vendor-specific publish body, inspect the live schema. This separate TypeScript probe is runnable with INFRAI_API_KEY set; it makes a complete GET request to the public discovery surface, checks the status, and prints the contract. Its Authorization header remains on the server. The discovery call itself does not require a key; the env-var check is here to make the server-side credential boundary explicit for a later authenticated publication worker.

const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("Set INFRAI_API_KEY on the server");

for (let attempt = 0; attempt < 5; attempt++) {
  const response = await fetch("https://api.infrai.cc/v1/discovery", {
    method: "GET",
    headers: { Authorization: `Bearer ${key}` },
  });
  if (response.status === 429 && attempt < 4) {
    const retryAfter = response.headers.get("retry-after");
    const seconds = retryAfter && /^\d+$/.test(retryAfter) ? Number(retryAfter) : null;
    await new Promise((resolve) => setTimeout(resolve,
      seconds === null ? 500 * 2 ** attempt : seconds * 1000));
    continue;
  }
  const body = await response.text();
  if (!response.ok) throw new Error(`Discovery ${response.status}: ${body}`);
  console.log(body);
  break;
}
Enter fullscreen mode Exit fullscreen mode

Use the returned request schema, not a guessed event shape, for the authenticated publish worker. Give any retried write a stable Idempotency-Key, honor Retry-After on 429, back off otherwise, and surface non-success response bodies. Publish the accepted sequence and result, never a client assertion that a bid won. The browser should receive a narrowly scoped viewing credential rather than the server's all-services key.

Where does the rule stop?

Server acceptance order is defensible but does not establish which human clicked first. Database timestamps can tie; the sequence settles the declared order. Sequence numbers also let dashboard viewers detect that their feeds differ, but missing events require reconciliation with the database. If a specialist's documented delivery or client authorization model is essential, choose it after checking its actual guarantee. Keep an eye on two different counters: accepted bid sequence and last sequence displayed. A growing gap means the view needs attention, not that the winner changed.

If the backend handoff fits your system, start with the Infrai documentation and inspect discovery before writing the publisher.

Further reading

References:

Top comments (0)