Distributed Auction System
A real-time, distributed auction platform I'm building in .NET — engineered for high-concurrency bidding, horizontal scale, and full observability from the first commit.
The Problem
Auctions are a worst-case concurrency bottleneck: hundreds of distributed nodes racing to update a single database row simultaneously, where execution order determines the legal winner. Naive designs result in stale prices, dropped writes, or race conditions. This project tackles the hard part: achieving strict consistency and linearizability under high-throughput write spikes without sacrificing low-latency execution.
Engineering Goals
Exactly one winning bid per price level — no lost updates, no stale price presented as current. The guarantees have to hold under peak contention, not just average load.
A price change should reach every connected client fast enough that the auction feels live. Push, never poll.
Any node can handle any bid. Adding capacity means adding replicas — no sticky sessions, no designated writer node.
Every bid is traceable end-to-end from the first commit. Instrumentation is part of the design, not a retrofit.
Tech Stack
Architecture
Everything in the system scales horizontally except the one thing that can't: deciding which bid wins. The design isolates that decision in a single atomic operation and keeps everything around it stateless.
Stateless ASP.NET Core containers on Azure Container Apps. No node holds auction state in memory, so scaling out is adding replicas, not re-architecting.
SignalR hubs hold the WebSocket connections; a Redis Pub/Sub backplane fans events out across nodes, so a bid accepted on one node reaches clients connected to every other node.
Live auction state — current price, high bidder, time remaining — lives in Redis. A Lua script performs the compare-and-swap that accepts or rejects each bid atomically.
PostgreSQL holds the durable record: auctions, bids, outcomes. Redis is authoritative in the moment; Postgres is what's true afterwards.
OpenTelemetry traces follow each bid across the whole path — hub to script to database — so a slow or lost bid is a query, not a mystery.
How a Bid Flows
A client sends a bid over its open SignalR connection. There are no sticky sessions — whichever node holds the socket handles it.
The node checks the cheap things first: auction still open, bid meets the minimum increment, bidder is eligible. Failures are rejected before touching shared state.
A Redis Lua script compares the bid against the current high bid and swaps it in atomically. Redis executes scripts single-threaded, so this is the serialization point of the whole system: however many nodes race, exactly one bid wins each price level.
The accepted bid is published on Redis Pub/Sub, and every node's hub pushes the new price to its connected clients.
The accepted bid is written to PostgreSQL as the durable record, with the full trace context attached.
Key Decisions & Tradeoffs
Bid acceptance could be a Postgres transaction with row locks — simpler, but every bid on a hot auction would queue on a single row. Moving the decision into a Redis Lua script keeps contention off the database and makes the swap atomic by construction. The cost: Redis becomes critical-path infrastructure, and durability happens one step later.
Polling is simpler to operate but turns 'live' into 'eventually'. SignalR pushes price changes the moment they happen; the Redis backplane is the price of making that work across many nodes instead of one.
Redis answers 'what is the price right now'; Postgres answers 'what actually happened'. The split buys speed on the hot path — and creates a window where an accepted bid exists in Redis but isn't yet durable. That gap is closed by design, not by luck.
Kubernetes offers more control; Azure Container Apps offers scale-out with almost no ops surface. This system's interesting problems are in the data path — spending complexity budget on infrastructure would be spending it in the wrong place.
Failure Modes & Resilience
Nodes are stateless — auction state lives in Redis, not process memory. SignalR's automatic reconnect moves clients to a healthy replica, and the first thing a reconnecting client does is resync the current price.
The hot path stops, deliberately. Bids fail fast and visibly rather than being accepted against state that might be stale. For an auction, consistency beats availability: a rejected bid is recoverable; a wrong winner isn't.
A reconnecting client never trusts its last known price — it refetches auction state before re-enabling bidding, so a flaky connection cannot produce a bid against a stale price.
The auction close is enforced inside the same Lua script that accepts bids, against the same clock — so 'did this bid make it in time' has exactly one answer, regardless of which node received it.
Current Status
Core bid pipeline: the Redis Lua CAS script, SignalR hub, and the contention tests that prove exactly-one-winner holds under concurrent load.
Postgres persistence and the end-to-end OpenTelemetry trace across the full bid path.
Load testing under realistic bid storms, then a public live demo and the repository going public.