Streaming Data with SignalR in .NET
Standard SignalR invocations send a complete message in one go. But some scenarios involve continuous data — stock tickers, log tailing, sensor readings, or file uploads. SignalR supports streaming in both directions: server-to-client and client-to-server.
Server-to-Client Streaming
Instead of returning a single result, a hub method can return an IAsyncEnumerable<T> or ChannelReader<T> to stream items to the client one at a time.
Using IAsyncEnumerable
The simplest approach uses IAsyncEnumerable<T>:
public class StockHub : Hub
{
private readonly IStockService _stockService;
public StockHub(IStockService stockService)
{
_stockService = stockService;
}
public async IAsyncEnumerable<StockPrice> StreamPrices(
string[] symbols,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
foreach (var symbol in symbols)
{
var price = await _stockService.GetPriceAsync(symbol);
yield return price;
}
await Task.Delay(1000, cancellationToken);
}
}
}
public record StockPrice(string Symbol, decimal Price, DateTime Timestamp);
The CancellationToken is cancelled when the client stops the stream or disconnects. Always respect it to avoid orphaned background work.
Using ChannelReader
For more control over buffering and backpressure, use ChannelReader<T>:
public ChannelReader<LogEntry> StreamLogs(
string serviceName,
CancellationToken cancellationToken)
{
var channel = Channel.CreateUnbounded<LogEntry>();
_ = WriteLogsAsync(channel.Writer, serviceName, cancellationToken);
return channel.Reader;
}
private async Task WriteLogsAsync(
ChannelWriter<LogEntry> writer,
string serviceName,
CancellationToken cancellationToken)
{
try
{
await foreach (var entry in _logService.TailAsync(serviceName, cancellationToken))
{
await writer.WriteAsync(entry, cancellationToken);
}
}
catch (Exception ex)
{
writer.Complete(ex);
return;
}
writer.Complete();
}
The Channel.CreateBounded<T>(capacity) variant lets you control memory usage by limiting how many items can be buffered.
JavaScript Client
On the client, use stream() and subscribe to the results:
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/stocks")
.build();
await connection.start();
const subscription = connection.stream("StreamPrices", ["MSFT", "AAPL"])
.subscribe({
next: (price) => {
console.log(`${price.symbol}: ${price.price}`);
updateDashboard(price);
},
complete: () => console.log("Stream completed"),
error: (err) => console.error("Stream error:", err)
});
// To stop the stream:
// subscription.dispose();
.NET Client
var stream = connection.StreamAsync<StockPrice>(
"StreamPrices",
new[] { "MSFT", "AAPL" });
await foreach (var price in stream)
{
Console.WriteLine($"{price.Symbol}: {price.Price:C}");
}
Client-to-Server Streaming
The reverse direction is equally useful. A client can stream data to a hub method — perfect for file uploads, audio/video chunks, or batched telemetry.
Hub Method
The hub method accepts an IAsyncEnumerable<T> or ChannelReader<T> parameter:
public async Task UploadFile(
string fileName,
IAsyncEnumerable<byte[]> chunks)
{
var userId = Context.UserIdentifier!;
var path = Path.Combine(_uploadPath, userId, fileName);
await using var fileStream = File.Create(path);
await foreach (var chunk in chunks)
{
await fileStream.WriteAsync(chunk);
}
await Clients.Caller.SendAsync("UploadComplete", fileName);
}
JavaScript Client
Use send() with a Subject:
const subject = new signalR.Subject();
// Start the upload
connection.send("UploadFile", "report.pdf", subject);
// Stream chunks
const chunkSize = 64 * 1024; // 64 KB
const file = fileInput.files[0];
for (let offset = 0; offset < file.size; offset += chunkSize) {
const slice = file.slice(offset, offset + chunkSize);
const buffer = await slice.arrayBuffer();
subject.next(new Uint8Array(buffer));
}
// Signal completion
subject.complete();
.NET Client
var channel = Channel.CreateBounded<byte[]>(10);
await connection.SendAsync("UploadFile", "data.csv", channel.Reader);
// Write chunks in a separate task
await using var file = File.OpenRead("data.csv");
var buffer = new byte[64 * 1024];
int bytesRead;
while ((bytesRead = await file.ReadAsync(buffer)) > 0)
{
await channel.Writer.WriteAsync(buffer[..bytesRead].ToArray());
}
channel.Writer.Complete();
Bidirectional Streaming
You can combine both directions. A hub method can accept a stream and return a stream:
public async IAsyncEnumerable<TranslationResult> TranslateStream(
IAsyncEnumerable<string> inputStream,
string targetLanguage,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var text in inputStream.WithCancellation(cancellationToken))
{
var translated = await _translationService.TranslateAsync(
text, targetLanguage, cancellationToken);
yield return new TranslationResult(text, translated);
}
}
Key Takeaways
- Use
IAsyncEnumerable<T>for simple server-to-client streams. - Use
ChannelReader<T>when you need control over buffering and backpressure. - Client-to-server streaming accepts
IAsyncEnumerable<T>parameters in hub methods. - Always respect the
CancellationTokento clean up when clients disconnect. - Combine both directions for request-response streaming patterns.