Back to Notes
·9 min read

Why Did Two Users Just Bid the Same Amount? (The Polling Problem)

#WebSockets#Django#RealTime#Architecture#Auctions
Why Did Two Users Just Bid the Same Amount? (The Polling Problem)

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.312DUPLICATE!
159   89           $14,200       18:42:09.891

User 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,100

The 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?

  1. We read auction.current_price → $14,000
  2. We calculate the new bid → $14,100
  3. We save the bid
  4. 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 DATA

And 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:

MetricBeforeAfter
Duplicate bid reports5-10/week0
Polling interval3 seconds1 second
Server requests/auction5,00015,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 modify

Lesson 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.


Aamir Shahzad

Aamir Shahzad

Author

Software Engineer with 7+ years of experience building scalable data systems. Specializing in Django, Python, and applied AI.