We develop auction applications where every millisecond of latency means a lost lot. Imagine: 30 seconds left on a rare item, and suddenly the server hangs for 2 seconds — the user loses their chance. We handle such scenarios with WebSocket architecture and optimistic locking. Below, we cover key technical decisions: WebSocket with auto-reconnection, race condition handling, auto-bidding (proxy bidding), and anti-sniping.
An auction app is more than a catalog. Its heart is real-time, where data consistency is critical. Without proper versioning and business logic, users see stale prices and lose bids. Here’s a proven architecture that handles thousands of concurrent connections.
How to Build a Mobile Auction App from Scratch
We start by choosing the transport and sync protocol. We use WebSocket (RFC 6455) and optimistic locking at the database level. We define events: BID_PLACED, AUCTION_EXTENDED, AUCTION_ENDED, YOU_WON, YOU_WERE_OUTBID. Each event contains the lot version for consistency checks.
Why WebSocket, Not Polling?
Polling is unacceptable for auctions: 5-10 second HTTP delays cause lost bids. WebSocket with reconnection is the standard, delivering <100 ms latency — 50-100 times faster than polling, critical for high-activity auctions.
| Method | Latency | Reliability |
|---|---|---|
| Polling (HTTP) | 5-10 seconds | Medium — frequent requests overload server |
| WebSocket | <100 ms | High — automatic reconnection |
Implementation on Swift and Kotlin:
Swift WebSocket (URLSessionWebSocketTask)
// iOS: WebSocket via URLSessionWebSocketTask
class AuctionWebSocket {
private var webSocketTask: URLSessionWebSocketTask?
private var reconnectTimer: Timer?
func connect(auctionId: String) {
let url = URL(string: "wss://api.yourauction.com/auctions/\(auctionId)/live")!
webSocketTask = URLSession.shared.webSocketTask(with: url)
webSocketTask?.resume()
receive()
}
private func receive() {
webSocketTask?.receive { [weak self] result in
switch result {
case .success(let message):
if case .string(let text) = message {
self?.handleMessage(text)
}
self?.receive()
case .failure:
self?.scheduleReconnect()
}
}
}
private func scheduleReconnect() {
reconnectTimer?.invalidate()
reconnectTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
self?.connect(auctionId: self?.currentAuctionId ?? "")
}
}
}
Kotlin WebSocket (OkHttp)
// Android: OkHttp WebSocket
class AuctionWebSocketManager(
private val client: OkHttpClient,
private val scope: CoroutineScope
) {
private val _events = MutableSharedFlow<AuctionEvent>()
val events: SharedFlow<AuctionEvent> = _events.asSharedFlow()
fun connect(auctionId: String) {
val request = Request.Builder()
.url("wss://api.yourauction.com/auctions/$auctionId/live")
.build()
client.newWebSocket(request, object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
scope.launch {
val event = json.decodeFromString<AuctionEvent>(text)
_events.emit(event)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
scope.launch {
delay(3000)
connect(auctionId)
}
}
})
}
}
How to Avoid Race Conditions with Simultaneous Bids?
The toughest business logic: two users bid at the same time. Example:
- Current bid: $1000
- User A sees $1000, bids $1100
- User B sees $1000, bids $1050
- Both bids hit the server at once
The right solution is optimistic locking with lot versioning. It outperforms pessimistic locking because it doesn't lock the row on reads, allowing high throughput. The server checks the lot version before updating:
def place_bid(user_id: str, lot_id: str, amount: Decimal, expected_version: int) -> BidResult:
with db.transaction():
lot = db.select_for_update(f"SELECT * FROM lots WHERE id = %s", lot_id)
if lot.version != expected_version:
return BidResult(
success=False,
reason="LOT_UPDATED",
current_bid=lot.current_bid,
new_version=lot.version
)
if amount <= lot.current_bid:
return BidResult(
success=False,
reason="BID_TOO_LOW",
current_bid=lot.current_bid,
new_version=lot.version
)
db.execute(
"UPDATE lots SET current_bid=%s, current_bidder=%s, version=version+1 WHERE id=%s",
(amount, user_id, lot_id)
)
db.execute(
"INSERT INTO bids (lot_id, user_id, amount) VALUES (%s, %s, %s)",
(lot_id, user_id, amount)
)
broadcast_to_websockets(lot_id, {
"type": "BID_PLACED",
"amount": str(amount),
"bidder": mask_username(user_id),
"version": lot.version + 1
})
return BidResult(success=True, new_version=lot.version + 1)
On LOT_UPDATED, the client receives the current bid and can prompt the user to place a new one with the updated price. This avoids double charges and incorrect balances.
How Does Auto-Bidding Work?
Users set a maximum bid amount. The system automatically raises their bid in response to competitor bids — up to the set limit.
def process_autobid(lot_id: str, new_bid_amount: Decimal, new_bidder_id: str):
"""Check auto-bids after each new bid"""
autobids = db.get_active_autobids(lot_id, exclude_user=new_bidder_id)
for autobid in sorted(autobids, key=lambda x: x.max_amount, reverse=True):
counter_amount = new_bid_amount + lot.bid_step
if counter_amount <= autobid.max_amount:
# Automatically bid on behalf of the auto-bidder
place_bid(autobid.user_id, lot_id, counter_amount, lot.version)
break
Auto-bidding can cause conflicts with users, so a detailed history is vital: "Your bid of $1,200 was automatically raised in response to a bid of $1,100." The user always sees what happened and can adjust their limit.
What Is Anti-Sniping?
Sniping is a last-second bid. To prevent it, the auction extends when a bid is placed near the end:
ANTI_SNIPING_THRESHOLD = timedelta(minutes=2)
ANTI_SNIPING_EXTENSION = timedelta(minutes=2)
def after_bid_placed(lot_id: str):
lot = db.get_lot(lot_id)
time_remaining = lot.ends_at - datetime.utcnow()
if time_remaining < ANTI_SNIPING_THRESHOLD:
new_end_time = lot.ends_at + ANTI_SNIPING_EXTENSION
db.update_lot_end_time(lot_id, new_end_time)
broadcast_to_websockets(lot_id, {
"type": "AUCTION_EXTENDED",
"new_end_time": new_end_time.isoformat()
})
The client timer syncs with server time upon receiving AUCTION_EXTENDED. This gives all participants equal chance to counter-bid.
How to Organize Payments?
The winner gets a push notification and has limited time (24-48 hours) to pay. If unpaid, the lot goes to the next highest bidder or is relisted.
For high-value lots — deposit before bidding: blocked when registering for the auction, returned to losers.
Commission (from buyer or seller) is deducted on payment. Seller payouts via Stripe Connect or similar. If you need a custom payment scheme, contact us — we'll find a solution.
Estimated Timelines
| Scope | Timeline |
|---|---|
| Lot browsing, WebSocket, bidding, push notifications | 6-8 weeks |
| Auto-bidding, anti-sniping, bid history | +2 weeks |
| Seller dashboard, lot publishing, payouts | +3-4 weeks |
| Deposits and escrow | +1-2 weeks |
Cost is determined individually after requirements analysis.
What's Included in the Work?
- Technical specification and UI/UX prototype
- iOS development (Swift, SwiftUI) and Android (Kotlin, Jetpack Compose)
- Backend on Python/FastAPI or Node.js
- WebSocket integration with reconnection
- Implementation of auto-bidding and anti-sniping
- Payment system (deposits, escrow, payouts)
- Testing (unit, UI, load)
- Publication to App Store and Google Play
- Documentation and admin training
- 3-month warranty support
Get a consultation for your project. We'll assess timelines and propose the optimal solution. Our experience ensures stable auction operation even under high load. Contact us to discuss architecture and implementation details.







