Modern technology makes it possible to build complex data-driven applications with widely available development tools and frameworks. A real-time racing data dashboard is a good example. The dashboard itself may appear relatively simple, but the complexity increases significantly when processing and distributing continuously changing data.

A racing data application may need to handle race entries, post positions, odds, jockey changes, results, news, and other information. This means the application needs an initial snapshot, incremental live updates, reliable reconnection handling, and a way to keep multiple application servers working with consistent shared state.

In this article, we will explore how to build a real-time racing data dashboard using ASP.NET Core and SignalR.

Start With a Licensed Data Source

Before you start writing even a single line of code, you have to figure out your data source. A horse racing data dashboard heavily relies on source accuracy. So, you have to choose a licensed, accurate, and reliable data source for the information your application needs.

There are several sources for obtaining racing data. Equibase is a well-known source for North American Thoroughbred racing information and provides entries, results, and racing statistics.

It also provides an API that can be used as a direct data source. However, access requires an authenticated account. Live odds and other commercial data feeds should therefore be obtained through an authorized provider rather than collected from a public webpage.

It is also useful to limit the initial scope of the dashboard. Instead of trying to include every horse race, start with one country, racing circuit, or a small number of races. Focusing on a single racetrack can make the initial implementation easier to manage.

Starting with a limited geographic or racing scope also makes it easier to organize the available data and test the application. The goal should be to provide accurate and useful information while keeping the initial system manageable. Once the basic implementation works reliably, additional tracks and data sources can be added.

The first step is to create an abstraction around the external data provider:

public interface IRacingFeed
{
    IAsyncEnumerable<RaceUpdate> ReadUpdatesAsync(
        CancellationToken cancellationToken);
}

public sealed record RaceUpdate(
    string RaceId,
    int PostPosition,
    string HorseName,
    decimal? Odds,
    bool IsScratched,
    DateTimeOffset ReceivedAt);

A development implementation can read recorded JSON messages from disk. Production can use a licensed HTTP, WebSocket, or streaming feed without changing the rest of the application.

Model Snapshots and Deltas Separately

A newly connected user needs the complete race data. Existing users, however, do not need the entire dataset every time a single value changes.

The application should therefore distinguish between an initial snapshot and incremental updates.

For example, if one runner's odds change, there is no need to broadcast the complete race state again. Instead, the application can send a delta containing only the changed information.

public sealed record RunnerQuote(
    int PostPosition,
    string HorseName,
    decimal? Odds,
    bool IsScratched);

public sealed record RaceSnapshot(
    string RaceId,
    long Version,
    DateTimeOffset UpdatedAt,
    IReadOnlyList<RunnerQuote> Runners);

public sealed record RaceDelta(
    string RaceId,
    long Version,
    RunnerQuote Runner);

The version number provides an ordering mechanism. The browser can use it to reject an older message that arrives after a newer snapshot or update.

Use a Strongly Typed SignalR Hub

SignalR hubs can send messages to all clients, individual connections, or named groups. A strongly typed Hub<T> provides compile-time checking for server-to-client methods instead of relying on string-based method names.

public interface IRaceClient
{
    Task ReceiveSnapshot(RaceSnapshot snapshot);
    Task ReceiveDelta(RaceDelta delta);
}

public sealed class RaceHub(IRaceStateStore state)
    : Hub<IRaceClient>
{
    public async Task Subscribe(string raceId)
    {
        var group = $"race:{raceId}";

        await Groups.AddToGroupAsync(
            Context.ConnectionId,
            group);

        var snapshot = await state.GetAsync(
            raceId,
            Context.ConnectionAborted);

        if (snapshot is not null)
        {
            await Clients.Caller.ReceiveSnapshot(snapshot);
        }
    }

    public Task Unsubscribe(string raceId) =>
        Groups.RemoveFromGroupAsync(
            Context.ConnectionId,
            $"race:{raceId}");
}

SignalR groups are useful for this scenario because users interested in one race do not need to receive updates for unrelated races.

Register the Hub in Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR();
builder.Services.AddSingleton<IRaceStateStore, RaceStateStore>();
builder.Services.AddSingleton<IRacingFeed, ReplayRacingFeed>();
builder.Services.AddHostedService<RaceFeedWorker>();

var app = builder.Build();

app.UseDefaultFiles();
app.UseStaticFiles();

app.MapHub<RaceHub>("/hubs/races");

app.Run();

Let a Background Service Process the Feed

The hub should manage client subscriptions. It should not maintain a permanent connection to the external data provider or poll an external API every time a browser opens the page.

ASP.NET Core allows services outside a hub to publish messages through an injected IHubContext. This approach is useful for controllers, middleware, and dependency-injected background services.

A background service can continuously consume the racing feed, update the current state, and publish only meaningful changes to subscribed clients.

public sealed class RaceFeedWorker(
    IRacingFeed feed,
    IRaceStateStore state,
    IHubContext<RaceHub, IRaceClient> hub,
    ILogger<RaceFeedWorker> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        await foreach (var update in
            feed.ReadUpdatesAsync(stoppingToken))
        {
            try
            {
                var delta = await state.ApplyAsync(
                    update,
                    stoppingToken);

                if (delta is null)
                {
                    continue;
                }

                await hub.Clients
                    .Group($"race:{delta.RaceId}")
                    .ReceiveDelta(delta);
            }
            catch (Exception ex)
            {
                logger.LogError(
                    ex,
                    "Failed to process update for {RaceId}",
                    update.RaceId);
            }
        }
    }
}

ApplyAsync should compare the incoming value with the stored runner. When nothing has changed, it returns null.

This prevents the dashboard from broadcasting identical values repeatedly when the provider sends periodic refresh messages.

Real-time does not mean sending everything all the time.

It means sending the right information quickly.

Connect the Browser and Enable Reconnection

SignalR's JavaScript client does not automatically reconnect unless withAutomaticReconnect() is enabled.

let currentVersion = 0;

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/races")
    .withAutomaticReconnect()
    .build();

connection.on("ReceiveSnapshot", snapshot => {
    if (snapshot.version < currentVersion) return;

    currentVersion = snapshot.version;
    renderRace(snapshot);
});

connection.on("ReceiveDelta", delta => {
    if (delta.version <= currentVersion) return;

    currentVersion = delta.version;
    updateRunner(delta.runner);
});

connection.onreconnected(async () => {
    await connection.invoke("Subscribe", raceId);
});

await connection.start();
await connection.invoke("Subscribe", raceId);

After reconnecting, the client should subscribe again and obtain a fresh snapshot. The browser should not assume that it received every update while the connection was unavailable.

A dashboard that reconnects but continues displaying stale data is technically online but functionally incorrect.

Keep Race State Outside the Hub

SignalR hub instances are transient. The current race state should therefore be stored in a separate service rather than in fields on the hub.

For a single-server setup, a thread-safe in-memory store may be sufficient for the current state.

In a multi-server environment, the application needs a shared state mechanism so that different application instances can access the latest snapshot.

ASP.NET Core exposes distributed caching through IDistributedCache, with Redis available through Microsoft.Extensions.Caching.StackExchangeRedis.

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration =
        builder.Configuration.GetConnectionString("Redis");

    options.InstanceName = "RaceDashboard:";
});

The current RaceSnapshot can be stored under a key such as:

race:DMR:2026-08-22:8

Historical data, such as odds movements and other time-series information, should be persisted in a database or event store rather than being kept only in cache.

Final Thoughts

A real-time racing dashboard does not need to start with a highly complex architecture. A better approach is to begin with a limited data scope, establish a reliable data ingestion layer, and build the real-time update pipeline incrementally.

A practical implementation can start with a licensed data provider and a limited set of races. Once the ingestion, state management, SignalR communication, and reconnection mechanisms are working reliably, additional races and data sources can be introduced.

SignalR can deliver changes only to clients subscribed to the relevant race, while a shared state or distributed caching layer can allow multiple application instances to access current data.

The important part is not simply connecting a data provider to SignalR. The application also needs to handle state management, incremental updates, reconnection, versioning, and scalability.