The Support Ticket That Ruined My Weekend
Friday, 6:47 PM. I'm about to close my laptop when Slack pings.
"Two users are furious. They both bid $14,100 on the same car. How is that possible?"
I checked the database:
BIDS TABLE (auction_id = 847):
id bidder_id bid_amount bid_time
───────────────────────────────────────────────────
156 42 $14,000 18:42:03.127
157 89 $14,100 18:42:06.234
158 42 $14,100 18:42:06.312 ← DUPLICATE!
159 89 $14,200 18:42:09.891User 42 and User 89 both bid $14,100. 78 milliseconds apart.
That's not supposed to happen. Each bid should be $100 higher than the last.
The bug wasn't in the bid calculation. The bug was in how users saw the current price.
How Our Auction System Worked (Polling)
We built a live auction platform. Users watch a countdown timer. When they click "Bid," their bid goes in at the current price + $100.
"Live" is the key word. Multiple users watching the same auction. Prices changing in real-time.
Except we didn't build real-time. We built polling.
HOW POLLING WORKS:
┌──────────────────┐ ┌──────────────────┐
│ Browser │ │ Server │
│ (User A) │ │ (Django) │
└────────┬─────────┘ └────────┬─────────┘
│ │
│ ─── "What's the current price?" ──> │
│ GET /auction/bids/847/ │ Every 3
│ <── "$14,000" ─────────────────── │ seconds
│ │
│ ... 3 seconds pass ... │
│ │
│ ─── "What's the current price?" ──> │
│ GET /auction/bids/847/ │
│ <── "$14,000" ─────────────────── │
│ │
│ ... User A clicks BID ... │
│ │
│ ─── POST /auction/submit_bid/ ────> │
│ {auction_id: 847} │
│ <── "Your bid: $14,100" ───────── │
│ │
▼ ▼The browser asks "what's the current price?" every 3 seconds. The server answers. The browser updates the UI.
Simple. Works for one user.
Breaks with two.
The Race Condition Nobody Warned Me About
Here's exactly what happened with User 42 and User 89:
TIMELINE OF THE BUG:
T=0.000s Server: Current price is $14,000
User A's browser: Shows $14,000
User B's browser: Shows $14,000
T=0.100s User B clicks "Bid"
Browser sends: POST /submit_bid/
T=0.150s User A clicks "Bid" (hasn't polled yet, still sees $14,000)
Browser sends: POST /submit_bid/
T=0.200s Server receives User B's bid
Calculates: $14,000 + $100 = $14,100
Saves bid to database
Returns: "Your bid: $14,100"
T=0.250s Server receives User A's bid
Reads current_price from auction object (still $14,000 in memory!)
Calculates: $14,000 + $100 = $14,100
Saves bid to database
Returns: "Your bid: $14,100"
RESULT: Both users bid $14,100The problem: User A's browser didn't know User B had just bid.
The polling interval was 3 seconds. User B's bid happened between polls. User A was looking at stale data.
The Code That Let This Happen
Here's the actual bid submission code:
# auction/views.py
@csrf_exempt
def submit_bid(request):
if request.method == 'POST':
auction_id = request.POST.get('auction_id')
bidder = request.user
auction = Auction.objects.get(id=auction_id)
if auction.status == 'OPEN':
# THE PROBLEM IS HERE ↓
try:
bid_amount = auction.current_price + Decimal('100')
except:
auction.current_price = auction.min_desired_price
auction.save()
bid_amount = auction.current_price + Decimal('100')
# No locking! No transaction!
bid = Bid(auction=auction, bidder=bidder, bid_amount=bid_amount)
bid.save()
# Update auction's current price
auction.current_price = bid_amount
auction.save()
return JsonResponse({
'status': 'success',
'message': 'Bid submitted successfully.',
'new_current_price': bid_amount
})See the problem?
- We read
auction.current_price→ $14,000 - We calculate the new bid → $14,100
- We save the bid
- We update
auction.current_price→ $14,100
Between steps 1 and 4, another request could read the old price. No locking. No transaction. Pure chaos.
The Math of Polling Failure
Let's do the numbers:
POLLING IMPACT:
Poll interval: 3 seconds
Active users in auction: 50
Requests per user per minute: 20 (3-second intervals)
Total requests per minute: 50 × 20 = 1,000
Auction duration: 5 minutes
Total polling requests: 5,000 requests
Each request:
- TCP connection overhead
- Django middleware stack
- Database query (Bid.objects.filter...)
- JSON serialization
- Response transmission
Server cost: HIGH
User experience: STALE DATAAnd that's just one auction. On a busy day, we had 20+ auctions running simultaneously.
20 auctions × 50 users × 20 requests/min = 20,000 requests/minute
Just for polling. Not including actual bids.Why WebSockets Would Have Prevented This
WebSockets flip the model:
POLLING (what we had):
─────────────────────
Browser: "Any updates?"
Server: "No"
Browser: "Any updates?"
Server: "No"
Browser: "Any updates?"
Server: "Yes! New bid: $14,100"
Browser: "Any updates?"
Server: "No"
...
WEBSOCKETS (what we needed):
────────────────────────────
Browser: "I want to watch auction 847"
Server: "Connected. I'll tell you when anything happens."
[silence - no traffic]
Server: "New bid! $14,100 from User B"
Browser: Updates instantly
Server: "New bid! $14,200 from User A"
Browser: Updates instantly
[silence - no traffic]With WebSockets:
- Server pushes updates → No polling overhead
- Instant delivery → No 3-second delay
- All clients update together → No stale price problem
What WebSocket Architecture Would Look Like
WEBSOCKET AUCTION ARCHITECTURE:
┌─────────────────────┐
│ Redis Pub/Sub │
│ (Channel Layer) │
└──────────┬──────────┘
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Worker 1 │ │ Worker 2 │ │ Worker 3 │
│ (Django │ │ (Django │ │ (Django │
│ Channels) │ │ Channels) │ │ Channels) │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
┌────────┴────────┬──────┴──────┬────────┴────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ User A │ │ User B │ │ User C │ │ User D │
│ Browser │ │ Browser │ │ Browser │ │ Browser │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
FLOW:
1. User A opens auction page → WebSocket connects → Joins "auction_847" group
2. User B places bid → Server validates → Saves to DB
3. Server broadcasts to "auction_847" group: {"type": "bid", "amount": 14100}
4. ALL connected clients receive update INSTANTLY
5. No polling. No stale data. No duplicate bids.The Consumer We Should Have Built
# auction/consumers.py (what we needed)
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from decimal import Decimal
class AuctionConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.auction_id = self.scope['url_route']['kwargs']['auction_id']
self.room_group_name = f'auction_{self.auction_id}'
# Join auction room
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
await self.accept()
async def receive(self, text_data):
data = json.loads(text_data)
if data['type'] == 'place_bid':
result = await self.place_bid(data['user_id'])
if result['success']:
# Broadcast to ALL clients in the room
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'bid_placed',
'bid_amount': str(result['bid_amount']),
'bidder_id': result['bidder_id'],
'bid_time': result['bid_time'],
}
)
@database_sync_to_async
def place_bid(self, user_id):
from django.db import transaction
from auction.models import Auction, Bid
try:
with transaction.atomic():
# LOCK THE ROW - prevents race conditions!
auction = Auction.objects.select_for_update().get(id=self.auction_id)
if auction.status != 'OPEN':
return {'success': False, 'message': 'Auction is closed'}
# Calculate bid with locked price
bid_amount = auction.current_price + Decimal('100')
# Create bid
bid = Bid.objects.create(
auction=auction,
bidder_id=user_id,
bid_amount=bid_amount
)
# Update auction price
auction.current_price = bid_amount
auction.save()
return {
'success': True,
'bid_amount': bid_amount,
'bidder_id': user_id,
'bid_time': bid.bid_time.isoformat()
}
except Exception as e:
return {'success': False, 'message': str(e)}The key difference: select_for_update().
This locks the auction row. If two bids come in simultaneously, one waits for the other to finish. No duplicate amounts.
The Quick Fix We Actually Shipped
We didn't have time for a full WebSocket rewrite. Here's what we did instead:
Fix 1: Reduce polling interval
// BEFORE: Poll every 3 seconds
if (counter == 3) {
getBids(auctionId)
counter = 0
}
// AFTER: Poll every 1 second during active bidding
if (counter == 1) {
getBids(auctionId)
counter = 0
}More requests, but fresher data.
Fix 2: Add row-level locking in bid submission
# BEFORE: No locking
auction = Auction.objects.get(id=auction_id)
bid_amount = auction.current_price + Decimal('100')
# AFTER: Lock the row
from django.db import transaction
with transaction.atomic():
auction = Auction.objects.select_for_update().get(id=auction_id)
bid_amount = auction.current_price + Decimal('100')
bid = Bid(auction=auction, bidder=bidder, bid_amount=bid_amount)
bid.save()
auction.current_price = bid_amount
auction.save()Now if two bids arrive simultaneously, one waits.
Fix 3: Return fresh price in bid response
# BEFORE: Just return success
return JsonResponse({'status': 'success', 'new_current_price': bid_amount})
# AFTER: Return full bid list so client immediately updates
bids = Bid.objects.filter(auction=auction).order_by('-bid_time')[:10]
return JsonResponse({
'status': 'success',
'new_current_price': bid_amount,
'all_bids': [{'amount': b.bid_amount, 'time': b.bid_time.isoformat()} for b in bids]
})Client updates immediately after their own bid, doesn't wait for next poll.
The Results
After the quick fixes:
| Metric | Before | After |
|---|---|---|
| Duplicate bid reports | 5-10/week | 0 |
| Polling interval | 3 seconds | 1 second |
| Server requests/auction | 5,000 | 15,000 |
| User complaints | "I bid but it didn't work" | None |
The server load tripled. But the duplicate bids stopped.
Not elegant. But it worked.
Lessons Learned
Lesson 1: Polling Is Not Real-Time
3 seconds feels instant to a developer. It's an eternity in a competitive auction.
If your feature requires "live" updates, polling is lying to your users.
Lesson 2: Race Conditions Hide Until They Don't
The code worked fine with 5 users. It broke with 50.
Race conditions are probability problems. Low traffic = rare bugs. High traffic = constant bugs.
Lesson 3: select_for_update() Is Your Friend
Any time two requests might modify the same row, lock it:
with transaction.atomic():
obj = Model.objects.select_for_update().get(id=id)
# Safe to modifyLesson 4: Ship the Quick Fix, Plan the Real Fix
We couldn't rewrite to WebSockets in a weekend. We could add locking in an hour.
Ship the band-aid. Schedule the surgery.
Quick Reference
Polling (what we had):
setInterval(() => {
fetch('/api/bids/' + auctionId)
.then(r => r.json())
.then(updateUI)
}, 3000)WebSocket (what we needed):
const ws = new WebSocket('ws://site.com/ws/auction/' + auctionId)
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
updateUI(data)
}Row locking (the fix):
with transaction.atomic():
obj = Model.objects.select_for_update().get(id=id)
obj.field = new_value
obj.save()That's how two users ended up bidding the same amount and how we stopped it from happening again.
Related Reading
- The Page That Made 147 Database Queries - Fixing N+1 queries in the auction system
- Processing 200GB of Vehicle Data Daily - The pipeline that feeds this auction data
- Building a Search API with 30+ Filters - How users find cars to bid on
