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.
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 plenty of places to obtain 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 dashboard can initially focus on information such as entries, race schedules, results, and other race-related details. 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.
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}");
}
Join the conversation! Your thoughts help the community grow.