Skip to main content
Markdown

Live auctions

An auction that runs live on the page: the price moves as bids come in, the clock counts down to its end, a bid in the last seconds gives everyone time to answer, the bidder who was beaten hears it on any page, and the winner hears it as the auction closes. Anyone watches, signed in or not, and those who sign in bid.

Your server decides every bid. A page never sends a price to the others: it asks your server, which takes the bid or refuses it in one step, then tells everyone the new price. What the page shows is only ever what your server decided.

The room​

A room for each auction, named after it: auction:7. Your server sends the bids, the end and the result into it, and every page in it shows them.

A room is declared by a rule your server registers once for every name that fits a pattern, here auction:{id}, and WordPlus Cloud holds every page to it (permissions). This one says:

  1. Anyone watches, signed in or not: the room is public, and counts who is in it.
  2. Nobody sends into it: it lists no emit, so no page can send a bid or a price to the others. Your server alone speaks there.
// 1. Anyone watches, signed in or not: a public room, counted.
// 2. It lists no `emit`: no page sends into it, the price comes from your server alone.
realtime.room( 'auction:{id}', { public: true, count: true, history: 20 } );
  • A public room needs no rule: anyone joins it, signed in or not, with a grant that names no user, which may sit in a cached page (rooms). It is not sealed, which suits an auction, whose bids are public. Show each bidder by a name of your own choosing, such as Bidder 4.
  • count gives the room how many watch, live.
  • history carries the last 20 events for 5 minutes, so a page that reconnects gets the bids it missed, in order.

A bid​

The Bid button of a page calls a route of yours with the amount. Your server decides it in one step, while no other bid on that auction is decided:

  1. The bidder is signed in, and may bid on this auction: never its seller.
  2. The auction is open, its end not passed by the clock of your server, and the amount is at least the price and a step above it. Otherwise the bid is refused, with the price as it stands.
  3. A bid in the last 30 seconds moves the end 30 seconds on, so nobody wins by bidding as the clock runs out.
  4. The new price goes to everyone, with the name of the bidder and the end, and the bidder it beat hears it on every page they have open.
  5. The answer to the bidder says it leads, at that price: their own acknowledgment, from your server.
app.post( '/auctions/:id/bids', async ( req, res ) => {
// 1. A signed-in bidder, never the seller.
if ( !req.user || !( await mayBid( req.params.id, req.user.id ) ) ) return res.sendStatus( 403 );
const amount = Number( req.body.amount );

const outcome = await db.transaction( async ( tx ) => {
const auction = await tx.lockAuction( req.params.id ); // SELECT … FOR UPDATE: one bid at a time

// 2. Open, its end not passed, and a step above the price.
if ( auction.closed || Date.now() >= auction.endsAt ) return { refused: 'closed', auction };
if ( !( amount >= auction.price + auction.step ) ) return { refused: 'too_low', auction };

// 3. In the last 30 seconds, the end moves 30 seconds on.
const endsAt = Math.max( auction.endsAt, Date.now() + 30_000 );
await tx.saveBid( auction.id, req.user.id, amount, endsAt );
return { auction: { ...auction, price: amount, endsAt }, beaten: auction.leaderId };
} );
const { auction, beaten, refused } = outcome;
if ( refused ) return res.status( 409 ).json( { error: refused, price: auction.price, ends_at: auction.endsAt } );

// 4. The new price to everyone, and to the bidder it beat.
await realtime.to( 'auction:' + auction.id ).emit( 'bid', { price: amount, bidder: await bidderName( auction.id, req.user.id ), ends_at: auction.endsAt } );
if ( beaten && beaten !== String( req.user.id ) ) await realtime.toUser( beaten ).emit( 'outbid', { auction: auction.id, price: amount } );

// 5. The answer to the bidder: their bid leads.
res.json( { price: amount, ends_at: auction.endsAt } );
} );
  • One bid at a time: the lock on the auction makes two bids of the same amount at the same moment one that leads and one refused, whatever the pages showed.
  • Your server sends after it commits, so a page never shows a bid your database does not hold.
  • The answer is the acknowledgment: the bidder learns at once that they lead, or why not, too_low or closed with the price now. A bid sent into the room by the page could not be acknowledged this way: it would reach every page before your server could refuse it.

The clock and the close​

The clock of your server decides when an auction ends. The pages count down to ends_at, which each bid carries, and a job of your server closes the auction once its end has passed:

  1. At its end, the job closes it, and checks first: a bid in the last seconds may have moved the end, and the job then runs again at the new one.
  2. Everyone sees it sold, at the price, to the name of the winner, and the winner hears it on every page they have open.
async function closeIfOver( id ) {
// 6. Closed only once its end has passed, in one update: a moved end leaves it open.
const auction = await closeAuctionIfOver( id );
if ( !auction ) return scheduleClose( id );

// 7. Sold, to everyone, and to the winner.
const winner = auction.leaderId ? await bidderName( id, auction.leaderId ) : null;
await realtime.to( 'auction:' + id ).emit( 'closed', { price: auction.price, winner } );
if ( auction.leaderId ) await realtime.toUser( auction.leaderId ).emit( 'won', { auction: id, price: auction.price } );
}

scheduleClose( id ) is your own job runner, set to run closeIfOver( id ) at the end the auction holds now.

  • On WordPress, WP-Cron runs my_auction_close on the first request to your site after its time, so the word Sold may come later than the end. A bid after the end is refused by the route all the same, as the route checks the end itself. A system cron that calls wp-cron.php every minute keeps Sold within a minute of the end.

The page​

Your server draws the page with the auction as it stands: its price, its end, and its own time now, so the page knows how far the clock of the device is off. Then the room keeps it current:

const auction = rt.join( 'auction:7' );
let { price, endsAt } = initial; // as your server drew the page
let offset = initial.now - Date.now(); // the server clock less the clock of this device

auction.on( 'bid', ( bid, meta ) => {
offset = meta.at - Date.now(); // when WordPlus Cloud sent it, by its clock
price = bid.price;
endsAt = bid.ends_at;
showPrice( bid.price, bid.bidder );
} );
auction.on( 'closed', ( { price, winner } ) => showSold( price, winner ) );
auction.on( 'count', ( n ) => ( watching.textContent = n + ' watching' ) );
setInterval( () => showClock( Math.max( 0, endsAt - ( Date.now() + offset ) ) ), 250 );

bidButton.onclick = async () => {
const answer = await fetch( '/auctions/7/bids', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( { amount: price + step } ),
} );
const result = await answer.json();
if ( answer.ok ) showLeading( result.price ); // the bid leads
else showRefused( result.error, result.price ); // too_low or closed, with the price now
};

rt.on( 'outbid', ( { price } ) => toast( 'Someone bid ' + price ) ); // this bidder, on any page
rt.on( 'won', ( { price } ) => toast( 'You won, at ' + price ) );
  • meta.at is when WordPlus Cloud sent the bid, by its own clock, which stays within a moment of a server on time. Each bid sets the clock of the page right again.
  • The countdown is only what the page shows. A bid that reaches your server after the end is refused, whatever the page showed.
  • A visitor watches the same page, with Sign in to bid in place of the button. The outbid and won notices need a signed-in user.

From your server​

await realtime.in( 'auction:7' ).count();                         // how many watch it now
await realtime.listRooms( { prefix: 'auction:' } ); // the auctions watched now, each with its count
await realtime.to( 'auction:7' ).emit( 'paused', { reason: 'The seller is checking a question' } );
  • The auctions watched now, from listRooms(), for a list of the most watched on your home page.
  • Anything else the page should show goes the same way: a pause, a note from the seller, a reserve price met.

Variations​

  • Bids from your phone apps: the iOS and Android apps call the same route, and join the same room (iOS rooms, Android rooms).
  • The auctioneer live on video: a live stream on the same page, with the bids beside it (live streaming).
  • Only for registered bidders to watch: a rule in place of public, which lets in those your server registered for the auction, and seals the room (rooms).
  • Many auctions on one page, such as a list of lots ending soon: the page joins the room of each, up to 1,000 at once (limits).
  • On another stack, the guide of each sets up the routes and the page: Next.js, Express, Laravel, Django, FastAPI, WordPress.