The kit’s realtime surface is a single SignalR hub — AppHub in BuildingBlocks/Web/Realtime/ — serving every realtime concern. On connect, every user joins their personal user:{userId} group plus every channel:{channelId} they belong to. Handlers broadcast via IHubContext<AppHub> after SaveChanges commits; the Valkey (a Redis-compatible, BSD-licensed Redis fork) backplane fans the broadcast across instances.
Opt in
builder.AddHeroPlatform(o => o.EnableRealtime = true);app.UseHeroPlatform(p => p.MapRealtime = true);AddHeroRealtime wires the hub registration; if CachingOptions:Redis is set, it also calls AddStackExchangeRedis(...) so cross-instance message fan-out works. Without Valkey, every instance broadcasts only to its own connected clients — fine for dev, not for multi-instance production.
Group conventions
When a user’s HubConnection lands at /realtime/hub, the hub’s OnConnectedAsync joins:
user:{userId}— for events targeted at this user (notifications, DM events, ticket-assigned).channel:{channelId}for every channel the user belongs to (the kit’sIUserChannelLookupreturns the list).
The result: broadcasting to Clients.Group($"user:{userId}") reaches every device that user is signed in on; broadcasting to Clients.Group($"channel:{channelId}") reaches every member of the channel.
For domain-specific groups (e.g. “everyone watching this dashboard”), invent a stable group name (dashboard:{tenantId}:{dashboardId}) and call Groups.AddToGroupAsync from a hub method or post-connect logic.
Broadcasting from a handler
The kit’s pattern is “save changes, then broadcast”:
public sealed class SendMessageCommandHandler( IChatDbContext db, IHubContext<AppHub> hub, ICurrentUser current) : ICommandHandler<SendMessageCommand, MessageDto>{ public async ValueTask<MessageDto> Handle(SendMessageCommand cmd, CancellationToken ct) { var message = Message.Create(/* ... */); db.Messages.Add(message); await db.SaveChangesAsync(ct).ConfigureAwait(false); // first commit, then broadcast
var dto = MessageDto.From(message); await hub.Clients.Group($"channel:{cmd.ChannelId}") .SendAsync("ChatMessageCreated", dto, ct).ConfigureAwait(false); return dto; }}The order matters: if you broadcast before SaveChanges and the commit fails, you’ve leaked a phantom event to clients. Always commit first, broadcast second.
Typing indicator with throttle
Chat-style typing indicators flood the wire if you broadcast every keystroke. The kit’s pattern is a 3-second distributed-cache throttle per (channel, user):
// AppHub.cs (simplified)public async Task Typing(Guid channelId){ if (!await _membership.IsMemberAsync(channelId, UserId, ct).ConfigureAwait(false)) return;
var throttleKey = $"chat:typing:{channelId}:{UserId}"; if (!await _cache.TryClaimAsync(throttleKey, TimeSpan.FromSeconds(3), ct).ConfigureAwait(false)) return;
await Clients.Group($"channel:{channelId}").SendAsync( "ChatTypingStarted", new { channelId, userId = UserId }, ct).ConfigureAwait(false);}TryClaimAsync returns true exactly once per (channel, user) per 3-second window. With Valkey, the throttle is cluster-wide — chatty clients can’t bypass by reconnecting to a different node.
Authenticating SignalR connections
Browsers’ native WebSocket API doesn’t accept Authorization headers on the initial handshake. SignalR’s JS client routes around this by accepting an accessTokenFactory:
const conn = new HubConnectionBuilder() .withUrl('/realtime/hub', { accessTokenFactory: () => getAccessToken() }) .withAutomaticReconnect() .build();The factory returns the JWT; the SignalR client appends it as ?access_token=.... The kit’s ConfigureJwtBearerOptions has OnMessageReceived extended to accept ?access_token= for /realtime/hub and /notifications so the standard JWT pipeline validates it.
Reading the user from the hub
AppHub reads identity from Context.User, not from a constructor-injected ICurrentUser. The reason: ICurrentUser depends on IHttpContextAccessor, and the originating HttpContext from the SignalR negotiate request is not pinned to subsequent hub-method invocations. Read claims directly:
private Guid UserId{ get { var raw = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? Context.User?.FindFirst("sub")?.Value ?? Context.User?.FindFirst("uid")?.Value ?? throw new HubException("No user id in JWT claims."); return Guid.Parse(raw); }}This is a memory-noted gotcha — ICurrentUser in a hub method returns the wrong identity (or null) once the connection has been alive for any length of time. Always go through Context.User.
Integration testing SignalR
TestServer (which the kit uses for integration tests) has no WebSocket transport. Tests must force long-polling on the connection:
var hub = new HubConnectionBuilder() .WithUrl(client.BaseAddress + "realtime/hub", options => { options.Transports = HttpTransportType.LongPolling; options.WebSocketFactory = (_, _) => throw new NotSupportedException(); }) .Build();This is in Tests/Integration.Tests/Infrastructure/SignalRTestClient.cs. Use it as the template for any new realtime integration test.
Configuration
{ "CachingOptions": { "Redis": "localhost:6379" // SignalR backplane uses the same multiplexer }, "Cors": { "AllowedOrigins": ["https://app.example.com"] }}Two important config notes:
- CORS for credentialed requests. Dev’s “allow any origin” silently breaks SignalR — credentialed CORS requires an explicit origin whitelist. Use
SetIsOriginAllowed(_ => true) + AllowCredentials()in dev; explicit origin allow-list in prod. - Sticky sessions / connection affinity. Long-polling fallback needs sticky sessions on the load balancer; WebSockets don’t. Configure both layers to be safe.
Related
- Chat module — the canonical consumer of every realtime feature listed here.
- Notifications module —
NotificationCreatedpush. - Server-Sent Events — one-way streaming when SignalR is overkill.
- Caching — the Valkey backplane is shared with the cache.