Tuesday, 18 August 2026

Comparing RoutingChatClient with Static Model Selection

Leave a Comment

More and more AI models are being used in contemporary.NET applications.

 

One program could be able to access:

A single application may have access to:

  • A high-capability model for complex reasoning

  • A faster model for simple requests

  • A lower-cost model for routine workloads

  • A specialized model for a particular task

  • A fallback model for availability problems

The engineering challenge is deciding which model should handle each request.

A static model-selection strategy is straightforward:

Application
    |
    v
Selected Model
    |
    v
Response

A routing strategy adds another decision layer:

Application
    |
    v
Routing Layer
    |
    +---- Model A
    +---- Model B
    +---- Model C
    |
    v
Response

The advantage is flexibility, but routing also introduces additional decision logic and potentially additional latency.

Microsoft.Extensions.AI provides the IChatClient abstraction and composable chat-client pipelines, which makes it possible to place routing, logging, retry, configuration, and other behaviors around AI clients. The current API also provides ChatClientBuilder and DelegatingChatClient as mechanisms for composing these pipelines.

This article explains how to benchmark a routing-based chat client against static model selection and determine whether routing actually improves the application's overall performance and economics.

Introduction

Suppose an application supports three request categories:

Simple Question
      |
      v
Fast Model

Complex Reasoning
      |
      v
Advanced Model

Fallback
      |
      v
Backup Model

A static strategy might send every request to the same model:

Every Request
      |
      v
Model A

A routing strategy might inspect the request and select a model:

+--> Model A
                     |
Request --> Router --+--> Model B
                     |
                     +--> Model C

The routing strategy can potentially improve cost, latency, or availability.

However, the router itself has a cost.

It may introduce:

  • Classification latency

  • Additional model calls

  • More complex configuration

  • More difficult debugging

  • Different behavior across workloads

Therefore, routing should be treated as an engineering hypothesis that needs to be benchmarked.

What Is Static Model Selection?

Static model selection means the application chooses the model before processing the request and does not dynamically change that choice.

For example:

IChatClient client = primaryClient;

var response = await client.GetResponseAsync(
    "Explain dependency injection in .NET.",
    cancellationToken: cancellationToken);
C#

The application knows exactly which client will process the request.

This approach is simple and predictable.

The execution path is:

Request
  |
  v
Static Selection
  |
  v
Model
  |
  v
Response

What Is Routing?

Routing introduces a decision mechanism between the application and the underlying model clients.

Request
   |
   v
Router
   |
   +--> Fast Model
   |
   +--> Capable Model
   |
   +--> Fallback Model

The router can use different strategies.

For example:

Request Complexity
      |
      +--> Simple ----> Fast Model
      |
      +--> Moderate -> Balanced Model
      |
      +--> Complex --> Advanced Model

Another strategy could use availability:

Primary Model
     |
     X
Unavailable
     |
     v
Fallback Model

A third strategy could combine both:

Complexity
    +
Cost
    +
Availability
    +
Latency
    |
    v
Model Selection

Microsoft.Extensions.AI and IChatClient

The IChatClient abstraction provides a common interface for chat model interactions. This allows application code to work against an abstraction instead of depending directly on a specific model implementation.

This is important for benchmarking because the application can execute the same workload against different client configurations.

For example:

public interface IModelExecutor
{
    Task<ChatResponse> ExecuteAsync(
        string prompt,
        CancellationToken cancellationToken);
}
C#

A static implementation can wrap one model.

A routing implementation can select among several models.

The benchmark can then compare both using the same test scenarios.

Static Selection Architecture

A simple static architecture looks like this:

Application
        |
        v
IChatClient
        |
        v
   Model A

The advantage is that there is almost no selection overhead.

The main limitation is that every request follows the same model path unless application code explicitly changes the client.

Routing Architecture

A routing architecture looks like this:

Application
             |
             v
       Routing Layer
             |
   +---------+---------+
   |         |         |
   v         v         v
Model A   Model B   Model C

The router becomes responsible for determining the target.

This can be implemented as a custom IChatClient wrapper or as a component in a chat-client pipeline.

DelegatingChatClient is specifically designed as a base type for clients that wrap another IChatClient, and the chat-client pipeline can be composed using ChatClientBuilder.Use(...).

Define the Benchmark Question

Before measuring anything, define what the benchmark is trying to prove.

For example:

Does dynamic routing reduce cost without causing unacceptable latency or quality degradation compared with static model selection?

That question produces several measurable dimensions:

Latency
Cost
Quality
Success Rate
Fallback Rate
Routing Accuracy
Throughput

Without a clear hypothesis, it is easy to produce a benchmark that generates numbers without providing an engineering conclusion.

Benchmark Scenarios

Use multiple workload categories.

Simple Requests

Examples:

What is dependency injection?

Convert this JSON into a C# record.

What does HTTP 404 mean?

Complex Requests

Examples:

Analyze this architecture and identify scalability risks.

Explain the tradeoffs between two distributed-system designs.

Review this code and identify concurrency problems.

Long-Context Requests

These contain larger amounts of input and can expose different model behavior.

Failure Scenarios

Simulate:

Timeout
Rate Limit
Unavailable Model
Invalid Response
Transient Network Failure

Routing should be evaluated not only when everything works but also when the preferred model fails.

Establish a Baseline

The first benchmark should use static selection.

For example:

All Requests
     |
     v
Model A

Measure:

p50 latency
p95 latency
p99 latency
Token usage
Cost
Success rate
Quality score

This becomes the baseline.

Then execute the same workload through the router.

All Requests
     |
     v
Router
     |
     +--> Model A
     +--> Model B
     +--> Model C

The two measurements can then be compared.

Benchmark Harness

Create a common interface.

public interface IBenchmarkClient
{
    string Name { get; }

    Task<BenchmarkResponse> ExecuteAsync(
        BenchmarkRequest request,
        CancellationToken cancellationToken);
}
C#

The request can contain:

public sealed record BenchmarkRequest(
    string Id,
    string Category,
    string Prompt);
C#

The response can contain:

public sealed record BenchmarkResponse(
    string RequestId,
    string Model,
    TimeSpan Latency,
    long InputTokens,
    long OutputTokens,
    bool Success);
C#

This provides a consistent measurement format.

Measure Latency

Use a monotonic timer.

var start = Stopwatch.GetTimestamp();

var response = await client.ExecuteAsync(
    request,
    cancellationToken);

var elapsed =
    Stopwatch.GetElapsedTime(start);
C#

This measures application-observed execution time.

Do not include unrelated operations such as loading the benchmark dataset or writing the final report inside the timed region.

Measure Routing Overhead Separately

Routing latency should not be hidden.

Consider:

Total Routed Latency
=
Routing Decision
+
Model Request
+
Response Processing

If routing itself requires another model call:

Total Latency
=
Router Model Call
+
Target Model Call

That additional call can be significant.

If routing is rule-based:

Total Latency
=
Rule Evaluation
+
Target Model Call

The difference can be substantial.

Therefore, capture routing time independently:

var routingStart = Stopwatch.GetTimestamp();

var target = await router.SelectAsync(
    request,
    cancellationToken);

var routingLatency =
    Stopwatch.GetElapsedTime(routingStart);
C#

Then measure the actual model request separately.

Benchmark Static Selection

A static benchmark might look like:

public async Task<BenchmarkResponse> RunStaticAsync(
    BenchmarkRequest request,
    IChatClient client,
    CancellationToken cancellationToken)
{
    var start = Stopwatch.GetTimestamp();

    var response = await client.GetResponseAsync(
        request.Prompt,
        cancellationToken: cancellationToken);

    var latency =
        Stopwatch.GetElapsedTime(start);

    return new BenchmarkResponse(
        request.Id,
        "static-model",
        latency,
        GetInputTokens(response),
        GetOutputTokens(response),
        true);
}
C#

The exact token-usage extraction depends on the provider and client implementation.

The benchmark should use the actual usage metadata available from the selected client rather than estimating token counts from string length.

Benchmark Routing

A routing benchmark follows the same measurement boundary:

public async Task<BenchmarkResponse> RunRoutedAsync(
    BenchmarkRequest request,
    IRoutingClient client,
    CancellationToken cancellationToken)
{
    var start = Stopwatch.GetTimestamp();

    var response = await client.GetResponseAsync(
        request.Prompt,
        cancellationToken);

    var latency =
        Stopwatch.GetElapsedTime(start);

    return new BenchmarkResponse(
        request.Id,
        response.Model,
        latency,
        response.InputTokens,
        response.OutputTokens,
        true);
}
C#

The important point is that both strategies receive the same benchmark request.

Rule-Based Routing

The simplest routing approach uses deterministic rules.

public string SelectModel(BenchmarkRequest request)
{
    return request.Category switch
    {
        "Simple" => "fast",
        "Complex" => "advanced",
        "LongContext" => "long-context",
        _ => "fast"
    };
}
C#

This has almost no classification overhead.

It is also easy to test.

The disadvantage is that rules can become increasingly complicated as workloads grow.

LLM-Based Routing

A more dynamic strategy can use a model to classify the request.

User Request
     |
     v
Routing Model
     |
     +--> Simple
     +--> Complex
     +--> Specialized
     |
     v
Target Model

This can be flexible but introduces an additional model operation.

For example:

Routing Decision = 80 ms
Target Model = 600 ms

Total = 680 ms

If static selection requires only:

Target Model = 600 ms

the router has made the request slower.

Routing must therefore generate enough savings elsewhere to justify its own overhead.

Benchmark Routing Accuracy

A routing system should also be evaluated for decision quality.

Create an expected model category for each benchmark request:

public sealed record RoutingExpectation(
    string RequestId,
    string ExpectedRoute);
C#

Then compare:

Expected Route
      vs
Selected Route

Calculate:

Routing Accuracy =
Correct Decisions
-----------------
Total Decisions

A router that selects the wrong model frequently may not produce the expected cost or quality benefits.

Measure Model Quality

Latency alone is not sufficient.

Suppose:

Static Model
Latency: 800 ms
Quality: 0.95

Routing
Latency: 650 ms
Quality: 0.86

Routing is faster, but the quality regression may be unacceptable.

Depending on the application, measure:

  • Task success rate

  • Structured-output validity

  • Groundedness

  • Answer relevance

  • Domain-specific correctness

  • Human evaluation

  • Retrieval quality for RAG workloads

The exact evaluation metric should match the application.

Cost Measurement

For each model request, capture:

Input Tokens
Output Tokens
Model
Pricing Version
Calculated Cost

Then aggregate by route.

Static Strategy
--------------
Total Cost
Average Cost
Cost Per Successful Task

Routing Strategy
----------------
Total Cost
Average Cost
Cost Per Successful Task

The most useful comparison is often:

Cost Per Successful Task

rather than simply cost per API request.

Example Cost Comparison

Imagine a benchmark produces:

MetricStatic SelectionRouting
Requests1,0001,000
Success Rate96%97%
p50 LatencyMeasureMeasure
p95 LatencyMeasureMeasure
Total TokensMeasureMeasure
Total CostMeasureMeasure
Cost / Successful TaskMeasureMeasure

The benchmark should populate these values from actual measurements.

Avoid inserting illustrative numbers into a production recommendation unless they come from a reproducible test.

Fallback Routing

Routing can also be used for resilience.

Primary Model
     |
     X
Failure
     |
     v
Fallback Model

A benchmark should measure:

Primary Success Rate
Fallback Rate
Fallback Latency
Final Success Rate

For example:

Request
  |
  v
Primary
  |
  X
  |
  v
Fallback
  |
  v
Response

The latency of the failed primary attempt should remain visible.

Otherwise, the fallback benchmark may appear faster than it actually is.

Routing and Retries

Retries introduce another variable.

Suppose the router sends a request to Model A.

Model A
   |
   X
Retry
   |
   X
Fallback Model

The final request may succeed, but the total cost includes both failed attempts.

Track:

Attempts
Models Used
Retry Count
Total Latency
Total Cost
Final Status

This provides a much more accurate picture of routing behavior.

Warm-Up Strategy

Do not use the first request as the only benchmark measurement.

Warm up each client before collecting steady-state measurements.

foreach (var client in clients)
{
    await client.ExecuteAsync(
        warmupRequest,
        cancellationToken);
}
C#

Then start collecting measurements.

Run cold-start tests separately if cold-start behavior matters to the production workload.

Run the Same Query Set

The static and routed systems should receive exactly the same workload.

Benchmark Dataset
       |
       +------> Static
       |
       +------> Router

Do not allow the router to receive easier questions than the static system.

A fixed dataset also makes regression testing easier.

Query Distribution Matters

Suppose the real application receives:

70% Simple
20% Moderate
10% Complex

but the benchmark contains:

20% Simple
30% Moderate
50% Complex

The resulting cost and latency numbers may not represent production.

Use a representative distribution.

If several workloads are important, benchmark them separately and report the results independently.

Concurrency Testing

A routing strategy can behave differently under load.

Test:

1 concurrent request
5 concurrent requests
10 concurrent requests
25 concurrent requests
50 concurrent requests

depending on service limits and the target workload.

Measure:

p50
p95
p99
Throughput
Error Rate
Route Distribution

A router that performs well at one request at a time may behave differently when multiple requests compete for the same model capacity.

Route Distribution

Record how frequently each model is selected.

For example:

Model A: 60%
Model B: 30%
Model C: 10%

This is important for cost analysis.

If the router unexpectedly sends 80% of requests to the expensive model, the expected savings may disappear.

Static vs Routing Comparison

A useful comparison table is:

DimensionStatic SelectionRouting
Implementation complexityLowMedium/High
Selection overheadMinimalDepends on strategy
Model flexibilityLowHigh
Cost optimizationLimitedPotentially strong
FailoverExplicit application logicCan be centralized
DebuggingSimpleMore complex
Observability requirementsModerateHigher
Workload adaptationLimitedStronger
PredictabilityHighDepends on routing policy

Routing is not automatically better.

It is better when the additional complexity produces measurable value.

Common Benchmarking Mistakes

Comparing Different Prompts

The workload must remain consistent.

Ignoring Router Latency

A routing decision is part of the request path.

Measuring Only Average Latency

Always examine tail latency.

Ignoring Routing Accuracy

A poor route can increase cost or reduce quality.

Using Only Simple Queries

Routing benefits often appear when workloads have meaningful variation.

Ignoring Failure Paths

Fallback behavior should be benchmarked explicitly.

Ignoring Cost of Retries

A successful fallback may still have incurred multiple failed model calls.

Comparing Different Model Configurations

Keep relevant settings consistent where the benchmark is intended to isolate routing behavior.

Treating Quality as Secondary

A cheaper or faster response is not necessarily a better response.

Observability

A routing system should record enough telemetry to explain every decision.

Useful attributes include:

TraceId
RequestId
SelectedModel
RoutingReason
RoutingLatency
ModelLatency
InputTokens
OutputTokens
RetryCount
FallbackUsed
EstimatedCost
Success

This allows engineers to answer:

Why did this request use Model B?
How long did routing take?
How much did the request cost?
Did fallback occur?
Was the response successful?

These questions become essential when debugging production behavior.

Building a Routing Wrapper

Because DelegatingChatClient is designed for wrapping an inner IChatClient, a custom routing abstraction can follow the same compositional pattern. The important design choice is to keep routing policy separate from model execution.

A simplified conceptual implementation could look like:

public sealed class RoutingChatClient
{
    private readonly IReadOnlyDictionary<string, IChatClient> _clients;
    private readonly IRoutingPolicy _policy;

    public RoutingChatClient(
        IReadOnlyDictionary<string, IChatClient> clients,
        IRoutingPolicy policy)
    {
        _clients = clients;
        _policy = policy;
    }

    public async Task<ChatResponse> GetResponseAsync(
        string prompt,
        CancellationToken cancellationToken)
    {
        var route = await _policy.SelectAsync(
            prompt,
            cancellationToken);

        var client = _clients[route.Model];

        return await client.GetResponseAsync(
            prompt,
            cancellationToken: cancellationToken);
    }
}
C#

This is a simplified example rather than a complete implementation of a production routing client.

In a real application, the routing layer should also handle:

  • Cancellation

  • Resilience

  • Telemetry

  • Model availability

  • Policy validation

  • Error classification

  • Fallback

  • Cost tracking

Composing the Client Pipeline

The ChatClientBuilder API supports composing intermediate chat-client stages. This allows routing-related behavior to coexist with logging, retries, options configuration, function invocation, and other middleware-like components.

A conceptual pipeline can look like:

Application
    |
    v
Routing
    |
    v
Logging
    |
    v
Retry
    |
    v
Model Client

The exact ordering should be chosen deliberately.

For example, placing telemetry around the routing layer can help measure routing decisions separately from downstream model latency.

Release Regression Testing

Once the benchmark works, run it automatically.

Code Change
    |
    v
Build
    |
    v
Benchmark Dataset
    |
    +--> Static Baseline
    |
    +--> Routing Strategy
    |
    v
Compare
    |
    +--> Latency
    +--> Cost
    +--> Quality
    +--> Reliability
    |
    v
Release Decision

For example:

Routing must satisfy:

p95 latency <= baseline + 20%
Quality >= baseline - 5%
Cost per successful task < baseline
Error rate <= baseline

The exact thresholds should be based on application requirements.

When Static Selection Is Better

Static model selection is often preferable when:

  • The workload is highly predictable.

  • One model already satisfies quality requirements.

  • Routing logic does not produce meaningful savings.

  • Simplicity is a major requirement.

  • The additional routing latency is unacceptable.

  • There are few model options.

A simpler architecture can be the better architecture.

When Routing Is Better

Routing becomes more attractive when:

  • Requests vary significantly in complexity.

  • Different models have different strengths.

  • Cost optimization is important.

  • Availability requirements justify fallback paths.

  • The application handles multiple workload classes.

  • The routing decision can be made reliably.

  • The operational team can observe and debug the routing behavior.

Advantages

Better Model Utilization

Different workloads can use different models.

Potential Cost Reduction

Simple requests can avoid unnecessarily expensive models.

Better Resilience

Fallback routing can improve availability when a preferred model fails.

Centralized Policy

Model-selection rules can be managed in one place.

Easier Model Evolution

New models can be introduced without rewriting every application workflow.

Disadvantages

Additional Complexity

Routing adds another component to the request path.

Routing Latency

A model-based router can add another AI operation.

Debugging Complexity

A response can depend on both the routing decision and the selected model.

More Telemetry

Engineers need visibility into route decisions, fallback, retries, and model usage.

Potential Quality Regression

An incorrect route can select a model that is cheaper or faster but less capable for the task.

Best Practices

  1. Establish a static-model baseline before evaluating routing.

  2. Use the same benchmark dataset for both strategies.

  3. Measure routing latency independently.

  4. Track p50, p95, and p99 latency.

  5. Measure routing accuracy.

  6. Track route distribution.

  7. Measure token consumption and cost.

  8. Include model quality in the benchmark.

  9. Test fallback and retry behavior separately.

  10. Run concurrency tests.

  11. Use realistic production query distributions.

  12. Keep model configuration consistent during controlled comparisons.

  13. Record routing decisions in telemetry.

  14. Compare cost per successful task rather than raw request cost alone.

  15. Automate benchmark execution as part of regression testing.

Frequently Asked Questions

Is RoutingChatClient always better than static model selection?

No. Routing introduces additional complexity and potentially additional latency. It should be used when dynamic model selection provides measurable value.

Does routing always reduce AI costs?

No. A router can increase costs if it adds another model call, selects expensive models too frequently, or causes additional retries.

Should routing be rule-based or AI-based?

Start with deterministic rules when they are sufficient. AI-based classification can provide more flexibility, but it introduces additional latency and evaluation complexity.

What should I measure when benchmarking routing?

At minimum, measure latency, cost, quality, routing accuracy, success rate, fallback rate, token usage, and route distribution.

Should the router itself be included in latency?

Yes. If the goal is to measure user-visible request latency, the routing decision is part of the request path and should be included in total latency. It should also be measured separately so its overhead is visible.

How can I prove that routing is worthwhile?

Compare routing with a static baseline using the same workload and evaluate whether it produces an acceptable improvement in cost, latency, reliability, or quality after accounting for routing overhead.

Conclusion

Dynamic model routing is an attractive architecture for applications that work with multiple AI models, but it should not be adopted simply because multiple models are available.

The right question is whether routing produces measurable value compared with a well-defined static baseline.

A useful benchmark evaluates the complete picture:

Routing Overhead
      +
Model Latency
      +
Cost
      +
Quality
      +
Reliability
      +
Fallback Behavior

A routing strategy may reduce cost for simple workloads, improve resilience during model failures, and select more capable models for complex requests. At the same time, it can introduce classification latency, additional operational complexity, and incorrect model selections.

The most reliable approach is therefore empirical: establish a static baseline, run the same workload through the routing strategy, measure p50/p95/p99 latency, cost, quality, route accuracy, and failure behavior, and then make the architecture decision from those results.

In production AI systems, model routing should be treated as a measurable optimization layer rather than an assumption that dynamic selection is automatically better.

Best ASP.NET Core 10.0 Hosting Recommendation

One of the most important things when choosing a good ASP.NET Core 8.0 hosting is the feature and reliability. HostForLIFE is the leading provider of Windows hosting and affordable ASP.NET Core, their servers are optimized for PHP web applications. The performance and the uptime of the hosting service are excellent and the features of the web hosting plan are even greater than what many hosting providers ask you to pay for. 

At HostForLIFE.eu, customers can also experience fast ASP.NET Core hosting. The company invested a lot of money to ensure the best and fastest performance of the datacenters, servers, network and other facilities. Its datacenters are equipped with the top equipments like cooling system, fire detection, high speed Internet connection, and so on. That is why HostForLIFEASP.NET guarantees 99.9% uptime for ASP.NET Core. And the engineers do regular maintenance and monitoring works to assure its Orchard hosting are security and always up.

Read More...

Tuesday, 11 August 2026

Using SignalR and ASP.NET Core to Create a Real-Time Horse Racing Data Dashboard

Leave a Comment

The ability to construct things yourself is one of the benefits of contemporary technology. even when discussing intricate constructions, such as a data dashboard for horse racing. The dashboard's actual layout may appear straightforward, but processing information becomes challenging. Additionally, a lot of scraped data is used in horse racing.



Race entries, post placements, odds, jockey changes, results, news, and everything else are all under discussion. In essence, we are developing an application that requires an initial snapshot, minor real-time updates, recognition processing, and a mechanism to maintain consensus across several servers.

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}");
}

A SignalR group is ideal here because users watching one race do not need every update from other tracks and 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 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, 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.

ASP.NET Core 10.0 Hosting Recommendation

One of the most important things when choosing a good ASP.NET Core 10.0 hosting is the feature and reliability. HostForLIFE is the leading provider of Windows hosting and affordable ASP.NET Core, their servers are optimized for PHP web applications. The performance and the uptime of the hosting service are excellent and the features of the web hosting plan are even greater than what many hosting providers ask you to pay for. 

At HostForLIFE.eu, customers can also experience fast ASP.NET Core hosting. The company invested a lot of money to ensure the best and fastest performance of the datacenters, servers, network and other facilities. Its datacenters are equipped with the top equipments like cooling system, fire detection, high speed Internet connection, and so on. That is why HostForLIFE.eu guarantees 99.9% uptime for ASP.NET Core. And the engineers do regular maintenance and monitoring works to assure its Orchard hosting are security and always up.

 

 

Read More...

Wednesday, 5 August 2026

Using JWT Authentication with Secure Refresh Tokens in .NET Core

Leave a Comment

The industry standard for stateless API authentication is JSON Web Tokens (JWT). However, contemporary apps need a Refresh Token method to issue new access tokens without requiring the user to constantly re-authenticate because access tokens have a limited lifespan for security reasons.

 
Step 1: Install Required NuGet Packages

Add the necessary JWT bearer authentication package to your project:

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
Step 2: Configure JWT Settings in appsettings.json

Store your signing keys, issuers, and token expiration lifetimes securely in configuration files.

{
  "JwtSettings": {
    "Secret": "SuperSecretKeyForJwtAuthenticationMustBeLongEnough123!",
    "Issuer": "YourApiIssuer",
    "Audience": "YourApiAudience",
    "AccessTokenExpirationMinutes": 15,
    "RefreshTokenExpirationDays": 7
  }
}
Step 3: Create a Token Generation Service

Implement a service responsible for generating cryptographic access tokens and secure, cryptographically random refresh tokens.

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;

public interface ITokenService
{
    string GenerateAccessToken(IEnumerable<Claim> claims);
    string GenerateRefreshToken();
    ClaimsPrincipal? GetPrincipalFromExpiredToken(string token);
}

public class TokenService : ITokenService
{
    private readonly IConfiguration _configuration;

    public TokenService(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    public string GenerateAccessToken(IEnumerable<Claim> claims)
    {
        var key = Encoding.UTF8.GetBytes(_configuration["JwtSettings:Secret"]!);
        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(claims),
            Expires = DateTime.UtcNow.AddMinutes(int.Parse(_configuration["JwtSettings:AccessTokenExpirationMinutes"]!)),
            Issuer = _configuration["JwtSettings:Issuer"],
            Audience = _configuration["JwtSettings:Audience"],
            SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
        };

        var tokenHandler = new JwtSecurityTokenHandler();
        var token = tokenHandler.CreateToken(tokenDescriptor);
        return tokenHandler.WriteToken(token);
    }

    public string GenerateRefreshToken()
    {
        var randomNumber = new byte[32];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomNumber);
        return Convert.ToBase64String(randomNumber);
    }

    public ClaimsPrincipal? GetPrincipalFromExpiredToken(string token)
    {
        var tokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = true,
            ValidAudience = _configuration["JwtSettings:Audience"],
            ValidateIssuer = true,
            ValidIssuer = _configuration["JwtSettings:Issuer"],
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JwtSettings:Secret"]!)),
            ValidateLifetime = false // Crucial: allows parsing expired tokens during refresh flow
        };

        var tokenHandler = new JwtSecurityTokenHandler();
        try
        {
            var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken);
            if (securityToken is not JwtSecurityToken jwtSecurityToken ||
                !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
            {
                return null;
            }

            return principal;
        }
        catch
        {
            return null;
        }
    }
}
Step 4: Configure Authentication in Program.cs

Wire up JWT bearer authentication services into your dependency injection pipeline.

using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

// Configure JWT Authentication
var jwtSettings = builder.Configuration.GetSection("JwtSettings");
var secretKey = Encoding.UTF8.GetBytes(jwtSettings["Secret"]!);

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidIssuer = jwtSettings["Issuer"],
        ValidateAudience = true,
        ValidAudience = jwtSettings["Audience"],
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(secretKey),
        ValidateLifetime = true,
        ClockSkew = TimeSpan.Zero
    };
});

builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddControllers();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();
Step 5: Implement the Auth Controller

Create endpoints for user login (issuing both access and refresh tokens) and token refreshing.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly ITokenService _tokenService;

    public AuthController(ITokenService tokenService)
    {
        _tokenService = tokenService;
    }

    [HttpPost("login")]
    public IActionResult Login([FromBody] LoginModel model)
    {
        // Validate user credentials against database (mocked here)
        if (model.Username != "admin" || model.Password != "password")
            return Unauthorized("Invalid credentials.");

        var claims = new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, model.Username) };

        var accessToken = _tokenService.GenerateAccessToken(claims);
        var refreshToken = _tokenService.GenerateRefreshToken();

        // Save refresh token securely to database associated with the user...

        return Ok(new { AccessToken = accessToken, RefreshToken = refreshToken });
    }

    [HttpPost("refresh")]
    public IActionResult Refresh([FromBody] TokenRequestModel model)
    {
        var principal = _tokenService.GetPrincipalFromExpiredToken(model.AccessToken);
        if (principal is null) return BadRequest("Invalid access token.");

        var username = principal.Identity?.Name;

        // Retrieve and validate stored refresh token from database for the user...

        var newAccessToken = _tokenService.GenerateAccessToken(principal.Claims);
        var newRefreshToken = _tokenService.GenerateRefreshToken();

        return Ok(new { AccessToken = newAccessToken, RefreshToken = newRefreshToken });
    }
}

public record LoginModel(string Username, string Password);
public record TokenRequestModel(string AccessToken, string RefreshToken);

ASP.NET Core 10.0 Hosting Recommendation

One of the most important things when choosing a good ASP.NET Core 10.0 hosting is the feature and reliability. HostForLIFE is the leading provider of Windows hosting and affordable ASP.NET Core, their servers are optimized for PHP web applications. The performance and the uptime of the hosting service are excellent and the features of the web hosting plan are even greater than what many hosting providers ask you to pay for. 

At HostForLIFE.eu, customers can also experience fast ASP.NET Core hosting. The company invested a lot of money to ensure the best and fastest performance of the datacenters, servers, network and other facilities. Its datacenters are equipped with the top equipments like cooling system, fire detection, high speed Internet connection, and so on. That is why HostForLIFE.eu guarantees 99.9% uptime for ASP.NET Core. And the engineers do regular maintenance and monitoring works to assure its Orchard hosting are security and always up.

 
Read More...