The good thing about modern technology is that you can build stuff yourself. Even when we’re talking about complex builds like a horse racing data dashboard. The actual structure of the dashboard might look simple, but the difficulty comes when it’s time to process information. And horse racing uses tons of scraped data.
We’re talking about race entries, post positions, odds, jockey changes, results, news, and everything else. So, basically we’re building an app that needs an initial snapshot, small live updates, recognition handling, and a way to keep several servers in agreement.
So, in today’s article, we will dive deeper into how to build a horse racing data dashboard with ASP.NET and SignalR. Let’s start outlining the important stuff that is crucial for this build.
Table of Contents
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 your source accuracy. So, you have to pick a licensed, accurate, and fast data source to scrape information from.
The good thing is that there are plenty of places to get that information. Equibase is the official source for North American Thoroughbred racing information. It provides publicly viewable entries, results, and thorough statistics.
On top of that, they also have an API allowing you to connect to it as a direct source. However, it requires an authenticated account, so keep that in mind. This means that live odds and commercial data feeds should always be obtained through an authorised provider rather than scraped from a public webpage.
But we can go one step back and discuss races. It is not a good idea to feature every single horse race in your dashboard. You should first pick a country or a couple of races. Focusing on a single racetrack, like Del Mar races, for example, is much easier for implementation.
Since this dashboard will be mainly used for bettors, focusing on a single racetrack or country allows you to find data sources much more easily. Betting is a huge part of the process, so data accuracy should also be important. Bettors will be searching Del Mar picks, entries, race schedules, best bets, and more. So, try not only quality but also information depth, and the only way to do that is to start small.
So, you should first find your provider, then build a code around it to hide it. Something like this:
public interface IRacingFeed
{
IAsyncEnumerable<RaceUpdate> ReadUpdatesAsync(
CancellationToken cancellationToken);
}
public sealed record RaceUpdate(
string RaceId,
int PostPosition,
string HorseName,
decimal? Odds,
bool IsScratched,
DateTimeOffset ReceivedAt);
Your 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
Now let’s talk about snapshots. A newly connected user needs the entire race data. On the other hand, you cannot bombard an existing user with information they already know.
In other words, you need a system to detect what information the user saw and give updates to only what’s changed. Do not broadcast the complete field every time one horse moves from 4-1 to 7-2.
Here is how to do that:
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);
As you can see, the code runs on versions. It lets the browser reject an old message that arrives after the newer snapshot.
Use a Strongly Typed SignalR Hub
SignalR hubs can send messages to all clients, individual connections or named groups. A strongly typed Hub<T> adds compile-time checking for server-to-client methods instead of relying on method names written as strings.
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}”);
}
A SignalR group is ideal here because users watching Del Mar’s fifth race do not need every update from Saratoga, Gulfstream and fifteen other tracks.
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 subscriptions.
It should not maintain a permanent connection to the racing 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. Microsoft specifically supports using it from controllers, middleware and dependency-injected background services.
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 changed, it returns null.
That small check prevents the dashboard from broadcasting identical odds repeatedly because the provider sends periodic refresh messages.
Real-time does not mean sending everything all the time.
It means sending the right thing 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, subscribe again and request a fresh snapshot. Do not assume the browser received every update while the connection was unavailable.
A dashboard that reconnects but preserves stale prices is technically online.
Keep Race State Outside the Hub
SignalR hub interfaces are transient. This means that you should store the current race state in a separate service rather than fields on the hub. If you’re using a single-server setup (most people would start with this), a thread-safe in-memory store is just enough.
In other words, multiple application interfaces need access to the same latest snapshot.
ASP.NET Core exposes distributed caching through IDistributedCache, with Redis available through Microsoft.Extensions.Caching.StackExchangeRedis. A distributed cache remains consistent across multiple application servers and survives ordinary app restarts and deployments.
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration =
builder.Configuration.GetConnectionString(“Redis”);
options.InstanceName = “RaceDashboard:”;
});
Persist the current RaceSnapshot under a key such as race:DMR:2026-08-22:8. Historical odds movements should go to a database or event store, not remain only in cache.
Final Thoughts
Don’t try to overcomplicate things from the start. A good racing dashboard has to start simple. If you start including large data streams, multiple sources, and try to cover hundreds of races, it is a recipe for disaster.
So, a good plan is to find a licensed provider for data and structure updates around a single race or racetrack, build a system that works, and try to duplicate that as you expand.
Remember, SignalR sends those changes only to clients subscribed to the relevant race, and Redis keeps multiple application interfaces consistent, which is crucial for expanding your platform.
So, the build is not as simple as copy/pasting code. It needs some personalization and fine-tuning, but since resources are available everywhere, it becomes much easier.