From dotnet-skills
Building real-time features. SignalR hubs, SSE (.NET 10), JSON-RPC 2.0, gRPC streaming, scaling.
npx claudepluginhub wshaddix/dotnet-skillsThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
Real-time communication patterns for .NET applications. Compares SignalR (full-duplex over WebSockets with automatic fallback), Server-Sent Events (SSE, built-in to ASP.NET Core in .NET 10), JSON-RPC 2.0 (structured request-response over any transport), and gRPC streaming (high-performance binary streaming). Provides decision guidance for choosing the right protocol based on requirements.
Out of scope: HTTP client factory patterns and resilience pipelines -- see [skill:dotnet-http-client] and [skill:dotnet-resilience]. Native AOT architecture and trimming strategies -- see [skill:dotnet-native-aot] for AOT compilation, [skill:dotnet-aot-architecture] for AOT-first design patterns, and [skill:dotnet-trimming] for trim-safe development. Blazor-specific SignalR usage (component integration, Blazor Server circuit management, render mode interaction) -- see [skill:dotnet-blazor-patterns] for Blazor hosting models and circuit patterns.
Cross-references: [skill:dotnet-grpc] for gRPC streaming implementation details and all four streaming patterns. See [skill:dotnet-integration-testing] for testing real-time communication endpoints. See [skill:dotnet-blazor-patterns] for Blazor-specific SignalR circuit management and render mode interaction.
| Protocol | Direction | Transport | Format | Browser Support | Best For |
|---|---|---|---|---|---|
| SignalR | Full-duplex | WebSocket, SSE, Long Polling (auto-negotiation) | JSON or MessagePack | Yes (JS/TS client) | Interactive apps, chat, dashboards, collaborative editing |
| SSE (.NET 10) | Server-to-client only | HTTP/1.1+ | Text (typically JSON lines) | Yes (native EventSource API) | Notifications, live feeds, status updates |
| JSON-RPC 2.0 | Request-response | Any (HTTP, WebSocket, stdio) | JSON | Depends on transport | Tooling protocols (LSP), structured RPC over simple transports |
| gRPC streaming | All four patterns | HTTP/2 | Protobuf (binary) | Limited (gRPC-Web) | Service-to-service, high-throughput, low-latency streaming |
EventSource API.SignalR provides real-time web functionality with automatic connection management and transport negotiation.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.MaximumReceiveMessageSize = 64 * 1024; // 64 KB
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
});
var app = builder.Build();
app.MapHub<NotificationHub>("/hubs/notifications");
public sealed class NotificationHub(
ILogger<NotificationHub> logger) : Hub
{
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
if (userId is not null)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}");
}
await base.OnConnectedAsync();
}
// Client-to-server method
public async Task SendMessage(string channel, string message)
{
// Broadcast to all clients in the channel group
await Clients.Group(channel).SendAsync("ReceiveMessage",
Context.UserIdentifier, message);
}
// Server-to-client streaming
public async IAsyncEnumerable<StockPrice> StreamPrices(
string symbol,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
yield return await GetLatestPrice(symbol, cancellationToken);
await Task.Delay(1000, cancellationToken);
}
}
}
Use interfaces to get compile-time safety for client method calls:
public interface INotificationClient
{
Task ReceiveMessage(string user, string message);
Task OrderStatusChanged(int orderId, string status);
}
public sealed class NotificationHub(
ILogger<NotificationHub> logger) : Hub<INotificationClient>
{
public async Task SendMessage(string channel, string message)
{
// Compile-time checked -- no magic strings
await Clients.Group(channel).ReceiveMessage(
Context.UserIdentifier!, message);
}
}
Inject IHubContext to send messages from background services or controllers:
public sealed class OrderService(
IHubContext<NotificationHub, INotificationClient> hubContext)
{
public async Task UpdateOrderStatus(int orderId, string userId, string status)
{
// Send to specific user group
await hubContext.Clients.Group($"user:{userId}")
.OrderStatusChanged(orderId, status);
}
}
SignalR automatically negotiates the best transport:
Force a specific transport when needed:
// Server: disable specific transports
app.MapHub<NotificationHub>("/hubs/notifications", options =>
{
options.Transports = HttpTransportType.WebSockets |
HttpTransportType.ServerSentEvents;
// Disables Long Polling
});
Use MessagePack for smaller payloads and faster serialization:
// Server
builder.Services.AddSignalR()
.AddMessagePackProtocol();
// Client (JavaScript)
// new signalR.HubConnectionBuilder()
// .withUrl("/hubs/notifications")
// .withHubProtocol(new signalR.protocols.msgpack.MessagePackHubProtocol())
// .build();
Override OnConnectedAsync and OnDisconnectedAsync to manage connection state:
public sealed class NotificationHub(
ILogger<NotificationHub> logger,
IConnectionTracker tracker) : Hub<INotificationClient>
{
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
var connectionId = Context.ConnectionId;
logger.LogInformation("Client {ConnectionId} connected (user: {UserId})",
connectionId, userId);
// Track connection for presence features
if (userId is not null)
{
await tracker.AddConnectionAsync(userId, connectionId);
await Groups.AddToGroupAsync(connectionId, $"user:{userId}");
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.UserIdentifier;
var connectionId = Context.ConnectionId;
if (exception is not null)
{
logger.LogWarning(exception,
"Client {ConnectionId} disconnected with error", connectionId);
}
if (userId is not null)
{
await tracker.RemoveConnectionAsync(userId, connectionId);
}
await base.OnDisconnectedAsync(exception);
}
}
Groups provide a lightweight pub/sub mechanism. Connections can belong to multiple groups and group membership is managed per-connection:
public sealed class ChatHub : Hub<IChatClient>
{
// Join a room (called by clients)
public async Task JoinRoom(string roomName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
await Clients.Group(roomName).UserJoined(Context.UserIdentifier!, roomName);
}
// Leave a room
public async Task LeaveRoom(string roomName)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName);
await Clients.Group(roomName).UserLeft(Context.UserIdentifier!, roomName);
}
// Send to specific group
public async Task SendToRoom(string roomName, string message)
{
await Clients.Group(roomName).ReceiveMessage(
Context.UserIdentifier!, message);
}
// Send to all except caller
public async Task BroadcastExceptSelf(string message)
{
await Clients.Others.ReceiveMessage(
Context.UserIdentifier!, message);
}
}
Groups are not persisted -- they are cleared when a connection disconnects. Re-add connections to groups in OnConnectedAsync if needed (e.g., from a database or cache).
Clients can stream data to the hub using IAsyncEnumerable<T> or ChannelReader<T>:
public sealed class UploadHub : Hub
{
// Accept a stream of items from the client
public async Task UploadData(
IAsyncEnumerable<SensorReading> stream,
CancellationToken cancellationToken)
{
await foreach (var reading in stream.WithCancellation(cancellationToken))
{
await ProcessReading(reading);
}
}
}
SignalR uses the same authentication as the ASP.NET Core host. For WebSocket connections, the access token is sent via query string because WebSocket does not support custom headers:
// Server: configure JWT for SignalR
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://identity.example.com";
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
// Read token from query string for WebSocket requests
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) &&
path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<NotificationHub>("/hubs/notifications")
.RequireAuthorization();
Access Context.UserIdentifier in the hub to identify the authenticated user. By default this maps to the ClaimTypes.NameIdentifier claim. Customize with IUserIdProvider:
public sealed class EmailUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection)
{
return connection.User?.FindFirst(ClaimTypes.Email)?.Value;
}
}
// Register
builder.Services.AddSingleton<IUserIdProvider, EmailUserIdProvider>();
For multi-server deployments, use a backplane to synchronize messages across instances. Without a backplane, messages sent on one server are not visible to connections on other servers.
Redis backplane:
builder.Services.AddSignalR()
.AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")!,
options =>
{
options.Configuration.ChannelPrefix =
RedisChannel.Literal("MyApp:");
});
Azure SignalR Service (managed backplane):
builder.Services.AddSignalR()
.AddAzureSignalR(builder.Configuration["Azure:SignalR:ConnectionString"]);
Azure SignalR Service offloads connection management entirely -- the ASP.NET Core server handles hub logic while Azure manages WebSocket connections, scaling, and message routing.
.NET 10 adds built-in SSE support to ASP.NET Core, making server-to-client streaming straightforward without additional packages.
app.MapGet("/events/orders", async (
OrderEventService eventService,
CancellationToken cancellationToken) =>
{
// TypedResults.ServerSentEvents returns an SSE response
return TypedResults.ServerSentEvents(
eventService.GetOrderEventsAsync(cancellationToken));
});
public sealed class OrderEventService
{
public async IAsyncEnumerable<SseItem<OrderEvent>> GetOrderEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var evt = await WaitForNextEvent(cancellationToken);
yield return new SseItem<OrderEvent>(evt, "order-update");
}
}
}
const source = new EventSource('/events/orders');
source.addEventListener('order-update', (event) => {
const order = JSON.parse(event.data);
updateDashboard(order);
});
source.onerror = () => {
// EventSource automatically reconnects
console.log('SSE connection lost, reconnecting...');
};
EventSource API)Last-Event-IDJSON-RPC 2.0 is a stateless, transport-agnostic remote procedure call protocol encoded in JSON. It is the foundation of the Language Server Protocol (LSP) and is used in some .NET tooling scenarios.
// Request
{"jsonrpc": "2.0", "method": "textDocument/completion", "params": {...}, "id": 1}
// Response
{"jsonrpc": "2.0", "result": {...}, "id": 1}
// Notification (no response expected)
{"jsonrpc": "2.0", "method": "textDocument/didChange", "params": {...}}
// Error
{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": 1}
StreamJsonRpc is the primary .NET library for JSON-RPC 2.0:
<PackageReference Include="StreamJsonRpc" Version="2.*" />
// Server: expose methods via JSON-RPC over a stream
using StreamJsonRpc;
public sealed class CalculatorService
{
public int Add(int a, int b) => a + b;
public Task<double> DivideAsync(double a, double b) =>
b == 0 ? throw new ArgumentException("Division by zero")
: Task.FromResult(a / b);
}
// Wire up over a WebSocket -- UseWebSockets() is required for upgrade handling
app.UseWebSockets();
app.Map("/jsonrpc", async (HttpContext context) =>
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
return;
}
var ws = await context.WebSockets.AcceptWebSocketAsync();
using var rpc = new JsonRpc(new WebSocketMessageHandler(ws));
rpc.AddLocalRpcTarget(new CalculatorService());
rpc.StartListening();
await rpc.Completion;
});
// Client
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri("ws://localhost:5000/jsonrpc"),
CancellationToken.None);
using var rpc = new JsonRpc(new WebSocketMessageHandler(ws));
rpc.StartListening();
var result = await rpc.InvokeAsync<int>("Add", 2, 3);
// result == 5
See [skill:dotnet-grpc] for complete gRPC implementation details including all four streaming patterns (unary, server streaming, client streaming, bidirectional streaming), authentication, load balancing, and health checks.
| Requirement | Choose |
|---|---|
| Service-to-service, both .NET | gRPC streaming |
| Browser client needs bidirectional | SignalR |
| Browser client needs server push only | SSE |
| Maximum throughput, binary payloads | gRPC streaming |
| Automatic reconnection with browser clients | SSE (native) or SignalR (built-in) |
| Multiple client platforms (JS, mobile, .NET) | SignalR |
Hub<T> catches method name typos at compile time instead of runtimeSee [skill:dotnet-native-aot] for AOT compilation pipeline and [skill:dotnet-aot-architecture] for AOT-compatible real-time communication patterns.
AddMessagePackProtocol() on the server when the client uses MessagePack -- mismatched protocols cause silent connection failures.OnMessageReceived for JWT with SignalR -- WebSocket connections cannot send custom HTTP headers after the initial handshake. The access token must be read from the query string in JwtBearerEvents.OnMessageReceived.OnConnectedAsync.Adapted from Aaronontheweb/dotnet-skills (MIT license).