Back to all posts

Building a Live-Stream Selling Backend with SignalR in ASP.NET Core

September 11, 20263 min read
ASP.NET CoreSignalRReal-Time Systems

When I needed to add live-stream selling to a multi-vendor marketplace, the first question I asked was: how do I get a "3 left in stock" update to 500 people watching a stream at the same time, instantly and reliably? Classic polling (hitting an API on an interval) both wastes server resources and never feels truly "live." The answer was SignalR.

Why one hub wasn't enough

My first attempt was routing everything — stream status, auction bids, chat messages — through a single LiveHub. Two problems showed up quickly:

  • Traffic contention: during an auction, dozens of bid events per second would start delaying chat messages riding the same channel.
  • Scaling difficulty: when I wanted to scale horizontally (multiple instances), a single hub's backplane configuration had to push every kind of traffic through the same Redis channel.

The fix was splitting into three functionally independent hubs: BroadcastHub (stream status, viewer count), AuctionHub (bid/auction events) and ChatHub (messaging). Each manages its own Group structure (keyed by stream ID) and carries its own backplane traffic.

public class AuctionHub : Hub
{
    public async Task JoinAuction(string auctionId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, $"auction-{auctionId}");
    }
 
    public async Task PlaceBid(string auctionId, decimal amount)
    {
        // Validate: is this bid higher than the current top bid?
        var result = await _auctionService.TryPlaceBidAsync(auctionId, amount, Context.UserIdentifier);
        if (!result.Success)
        {
            await Clients.Caller.SendAsync("BidRejected", result.Reason);
            return;
        }
 
        await Clients.Group($"auction-{auctionId}")
            .SendAsync("NewBid", result.Bid);
    }
}

The race condition trap

The most critical bug class in the auction was two users bidding at the exact same moment. Since Hub methods can run concurrently by default, a race condition opened up between checking "is this higher than the current top bid" and actually persisting the bid — two low bids could each think they were "valid" and both get accepted.

The fix was a per-auction lock (in a distributed setup this gets paired with a database-level optimistic lock — UPDATE ... WHERE current_bid < @newBid — an in-memory semaphore is enough on a single instance, but the database layer is what actually guarantees correctness once you scale horizontally):

var rowsAffected = await _db.Auctions
    .Where(a => a.Id == auctionId && a.CurrentBid < amount)
    .ExecuteUpdateAsync(s => s.SetProperty(a => a.CurrentBid, amount));
 
if (rowsAffected == 0)
{
    // Another bid got there first — reject this one.
}

This line isn't really about SignalR at all — it's about getting the data layer underneath it right. In real-time systems, the sneakiest bugs usually aren't in the transport layer, they're in state consistency.

Disconnects and reconnection

Mobile users switching from WiFi to cellular lose their connection constantly. SignalR's automatic reconnect handles part of this, but on reconnect the client loses track of which groups it belonged to. I solved this in OnReconnectedAsync — not by relying on the connection ID, but by having the client itself remember which stream it was watching and re-issue JoinAuction on reconnect. In other words, the source of truth for that state is the client, not the server — an idempotent "rejoin" flow.

Result

Three separate hubs, database-level optimistic locking, and a client-side group-rejoin flow — together, these three decisions let a single codebase carry live streaming, auctions and chat at the same time, in production, without any of them slowing the others down. You can find the rest of this project's technical details (an OpenAI-embedding recommendation engine, automatic background removal with ONNX) on the project page below.

Related Project

Take a closer look at the project this post is about.

View Project