Building a Real-Time Leaderboard with SignalR and Blazor

Build a real-time leaderboard with SignalR and Blazor — hub and group design, a working client, one source of truth for rankings, and scaling notes.

A real-time leaderboard is a push problem, not a polling problem: the server owns the standings, recalculates them whenever a score lands, and broadcasts the new snapshot through a SignalR hub to every connected client. The clients do almost nothing — they join a group for the competition they care about and render whatever state gets pushed. I built a real-time race management platform for multi-judge fitness competitions — live leaderboards, concurrent scoring from multiple judges, gap calculation, and peer sync over SignalR on Blazor, .NET 8, and PostgreSQL — and the architecture below is the one I would reach for again.

Why polling fails for leaderboards

The obvious first implementation is a timer: every client calls GET /api/standings every two seconds. It works in a demo and falls apart in production for three reasons.

Load scales with viewers, not with events. Two hundred spectators polling every two seconds is 100 requests per second against a query that aggregates every score in the competition — even when nothing has changed. With push, the expensive recalculation runs once per score submitted, and fan-out is cheap.

Latency is wrong in both directions. A two-second interval means standings are up to two seconds stale — an eternity when a judge just posted the score that decides first place — while most requests return identical data. Shortening the interval fixes staleness by multiplying waste.

Polling can interleave. Two in-flight requests can complete out of order, so a client briefly renders older standings on top of newer ones. You end up writing versioning logic anyway — at which point you have built half of a push system with none of the benefits.

SignalR gives you the push channel for free: WebSockets where available, fallbacks where not, and a connection/group model that maps cleanly onto “everyone watching competition X”. The official SignalR overview covers the transport details; what matters here is the design on top.

Hub design: one group per competition

The standard pattern is a thin hub whose only jobs are membership and fan-out. Business logic does not live in the hub. Clients call JoinCompetition when the leaderboard page loads; the server adds them to a group named after the competition, and every broadcast targets that group.

public interface ILeaderboardClient
{
    Task StandingsUpdated(LeaderboardSnapshot snapshot);
}

[Authorize]
public class LeaderboardHub : Hub<ILeaderboardClient>
{
    public Task JoinCompetition(Guid competitionId) =>
        Groups.AddToGroupAsync(Context.ConnectionId, GroupName(competitionId));

    public Task LeaveCompetition(Guid competitionId) =>
        Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(competitionId));

    internal static string GroupName(Guid competitionId) =>
        $"competition:{competitionId}";
}

Two details worth copying:

  • A strongly typed hub (Hub<ILeaderboardClient>) turns broadcast method names into compile-time checks instead of magic strings on the server side.
  • The group name lives in one static method. Every broadcaster uses LeaderboardHub.GroupName(...), so the naming convention cannot drift between the hub and the services that publish to it.

Put [Authorize] on the hub even for a “public” leaderboard if the same connection carries privileged actions. Write paths deserve stronger protection than a cookie alone — judges submitting scores are a perfect candidate for the phishing-resistant login flow I covered in adding passkeys to ASP.NET Core Identity.

Score submission itself should not be a hub method. Route it through your normal application layer — an HTTP endpoint or a service — so validation, authorization, and persistence follow the same pipeline as everything else. The service then broadcasts through IHubContext<LeaderboardHub, ILeaderboardClient>:

public sealed class ScoreService(
    AppDbContext db,
    IHubContext<LeaderboardHub, ILeaderboardClient> hub)
{
    public async Task SubmitScoreAsync(SubmitScore command, CancellationToken ct)
    {
        db.Scores.Add(new Score
        {
            CompetitionId = command.CompetitionId,
            AthleteId = command.AthleteId,
            JudgeId = command.JudgeId,
            Value = command.Value,
            RecordedAt = DateTimeOffset.UtcNow
        });
        await db.SaveChangesAsync(ct);

        var snapshot = await BuildSnapshotAsync(command.CompetitionId, ct);

        await hub.Clients
            .Group(LeaderboardHub.GroupName(command.CompetitionId))
            .StandingsUpdated(snapshot);
    }
}

One source of truth for rankings and gaps

The mistake that kills leaderboard projects is letting clients compute rankings. The moment a browser sorts rows or calculates “3.5 points behind the leader” locally, you have N slightly different leaderboards — one per device, each with its own rounding, tie-breaking, and race conditions. The server computes everything; clients render.

The snapshot is the whole contract: ranks, totals, and gaps precomputed, plus a monotonically increasing version so clients can discard stale messages.

public sealed record StandingRow(
    Guid AthleteId,
    string Name,
    decimal Total,
    int Rank,
    decimal GapToLeader,
    decimal GapToNext);

public sealed record LeaderboardSnapshot(
    Guid CompetitionId,
    long Version,
    IReadOnlyList<StandingRow> Rows);

Ranking and gap calculation is one pass over the sorted totals. Ties share a rank, the leader’s gap is zero, and GapToNext is the distance to the row above:

private async Task<LeaderboardSnapshot> BuildSnapshotAsync(
    Guid competitionId, CancellationToken ct)
{
    var totals = await db.Scores
        .Where(s => s.CompetitionId == competitionId)
        .GroupBy(s => s.AthleteId)
        .Select(g => new { AthleteId = g.Key, Total = g.Sum(s => s.Value) })
        .OrderByDescending(x => x.Total)
        .ToListAsync(ct);

    var names = await db.Athletes
        .Where(a => a.CompetitionId == competitionId)
        .ToDictionaryAsync(a => a.Id, a => a.Name, ct);

    var rows = new List<StandingRow>(totals.Count);
    decimal leaderTotal = totals.Count > 0 ? totals[0].Total : 0;
    decimal? previousTotal = null;
    var rank = 0;

    for (var i = 0; i < totals.Count; i++)
    {
        var t = totals[i];
        if (previousTotal != t.Total)
        {
            rank = i + 1; // tied athletes keep the higher rank
        }

        rows.Add(new StandingRow(
            t.AthleteId,
            names[t.AthleteId],
            t.Total,
            rank,
            GapToLeader: leaderTotal - t.Total,
            GapToNext: previousTotal is null ? 0 : previousTotal.Value - t.Total));

        previousTotal = t.Total;
    }

    var version = await db.NextLeaderboardVersionAsync(competitionId, ct); // your own helper — a DB sequence, rowversion, or MAX(score id)
    return new LeaderboardSnapshot(competitionId, version, rows);
}

The version can come from a database sequence, a row-version column, or even max(score id) for the competition — anything that only moves forward. Recomputing from the raw score log on every update sounds wasteful, but for realistic competition sizes it is a single indexed aggregate query; do not cache derived standings until profiling says you must.

The Blazor client: join, listen, render

The same client component works in Blazor Server, WebAssembly, and MAUI Hybrid, because it talks to the hub over the SignalR client rather than assuming a hosting model. Which model you pick for the app overall is a separate decision — I walk through the trade-offs in Blazor Server vs WebAssembly vs Hybrid.

@page "/competitions/{CompetitionId:guid}/leaderboard"
@implements IAsyncDisposable
@inject NavigationManager Nav

<h1>Live standings</h1>

@if (_snapshot is null)
{
    <p>Connecting…</p>
}
else
{
    <table>
        <thead>
            <tr><th>#</th><th>Athlete</th><th>Total</th><th>Gap</th></tr>
        </thead>
        <tbody>
            @foreach (var row in _snapshot.Rows)
            {
                <tr>
                    <td>@row.Rank</td>
                    <td>@row.Name</td>
                    <td>@row.Total</td>
                    <td>@(row.Rank == 1 ? "—" : $"-{row.GapToLeader}")</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    [Parameter] public Guid CompetitionId { get; set; }

    private HubConnection? _hub;
    private LeaderboardSnapshot? _snapshot;

    protected override async Task OnInitializedAsync()
    {
        _hub = new HubConnectionBuilder()
            .WithUrl(Nav.ToAbsoluteUri("/hubs/leaderboard"))
            .WithAutomaticReconnect()
            .Build();

        _hub.On<LeaderboardSnapshot>(nameof(ILeaderboardClient.StandingsUpdated), snapshot =>
        {
            if (_snapshot is null || snapshot.Version > _snapshot.Version)
            {
                _snapshot = snapshot;
                InvokeAsync(StateHasChanged);
            }
        });

        await _hub.StartAsync();
        await _hub.InvokeAsync("JoinCompetition", CompetitionId);
    }

    public async ValueTask DisposeAsync()
    {
        if (_hub is not null)
        {
            await _hub.DisposeAsync();
        }
    }
}

Three things earn their place here. The version check makes out-of-order delivery harmless — an older snapshot arriving late is simply ignored. WithAutomaticReconnect() handles transient drops, but as general practice you should also re-join groups and re-fetch a fresh snapshot in a Reconnected handler, because group membership belongs to the connection, not the user. And InvokeAsync(StateHasChanged) matters because the hub callback fires off the renderer’s synchronization context.

Give late joiners an immediate snapshot too: either return the current standings from JoinCompetition, or expose the same BuildSnapshotAsync result through a plain HTTP endpoint the page calls on load. Nobody should stare at an empty table until the next score arrives.

Handling concurrent writers

With several judges scoring at once, the design above is already most of the answer, because the score log is append-only. Concurrent inserts into an append-only table do not conflict with each other — there is no shared row to fight over — and each insert triggers its own recomputation from the full log, so the final standings are independent of arrival order.

The remaining race is between broadcasts: two submissions can build snapshots concurrently, and the older one can reach clients last. The monotonic version plus the client-side check closes that gap without any locking. If you want stronger ordering guarantees server-side, serialize snapshot building per competition — a Channel consumed by a single writer per competition is a clean way to do it — but start with versions; they are usually enough.

Edits are the one place you need real concurrency control. If a judge can revise a previously submitted score, that row is now shared mutable state: put an EF Core concurrency token on it and surface a “score was changed by someone else” conflict rather than silently overwriting.

Scaling notes

A single server with default settings goes a long way, but three levers matter as the audience grows.

Coalesce broadcasts. Under a burst of submissions, per-score broadcasts waste bandwidth rendering states nobody sees. Throttle per competition — mark it dirty, broadcast at most every 250 ms — and you cap fan-out cost regardless of scoring rate, at the price of imperceptible latency.

Send snapshots until size hurts. A full snapshot for a few hundred athletes is a few kilobytes and makes clients trivially stateless. Only move to deltas when measured payload size forces you to, and switch the wire format to MessagePack before you switch to deltas — one AddMessagePackProtocol() call on the server and one on the client (plus the protocol package), with none of the complexity deltas add.

Multiple nodes need a backplane. Groups live in the memory of one server, so behind a load balancer two clients on different nodes stop seeing each other’s broadcasts. A Redis backplane or Azure SignalR Service fixes this; the SignalR hosting and scaling docs compare the options honestly. With a Redis backplane you also need sticky sessions for every transport, unless all clients are locked to WebSockets with negotiation skipped; Azure SignalR Service removes the sticky-session requirement entirely, because clients connect to the service rather than to your servers.

Closing thoughts

The whole architecture reduces to one sentence: scores are facts, standings are a projection, and SignalR is the delivery mechanism for that projection. Keep the hub thin, compute rankings and gaps in exactly one place, version every snapshot, and let append-only writes absorb the concurrency for you. It is the shape I would reach for on any live-ranking problem — competition scoring, auction bids, sales dashboards, live vote counts.

If you are building something real-time on .NET and want a second pair of hands on the architecture, I take on this kind of work — and the race management case study shows what it looks like shipped.