So far in this series, we’ve covered the foundations (part 1), connections and hubs (part 2), and building collaborative features with groups (part 3). In this post, we’re going to look at a different flavor of real-time: streaming data updates. This one is aimed at developers who already understand SignalR basics and want to push a continuous flow of data from the server to clients — think dashboards that update on their own, monitoring screens, or live feeds. Our goal is to show how to use SignalR for exactly that kind of continuous data stream.
Note: All code demos for this series are available in nimblepros/signalr-code-demo.
Use Cases: Continuous Data Streams 📈
Real-time updates so far have been event-driven: something happens, and a message goes out. Streaming is when the server has a steady flow of data to push. Some familiar examples:
- Stock prices: a price changes many times a second, and a dashboard shows the latest tick.
- Sensor readings: IoT devices report temperature, pressure, or other telemetry on an ongoing basis.
- Game server data: player counts, scores, and match state updating live.
- Monitoring & dashboards: CPU usage, request rates, or queue depths streaming into an operations screen.
- Progress tracking: a long-running background job reporting its progress as it goes.
The common thread: data arrives continuously, and the UI needs to reflect the latest values without any polling or page refreshing.
Server-Sent Events (SSE): Where It Fits 🛰️
Before we write code, it’s worth understanding how the server actually pushes all this data. SignalR supports several transports — the underlying mechanism that carries messages between client and server:
- WebSockets: a bidirectional, full-duplex connection. The preferred transport.
- Server-Sent Events (SSE): a lightweight, one-way channel where the server pushes events to the client over a regular HTTP connection.
- Long Polling: a fallback that keeps an HTTP request open until the server has something to send.
SignalR picks the best transport automatically for each client, preferring WebSockets and falling back when they aren’t available:
flowchart TD
A[Client wants real-time updates] --> B{WebSockets available?}
B -->|Yes| C[Use WebSockets - bidirectional]
B -->|No| D{Server-Sent Events available?}
D -->|Yes| E[Use SSE - server pushes to client]
D -->|No| F[Fall back to Long Polling]
C --> G[Your code uses the same streaming API]
E --> G
F --> G
The great part is that you don’t write different code for each transport. SSE is especially well-suited to the scenario in this post — server-to-client streaming of updates — because it’s a natural fit for one-way pushes. SignalR abstracts the transport away, so whether the connection lands on WebSockets or SSE, your streaming code looks the same.
Code Example: A Live Price Stream 👨💻
Let’s build a dashboard that displays a live stream of simulated stock prices. This demonstrates SignalR’s server-to-client streaming, where a hub method returns a stream that the client consumes as items arrive.
First, the model. Create a file called StockPrice.cs:
namespace SignalRForBlazorDemo.Models;
public record StockPrice(string Symbol, decimal Price, DateTimeOffset Timestamp);
Next, the hub. Create a file called MarketDataHub.cs:
using System.Threading.Channels;
using Microsoft.AspNetCore.SignalR;
using SignalRForBlazorDemo.Models;
namespace SignalRForBlazorDemo.Hubs;
public class MarketDataHub : Hub
{
// Server-to-client streaming: this method returns a ChannelReader<T> that
// the client iterates over with StreamAsync. The client receives a
// continuous flow of StockPrice ticks until it cancels or disconnects.
public ChannelReader<StockPrice> StreamPricesAsync(
string symbol, CancellationToken cancellationToken)
{
// An unbounded channel lets the producer write and the consumer read
// without blocking each other.
var channel = Channel.CreateUnbounded<StockPrice>();
// Run the simulated data feed in the background. In a real app this
// might read from a queue, a database, or a third-party market API.
_ = Task.Run(async () =>
{
var random = new Random();
var price = 100.0m;
try
{
while (!cancellationToken.IsCancellationRequested)
{
// Simulate a price tick.
price += (decimal)(random.NextDouble() - 0.5) * 2m;
await channel.Writer.WriteAsync(
new StockPrice(symbol, price, DateTimeOffset.UtcNow),
cancellationToken);
// A real feed would push as fast as data arrives.
await Task.Delay(1000, cancellationToken);
}
}
finally
{
// Always complete the writer so the client's foreach loop ends.
channel.Writer.TryComplete();
}
}, cancellationToken);
return channel.Reader;
}
}
Two things worth calling out here:
- The cancellation token is special. The
CancellationTokenpassed to a hub method is cancelled automatically when the client disconnects or cancels the stream. That’s what stops the background loop and completes the channel — no orphaned producers chewing up CPU forever. ChannelReader<T>vs.IAsyncEnumerable<T>. A hub method can return either.ChannelReader<T>is the right choice when the data is produced in the background (as it is here);IAsyncEnumerable<T>is a simpler fit when you already have a stream to yield from.
Register the hub in Program.cs:
app.MapHub<MarketDataHub>("/marketdata");
Finally, the component that consumes the stream. Create a file called LivePriceStream.razor:
@using Microsoft.AspNetCore.SignalR.Client
@implements IAsyncDisposable
<label>Symbol: <input value="@_symbol" @oninput="OnSymbolInput" /></label>
<button @onclick="StartStreamingAsync" disabled="@(_isStreaming)">Start Stream</button>
<button @onclick="StopStreaming" disabled="@(!_isStreaming)">Stop Stream</button>
@if (_errorMessage is not null)
{
<p class="text-red-600">@_errorMessage</p>
}
<table>
<thead>
<tr><th>Time</th><th>Price</th></tr>
</thead>
<tbody>
@foreach (var tick in _ticks)
{
<tr><td>@tick.Timestamp.ToString("HH:mm:ss")</td><td>@tick.Price.ToString("C")</td></tr>
}
</tbody>
</table>
@code {
private HubConnection? _connection;
private CancellationTokenSource? _streamCts;
private string _symbol = "MSFT";
private bool _isStreaming;
private string? _errorMessage;
private readonly List<StockPrice> _ticks = [];
protected override async Task OnInitializedAsync()
{
_connection = new HubConnectionBuilder()
.WithUrl("/marketdata")
.WithAutomaticReconnect()
.Build();
await _connection.StartAsync();
}
private async Task StartStreamingAsync()
{
if (_connection is null)
{
return;
}
_streamCts = new CancellationTokenSource();
_isStreaming = true;
_errorMessage = null;
_ticks.Clear();
try
{
// StreamAsync returns an IAsyncEnumerable<StockPrice> that yields
// each item the server writes to its channel, as it's written.
var stream = _connection.StreamAsync<StockPrice>(
"StreamPricesAsync", _symbol, _streamCts.Token);
await foreach (var tick in stream)
{
// Insert at the front so the most recent tick is on top.
_ticks.Insert(0, tick);
StateHasChanged();
}
}
catch (OperationCanceledException)
{
// Expected when the user stops the stream - not an error.
}
catch (Exception ex)
{
_errorMessage = $"Stream failed: {ex.Message}";
}
finally
{
_isStreaming = false;
}
}
private void StopStreaming()
{
// Cancelling the token tells the server to stop producing ticks and
// ends the await foreach loop cleanly.
_streamCts?.Cancel();
}
private void OnSymbolInput(ChangeEventArgs e)
{
_symbol = e.Value?.ToString() ?? string.Empty;
}
public async ValueTask DisposeAsync()
{
// Make sure the stream stops and the connection is cleaned up.
_streamCts?.Cancel();
if (_connection is not null)
{
await _connection.DisposeAsync();
}
}
}
Here’s the flow we just built, in picture form:
sequenceDiagram
participant C as LivePriceStream (Blazor component)
participant H as MarketDataHub (server)
participant P as Price feed (channel producer)
C->>H: StreamAsync("StreamPricesAsync", "MSFT")
H->>P: start producing ticks
activate P
loop every second
P-->>C: StockPrice tick
C->>C: insert tick and re-render the table
end
C-->>H: Stop / component disposed
Note over H: cancellation token fires, producer stops, channel completes
deactivate P
When you click Start Stream, the component opens the stream, and the server starts sending a price tick every second. Each tick arrives through await foreach, gets inserted into the list, and the table re-renders. Click Stop Stream (or close the page), and the cancellation token on the server stops the feed.
Scalability & Performance Considerations 🚀
Streaming every data point as it happens can get expensive once you have many clients and high-frequency data. A few techniques to keep in mind:
- Batch updates. Instead of sending one message per tick, accumulate ticks for a short time window (say 500 ms) and send them as one message. Your UI updates slightly less frequently, but the network traffic and message overhead drop dramatically.
- Send deltas, not snapshots. When only part of the data changed, send just the change instead of the whole payload. This matters most with large datasets, like a big monitoring grid.
- Consider backpressure. A fast producer and a slow consumer can pile up data. A bounded channel with a reasonable capacity forces the producer to slow down rather than buffer unboundedly.
- Choose a compact protocol. By default SignalR serializes JSON. The MessagePack protocol produces much smaller payloads for high-throughput scenarios, at the cost of a little setup.
- Stop producers when no one is listening. The cancellation token on a hub method fires when the client disconnects, which we leveraged above. If you’re generating data for many streams, consider centralizing production so a single producer feeds all subscribers, and shut it down when the last client leaves.
What’s Next? 🎬
This post rounded out the second of the three use cases we previewed back in part 1 — first collaboration, now live data updates. Next up is the third: notifications. We’ll look at pushing alerts and messages to users as events happen — the finishing piece of the real-time trio, and a great place to put everything we’ve learned together.
Resources 📚
- nimblepros/signalr-code-demo
- Streaming with SignalR in ASP.NET Core — official docs on client and server streaming
- SignalR .NET client — official docs on
StreamAsync - Server-Sent Events — MDN overview of SSE
- MessagePack Hub Protocol — official docs on the compact protocol

