Blog

Server-authoritative bids, countdown clock sync, optimistic UI and reconnection: the architecture behind real-time bidding with Socket.io and PostgreSQL.

Kasim KazmiUpdated 6 min read
AlphaAuctions live online auction platform

A real-time bidding system is trustworthy when the server, and only the server, decides which bid wins and when the auction ends; everything the browser shows is a projection of that decision. This post covers the architecture principles I apply to real-time bidding, grounded in building AlphaAuctions: server-authoritative bid acceptance, countdown clocks that do not drift, optimistic UI that never lies, reconnection, and race conditions.

AlphaAuctions is built with Next.js, TypeScript, Tailwind CSS, Socket.io, Prisma and PostgreSQL. The patterns apply to any stack with a WebSocket layer over a relational database.

Why is real-time bidding hard to get right?

An auction only works if bidders trust the number on screen. If a price is stale by even a couple of seconds, a bidder can believe they are winning an item they have already lost, or hesitate because the countdown they see does not match reality.

Polling an API every few seconds makes this worse, not better. Each client sees a different snapshot depending on when its last request landed. AlphaAuctions uses Socket.io instead, so bid updates, price changes and countdown state are pushed to every client in a bidding room as they happen.

Push transport solves latency. It does not solve correctness. Correctness comes from deciding where the truth lives.

Rule one: the server is the only source of truth

The browser never decides that a bid succeeded. A client sends an intent: I want to bid this amount on this listing. The server validates it against the current state in PostgreSQL, writes it, and only then broadcasts the new state to the room.

Validation on the server checks the things a client cannot be trusted with:

  • The auction is still open according to the server's clock, not the client's.
  • The amount beats the current highest bid by at least the minimum increment.
  • The bidder is authenticated and allowed to bid on this listing.
  • The request is not a duplicate of one already processed, for example a retry after a dropped connection.

How do you stop two bids from overwriting each other?

This is the race condition every auction has to handle. Two bidders submit $510 and $520 within the same few milliseconds. If the server reads the current price, checks it in application code and then writes, both requests can read $500, both pass validation, and whichever write lands last wins, even if it is the lower bid.

The fix is to make the database enforce the rule, so the check and the write happen as one atomic step. There are two common ways to do this with Prisma and PostgreSQL. One is a conditional update: update the listing's current price only where the stored price is still lower than the new bid, then check how many rows changed. If zero rows changed, someone else got there first and the bid is rejected with the latest price. The other is to lock the listing row inside a transaction while the bid is validated and inserted.

Either way, ordering is decided by the database, not by which socket message happens to arrive first. That is what makes concurrent bids resolve deterministically, which was one of the core requirements in the case study.

Keeping countdown clocks in sync without drift

Countdown timers are where real-time apps quietly break. If each client counts down from a duration, clocks drift: a backgrounded tab throttles its timers, a laptop wakes from sleep, a phone's clock is a minute off. Two bidders end up looking at different end times.

The approach that holds up is to never rely on a duration alone. The server sends the absolute end time of the auction along with its own current time. The client calculates the offset between the server clock and its local clock once, then renders the countdown as end time minus (local now plus offset) on every animation frame or tick. If the tab sleeps, the next tick is still correct, because it is recalculated from absolute values rather than decremented.

Two more rules keep this honest. The client's countdown is only a display, and the server's clock decides when bidding closes. And when the server closes an auction, it broadcasts a final state so every client snaps to the result, regardless of what its local timer shows.

Optimistic UI versus confirmation

Optimistic UI is the usual answer for making interfaces feel instant. Show the result immediately and reconcile later. For bids, a narrower version of it works better.

When a bidder submits, the interface immediately shows the bid as pending: the button is disabled, the amount is shown with a subtle in-flight state. What it does not do is show the bidder as the highest bidder or update the price everyone sees. That only happens when the server's broadcast arrives, because that broadcast is the confirmation.

This keeps the experience fast without ever showing something false. If the bid is rejected because someone outbid it in the meantime, the pending state simply resolves to the new price with a clear message. There is no awkward rollback of a price that was never real.

What happens when a bidder's connection drops?

Connections drop all the time: mobile networks, Wi-Fi handoffs, laptops closing. Socket.io reconnects automatically, but reconnecting the socket is not the same as recovering state. Any broadcasts sent while the client was offline are gone.

So on every reconnect, the client rejoins the listing's room and asks for a fresh snapshot: current price, highest bidder, end time and recent bid history. The snapshot replaces local state entirely rather than trying to merge missed events. It is simpler and it cannot get out of order.

Because bid history, current price and listing state live in PostgreSQL through Prisma, a refresh or a dropped connection never loses data. The real-time layer is a delivery mechanism. The database is the record.

A bid submitted just before a disconnect is the awkward case. The client does not know whether it landed. Sending a client-generated ID with every bid lets the client safely resend after reconnecting, and lets the server recognise and ignore the duplicate.

Real-time does not have to mean slow

A common assumption is that a socket-driven page has to trade away load performance. The production Lighthouse results in the case study say otherwise: 100 for performance, 100 for SEO, a 1.4s Largest Contentful Paint and zero Cumulative Layout Shift, on a page that opens a WebSocket connection and renders a live price chart.

The main lever is being deliberate about what has to be ready before first paint. The listing content renders first, and the socket connection and chart attach once the page is already visually complete. Reserving space for the chart and countdown up front is what keeps layout shift at zero when live data arrives.

Trade-offs and what I'd do differently

Server-authoritative bidding adds a round trip before a bidder sees confirmation. On a normal connection it is barely noticeable, and it is the right trade for an auction, where being wrong costs more than being slightly slower.

A single Socket.io server is simple and enough for one instance. Running several instances behind a load balancer needs a shared adapter, such as a Redis adapter, so a broadcast from one instance reaches clients connected to another. If I expected heavy concurrent traffic from launch, I would set that up from the start rather than add it later.

I would also write the bid acceptance rules as a small, pure function with its own tests from day one. Increments, close-time checks and duplicate detection are exactly the logic you want to test in isolation, away from sockets and databases.

Building a real-time product?

If you are planning an auction, marketplace, live dashboard or anything else where many users need to see the same state at once, I can help with the architecture and the build. See my service packages or get in touch, or read the full AlphaAuctions case study first.

Back to all articles
Blog and articles

Notes from client work and side projects: architecture decisions, tooling and what shipped.

  • MapleWealth personal finance dashboard for Canadian users

    Modeling TFSA, FHSA and RRSP Rules in Software

    How I model TFSA, FHSA and RRSP contribution room in MapleWealth: ledgers instead of balances, carry-forward, re-contribution timing and the edge cases.

    8 min read
    Read more
  • Dev Tools suite of free browser-based developer and design tools

    Building Dev Tools: 59 Free Browser Tools, No Backend

    Why I built 59 free developer and design tools that run entirely in the browser, how one data file drives them, and what I'd change.

    6 min read
    Read more
Contact

Tell me what you are building. In a free 30-minute call we will map the scope, the right stack and whether AI belongs in it, and you will leave with a clear next step.

Prefer a quick chat? Message me on WhatsApp
Toronto · Canada