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>:

Example.cs
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>:

Example.cs
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:

script.js
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

Example.cs
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:

Example.cs
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:

script.js
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

Example.cs
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:

Example.cs
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