Requirements
- Users can create auction listings with a start price, reserve price, and end time
- Users place bids; highest bid wins when auction ends
- Auto-bidding (proxy bidding): user sets maximum bid; system bids automatically up to that max
- Real-time bid notifications to all watchers
- Prevent race conditions: two users bidding simultaneously — only one wins
- 1M concurrent auctions, 10K bids/second
Data Model
Auction(auction_id UUID, seller_id, title, description, start_price DECIMAL,
reserve_price DECIMAL, current_price DECIMAL, current_winner_id,
start_time, end_time, status ENUM(PENDING,ACTIVE,ENDED,CANCELLED),
bid_count INT)
Bid(bid_id UUID, auction_id, bidder_id, amount DECIMAL, bid_type ENUM(MANUAL,AUTO),
proxy_max DECIMAL, placed_at, status ENUM(WINNING,OUTBID,INVALID))
AuctionWatcher(auction_id, user_id, notify_on ENUM(NEW_BID,OUTBID,ENDING_SOON))
Bid Placement (Preventing Race Conditions)
Two users bidding at the same millisecond. Without locking, both could see current_price = $50 and both place bids at $60, creating two “winning” bids.
def place_bid(auction_id, bidder_id, amount):
# Optimistic locking with version check
BEGIN TRANSACTION
SELECT auction_id, current_price, current_winner_id, version
FROM Auction WHERE auction_id = :id FOR UPDATE
if auction.status != 'ACTIVE':
ROLLBACK; raise AuctionNotActive
if now() > auction.end_time:
ROLLBACK; raise AuctionEnded
if amount <= auction.current_price:
ROLLBACK; raise BidTooLow(min_bid=auction.current_price + 0.01)
UPDATE Auction SET current_price = :amount,
current_winner_id = :bidder_id, bid_count = bid_count + 1,
version = version + 1 WHERE auction_id = :id
INSERT INTO Bid(auction_id, bidder_id, amount, status) VALUES (...)
# Mark previous winning bid as OUTBID
UPDATE Bid SET status = 'OUTBID' WHERE auction_id = :id
AND status = 'WINNING' AND bid_id != :new_bid_id
COMMIT
publish_bid_event(auction_id, amount, bidder_id)
The SELECT FOR UPDATE serializes concurrent bids on the same auction. Only one transaction holds the lock at a time; the second waits, then sees the updated current_price and fails the amount check if it bid the same amount.
Auto-Bidding (Proxy Bidding)
User sets a maximum proxy bid. System bids on their behalf, incrementing by the minimum bid step each time they are outbid, up to their max. Logic: when a new manual bid comes in at amount A, check if any proxy bid has proxy_max > A. If yes: outbid amount = min(proxy_max, A + bid_step). The proxy bidder wins at the minimum amount needed to beat the manual bidder. Store proxy_max encrypted in the Bid table; never expose it to other users. Process proxy bids synchronously within the same transaction as the incoming bid.
Real-time Notifications
On each bid event published to Kafka: the notification service consumes and pushes to all watchers via WebSocket (for web clients) or APNs/FCM (for mobile). WebSocket server maintains a mapping of auction_id → [connected client WebSocket handles]. On bid event: broadcast to all handles for that auction_id. Scale WebSocket servers horizontally: use Redis Pub/Sub to fan out bid events across WebSocket server instances. Each WebSocket server subscribes to Redis channel auction:{auction_id} for auctions its clients are watching.
Auction Ending
Auction end is time-based. Options: (1) Cron job that runs every second, queries auctions WHERE end_time <= NOW() AND status='ACTIVE', marks them ENDED, triggers winner notification. At 1M auctions, this query is expensive. (2) Delayed job queue (preferred): when an auction is created, schedule a job at end_time using a delayed queue (Redis ZADD with score=end_time, a worker polls ZRANGEBYSCORE). On job fire: mark auction ENDED, charge winner's payment method, notify seller and winner. (3) Soft ending: an auction is only ended when queried after its end_time — lazy evaluation. Simpler but notifications are delayed until someone looks.
Sniping Prevention
Auction sniping: a bid placed in the last second. Solution: soft closing — if a bid is placed within the last 5 minutes of an auction, extend the end_time by 5 minutes. Update end_time in the DB; push updated end_time to all watchers. This mirrors how eBay-style auctions work.
Key Design Decisions
- SELECT FOR UPDATE on auction row — serializes concurrent bids, prevents race conditions
- Proxy bidding processed in same transaction as incoming bid — atomic, consistent
- Delayed job queue for auction ending — avoids expensive cron scan at scale
- Redis Pub/Sub for WebSocket fan-out — decouples bid events from WebSocket server count
- Soft closing (end time extension) — prevents last-second sniping
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you prevent race conditions when two users bid simultaneously on an auction?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use SELECT FOR UPDATE to serialize concurrent bids on the same auction row. When User A and User B bid at the same time: both start a transaction and execute SELECT … FOR UPDATE on the auction row. Only one transaction acquires the lock; the other blocks. The first transaction updates current_price to A's bid and commits, releasing the lock. The second transaction then acquires the lock, re-reads current_price (now A's bid), and if B's bid is not higher, the transaction fails with BidTooLow. This ensures the auction's current_price is always the true highest bid and only one winner exists. The FOR UPDATE lock is held only for the duration of the bid transaction (~few milliseconds), so concurrent bidders on different auctions are unaffected.”}},{“@type”:”Question”,”name”:”How does proxy bidding (auto-bidding) work in an online auction?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Proxy bidding lets users set a maximum they are willing to pay. The system bids on their behalf up to that maximum. When a new manual bid comes in at amount A: check if any proxy bid has proxy_max > A. If yes, the proxy bidder automatically bids at min(proxy_max, A + bid_step), winning at the minimum necessary amount. Example: Alice sets proxy max = $100. Bob bids $50. System automatically bids Alice at $51. Bob bids $80. System bids Alice at $81. Bob bids $105, exceeding Alice's max — Bob wins at $101 (Alice's max + 1 step). Store proxy_max encrypted in the Bid table; never expose to other users. Process proxy bids within the same DB transaction as the incoming bid to maintain atomicity. Proxy bids appear in bid history as system bids.”}},{“@type”:”Question”,”name”:”How do you implement auction ending reliably at scale?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Avoid cron-scanning all active auctions every second — at 1M auctions this is an expensive query. Better approach: delayed job queue. When an auction is created with end_time T, schedule a job at time T: ZADD auction_endings {T_epoch} {auction_id} in Redis. A worker polls: ZRANGEBYSCORE auction_endings 0 {now} LIMIT 0 100 to fetch auctions that should have ended. For each: BEGIN TRANSACTION; SELECT … FOR UPDATE; if status=ACTIVE and now > end_time: UPDATE status=ENDED; charge winner; COMMIT. Remove from the sorted set: ZREM auction_endings {auction_id}. Worker runs every second. At 1M auctions, only the ones ending right now are processed — typically very few per second. Handle worker failures: if a worker crashes mid-processing, the auction_id remains in the sorted set and is retried on the next poll. Use idempotency: UPDATE … WHERE status=ACTIVE prevents double-ending.”}},{“@type”:”Question”,”name”:”How do you push real-time bid updates to all auction watchers?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”On each bid: publish a bid event to Kafka: {auction_id, new_price, bidder_display_name, bid_count, end_time}. A notification service consumes from Kafka and pushes to all watchers. For web clients: WebSocket connections. For mobile: APNs (iOS) and FCM (Android). WebSocket architecture: each WebSocket server handles N concurrent connections. When a client opens an auction page, they connect to a WebSocket server and subscribe to that auction: SUBSCRIBE auction:{auction_id}. The WebSocket server subscribes to Redis Pub/Sub channel auction:{auction_id}. Notification service publishes to Redis: PUBLISH auction:{auction_id} {bid_event_json}. All WebSocket servers subscribed to that channel receive the event and forward to their connected clients. This fan-out scales to any number of WebSocket servers without direct coordination.”}},{“@type”:”Question”,”name”:”What is auction sniping and how do you prevent it?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Auction sniping is placing a bid in the last seconds of an auction before other bidders can respond. It is legal but frustrating on fixed-end auctions. Solution: soft closing (automatic extension). If a bid is placed within the last N minutes of an auction (typically 3-5 minutes), automatically extend the end_time by N minutes. SQL: UPDATE Auction SET end_time = end_time + INTERVAL 5 MINUTE WHERE auction_id = :id AND end_time – NOW() < INTERVAL 5 MINUTE AND status = ACTIVE. Notify all watchers of the new end_time via WebSocket/push notification. This continues as long as bids keep coming in the final window. Update the delayed job queue: remove the old ending job and schedule a new one at the updated end_time. The soft close incentivizes bidders to bid their true maximum early via proxy bidding rather than waiting to snipe.”}}]}
Shopify system design covers e-commerce and transaction systems. See common questions for Shopify interview: auction and e-commerce system design.
Stripe system design covers payment flows for auction and marketplace systems. Review patterns for Stripe interview: payment processing for auction systems.
Coinbase system design covers order matching and real-time bidding. See design patterns for Coinbase interview: real-time bidding and order matching design.