Engineering write-up · 6 min read

Bids that never go out of order.

Live auctions are unforgiving. In the last seconds of a lot, dozens of bidders press the button at once, and the system has one job: decide, correctly and fast, who is winning.

By Ahmed Farid, senior software engineer ·

This is the architecture behind Yelo Sale, a B2B2C vehicle auction platform in Saudi Arabia: a Laravel 11 API serving a Next.js 14 web app and Flutter apps for iOS and Android, in Arabic and English. Fleet owners list cars and lots; verified bidders compete live; the winner reserves and pays online.

The bug every auction starts with: two winners

The naive version of "place a bid" reads the current price, checks the new bid is higher, and writes it. Under light traffic that works. In the final seconds of a lot it does not: two requests read the same price at the same moment, both pass the check, both write, and both bidders are told they are winning. One of them finds out later, which is the moment a marketplace loses a customer's trust.

Row locks in the database fix the correctness, but every bid on a hot lot now waits in line on the same row, and the wait shows up exactly when the auction is most exciting. And whatever the database does, the order must not come from the clients: their clocks drift, and their messages arrive whenever the network delivers them.

Rule one: the server decides the order

A client never decides that it is winning. It proposes a bid; the server accepts or rejects it, and the order in which the server accepts bids is the order of the auction. On Yelo Sale the live bid state sits in Redis and changes through atomic operations, so every accepted bid has a single, unambiguous place in that order. That is what removes lost bids and duplicate winners under final-second load.

The check ("is this bid high enough, is the lot still open?") and the write ("this is the new price, this is the new leader") have to be one step. In Redis, one way to make them one step is a short Lua script, which Redis runs without interleaving anything else. A simplified version of the pattern:

-- KEYS[1]: the lot's live state (a hash: price, step, leader, ends_at, seq)
-- ARGV: bidder id, bid amount, server time
local price = tonumber(redis.call('HGET', KEYS[1], 'price'))
local step  = tonumber(redis.call('HGET', KEYS[1], 'step'))
local ends  = tonumber(redis.call('HGET', KEYS[1], 'ends_at'))
if tonumber(ARGV[3]) >= ends then return {0, 'closed'} end
if tonumber(ARGV[2]) < price + step then return {0, 'too_low'} end
local seq = redis.call('HINCRBY', KEYS[1], 'seq', 1)
redis.call('HSET', KEYS[1], 'price', ARGV[2], 'leader', ARGV[1])
return {seq, 'accepted'}

The sequence number is the useful part: every accepted bid gets the next number, so anything downstream (the broadcast, the bid history, the ERP) can tell which bid came after which without trusting arrival order.

Broadcast what was accepted, not what was attempted

Only after the server accepts a bid does anyone hear about it. On Yelo Sale a Socket.IO layer on Node broadcasts the accepted state to everyone watching the lot, on web and mobile alike, with under 200 ms from bid to broadcast end to end. The rule I would hold any client app to: render the state the server sends, and never show "you are winning" on its own initiative, because an optimistic winner that turns out to be wrong is worse than a short wait.

Keeping one API for all three clients matters here. In an Nx monorepo the web app and the mobile apps share the same contract, so a rule like "a lot that has closed refuses bids" lives in one place, not in three.

Proxy bidding belongs on the server too

Proxy auto-bidding lets a bidder set a ceiling and walk away: the system counters rivals on their behalf, up to that ceiling, so they win at the lowest price necessary. On Yelo Sale it runs server-side, like the bids themselves. It is tempting to treat this as a background job that "reacts" to new bids. That reintroduces the race: two proxies reacting to the same bid are two writers again.

The way I would structure it in any auction: resolve proxies inside the same server-side step that accepts the bid. When a new bid arrives, compare it with the standing ceilings, work out where the price settles (typically the higher ceiling wins, one step above the lower one), and record that outcome as accepted bids in order. Then broadcast once.

After the hammer: the lot is a state machine

Bidding is the exciting part; the money is in what happens next. Every lot on Yelo Sale moves through listed → live auction → reserved → paid → delivered, with admin governance at each step, and that is where payments (Amazon Payfort), PDF and QR invoices and the two-way sync with Microsoft Dynamics 365 come in. The rule I apply to anything like this: tie side effects to transitions, never to individual bids. A transition happens once or not at all, which is what keeps an ERP and a platform agreeing about which car was sold to whom.

Access follows the same idea of hard boundaries: admins, companies and customers each sign in behind their own guard. Separate guards are a cheap way to make sure a token issued to one kind of user is never accepted on another kind's endpoints.

What I would tell a team building an auction