Real-Time Bidding Architecture: WebSockets, Latency, and Scaling to Thousands of Concurrent Bidders
A deep technical dive into the real-time infrastructure behind auction platforms — WebSocket scaling, Redis-based atomic bid acceptance, latency budgets, and the load-testing approach that proves a bidding engine before it goes live.

Our guide to building an auction platform covers the full technical picture — formats, KYC, payments, notifications. This piece goes narrower and deeper into the single component that decides whether an auction platform survives contact with a real, contested lot at scale: the real-time bidding layer.
Everything else on an auction platform can be slow and get away with it. Catalogue pages can take 800ms to render. Search can lag. Payment settlement can run as a background job. The bidding engine cannot. It is the one part of the system where "eventually consistent" is not an acceptable answer, because the thing being made consistent is money, and the people watching it happen are watching in real time, on a countdown, competing against each other.
This is what "sub-200ms" actually means, where the milliseconds go, and how we build and load-test the infrastructure to hit that number under real contested-lot conditions — not just in a demo with two browser tabs open.
The Latency Budget: Where the Milliseconds Go
"Sub-200ms bid propagation" is a target we quote often, and it's worth breaking into its actual components, because treating it as one number hides where the engineering effort needs to go.
- Client to server network: 20–80ms — depends on the bidder's connection and geographic distance to the nearest edge or region
- Server-side validation and write: 5–20ms — determined entirely by atomic operation design (see below)
- Pub/sub fan-out to other server instances: 1–5ms — Redis pub/sub or equivalent, same-region
- Server to all connected clients (broadcast): 10–60ms — depends on connected clients per lot "room" and server CPU during broadcast
- Client render: 5–15ms — framework re-render cost, DOM update efficiency
Add those up and a well-architected system lands comfortably under 200ms even for bidders on the other side of a region from your servers. The budget gets blown in one of three places, almost always: the validation and write step isn't actually atomic and does multiple round-trips to a database before confirming; the broadcast step is implemented as N sequential emits instead of a single room-based broadcast; or the deployment topology puts bidders and servers in different regions with no edge presence.
The first of those — non-atomic validation — is the one that doesn't just add latency, it produces wrong answers under load. That's worth its own section.
WebSocket Infrastructure Choices at Scale
The pillar guide covers the high-level choice (WebSockets over long-polling, SSE for one-directional cases). At scale, the decision that matters more is which WebSocket implementation and how it's deployed.
Socket.io remains our default recommendation for most builds. It handles reconnection, room/namespace management, and fallback transport negotiation out of the box, and its Redis adapter makes horizontal scaling straightforward — bid events published on one server instance reach clients connected to any other instance via Redis pub/sub. The overhead versus raw WebSockets is real but small, and the operational simplicity is worth it for the vast majority of platforms.
Raw ws or uWebSockets.js become worth considering when a single platform needs to sustain tens of thousands of concurrent connections per instance and Socket.io's per-message overhead starts showing up in profiling. uWebSockets.js in particular can sustain dramatically higher connection counts per server with lower memory overhead, at the cost of having to hand-roll reconnection logic, room management, and heartbeat handling that Socket.io gives you for free. We reach for this only when a load test has actually shown Socket.io's overhead to be the bottleneck — not preemptively, because the hand-rolled reconnection logic is exactly the kind of code that produces the "client thinks it's connected, server has already dropped it" bugs that are painful to debug in production.
Horizontal Scaling Without Sticky Sessions
A common early mistake is deploying WebSocket servers behind a load balancer with sticky sessions (so a client always reconnects to the same instance) and calling it done. This works until an instance restarts during a deploy — every bidder connected to that instance gets dropped simultaneously, and if lots are actively closing at that moment, you have a real problem.
The correct pattern: any server instance can accept any client connection, and Redis pub/sub (or a message broker like NATS) is the shared source of truth for who needs to hear about this bid. A bid submitted to instance A is validated and written to the authoritative store, then published to a Redis channel keyed by lot ID. Every instance subscribes to channels for the lots it has connected clients for, and pushes the update to those specific clients. This means a rolling deploy that cycles every server instance produces individual client reconnections (handled gracefully by Socket.io's reconnection logic) rather than a correlated mass disconnect-and-miss-bids event.
Atomic Bid Acceptance: The Part That Cannot Be "Mostly" Correct
The pillar guide introduces the Redis Lua script pattern for atomic bid validation. Here's the fuller version, including the parts that get skipped in a first implementation and then cause incidents later.
-- atomic_bid_accept.lua (KEYS[1] = lot:{lotId}:highest_bid, KEYS[2] = lot:{lotId}:highest_bidder, KEYS[3] = lot:{lotId}:status, ARGV[1] = proposed bid amount, ARGV[2] = bidder id, ARGV[3] = minimum increment) — local status = redis.call('GET', KEYS[3]); if status ~= 'open' then return {0, 'lot_not_open'} end; local current = tonumber(redis.call('GET', KEYS[1]) or '0'); local proposed = tonumber(ARGV[1]); local increment = tonumber(ARGV[3]); if proposed < current + increment then return {0, 'below_minimum_increment', current} end; redis.call('SET', KEYS[1], proposed); redis.call('SET', KEYS[2], ARGV[2]); redis.call('PUBLISH', 'lot:' .. KEYS[1] .. ':events', cjson.encode({bid = proposed, bidder = ARGV[2], ts = redis.call('TIME')})); return {1, 'accepted', proposed}
Three details that matter beyond the happy path:
Lot status is checked inside the same atomic script, not before it. A common bug: the application checks "is this lot open?" as a separate read before calling the bid-acceptance script. Between that read and the script executing, the lot can close (via the scheduled closing worker) and the bid gets accepted into a closed lot. Checking status inside the atomic script closes this window entirely.
The script returns a reason code, not just accept/reject. "Below minimum increment" and "lot not open" require different client-side handling — one should suggest a corrected bid amount, the other should refresh the lot state entirely. Collapsing both into a boolean forces the client to guess.
The durable database write happens asynchronously, after the Redis operation confirms. Redis is the authoritative source of truth for live bid state because it's fast enough to be atomic under contention; PostgreSQL is the durable system of record. The write to PostgreSQL happens via a queued job immediately after the Redis operation succeeds, and that job must be idempotent (safe to retry) since queue processing can redeliver messages. We key these writes on a bid ID generated at submission time, so a redelivered job is a harmless no-op rather than a duplicate bid record.
Message Ordering: Why "Last Write Wins" Isn't Enough
Redis's single-threaded command execution guarantees that two bids arriving within the same millisecond are still processed one at a time, in the order Redis received them. That solves write ordering. It does not automatically solve display ordering on the client side, and this is where platforms get subtle bugs that are hard to reproduce.
Network jitter means bid events can arrive at a given client's WebSocket connection out of the order they were published, particularly across regions or under load. If your client simply renders "latest event received" as the current state, two bidders can briefly see different, contradictory pictures of who's winning — usually self-correcting within a second, but genuinely confusing during the final seconds of a contested lot, which is exactly when confusion is most costly.
The fix is to attach a monotonically increasing sequence number to every bid event at the point it's published (not at the point it's sent to any individual client), and have the client discard any event with a sequence number lower than the last one it rendered. This is a small addition to the Lua script above (an INCR on a per-lot sequence counter, included in the published payload) and it eliminates an entire category of "why did the bid count flicker" support tickets.
Load Testing: Proving It Before a Bidder Does
The failure mode we see most often isn't bad architecture — it's untested architecture. A bidding engine that has only ever been exercised by a developer clicking a UI, or by an automated test suite with one simulated bidder, has not actually been proven under the condition that matters: dozens of bids arriving on the same lot within the same second, from genuinely concurrent connections, while hundreds of other connections are idle but present.
Our load-testing approach for a bidding engine, before any real launch:
- Baseline connection load. Simulate the expected peak concurrent connection count (we target 10x projected peak) using a tool like k6 or Artillery with WebSocket support, and confirm connection establishment, heartbeat, and idle memory/CPU stay flat.
- Contested-lot burst simulation. Script a scenario where 50–200 virtual bidders submit bids on the same lot within a 2-second window, at random small intervals, and assert that: exactly one bid is ever recorded as "currently winning" at any point in the event log, every rejected bid receives a reason code, and total propagation latency to all connected observers stays under budget throughout the burst.
- Reconnection storm simulation. Forcibly disconnect a meaningful fraction (20–40%) of connections simultaneously (simulating a network blip or a deploy) and confirm the server doesn't fall over under the resulting reconnection burst — this is where naive reconnection backoff (or its absence) turns a minor blip into a self-inflicted denial-of-service against your own servers.
- Sustained multi-lot load. Run the burst scenario above concurrently across 50–100 lots simultaneously closing in the same window, which is the realistic shape of a live sale's final minutes, and is a materially harder problem than any single lot in isolation because it multiplies both write contention and broadcast fan-out at once.
A platform that hasn't been through something resembling this sequence has not actually been tested for the condition it exists to handle. We run this before every auction platform launch, and it is, without exception, where we find the issues that would otherwise have shown up during the client's first real high-value sale.
What to Monitor Once It's Live
Uptime monitoring tells you the server is running. It doesn't tell you whether bidding is actually working within acceptable latency. The metrics that matter specifically for a bidding engine:
- Bid propagation latency, p50/p95/p99 — not average. The p99 during a contested lot's final 30 seconds is the number that determines whether your platform feels trustworthy or broken to the bidders who experienced it, and averages hide exactly this tail.
- WebSocket connection churn rate — a sudden spike indicates either a client-side bug causing repeated reconnects, or a server-side issue dropping connections it shouldn't.
- Redis command latency and queue depth on the bid-acceptance script — this is your leading indicator of contention before it becomes visible as user-facing latency.
- Rejected-bid rate and reason-code distribution — a sudden spike in "lot not open" rejections usually indicates a client-side desync between displayed lot status and server truth, worth investigating even though the atomic script correctly rejected the bids.
How We Approach This at Cyberbeak
We build the bidding engine first, in isolation, and load-test it against the scenarios above before a single line of UI code is written on top of it. This ordering is deliberate: the UI is comparatively cheap to change later; the correctness and performance characteristics of the bidding engine are not something you want to discover need rework after the catalogue, dashboards, and admin panel have all been built assuming it works. Our standard delivery includes the load-testing suite as a deliverable, not a one-off exercise — so it can be re-run against every future scaling milestone, not just at initial launch.
Related reading: How to Build an Online Auction Platform — 2026 Technical Guide.
If you're evaluating the real-time infrastructure for an auction or marketplace platform — whether from scratch or as a scaling fix to an existing system — talk to our engineering team. The first conversation is technical, not a sales call.
تحدث مع فريقنا حول مشروعك
نعمل مع الشركات في المملكة المتحدة والولايات المتحدة والإمارات والمملكة العربية السعودية وكندا وأستراليا وألمانيا لبناء برامج مخصصة ومنصات SaaS وأنظمة السوق.