The kit’s realtime surface is a single SignalR hub — AppHub in BuildingBlocks/Web/Realtime/, mapped at /api/v1/realtime/hub — serving every realtime concern. On connect, every user joins their personal user:{userId} group, their tenant:{tenantId} 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 registers SignalR; if CachingOptions:Redis is set, it also calls AddStackExchangeRedis(...) (channel prefix fsh-signalr) 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. MapRealtime maps the hub at /api/v1/realtime/hub plus a presence snapshot endpoint at GET /api/v1/realtime/presence?userIds=a,b,c that clients poll for initial online status.
Group conventions
When a user’s HubConnection lands at /api/v1/realtime/hub, the hub’s OnConnectedAsync joins:
user:{userId}— for events targeted at this user (notifications, DM events, ticket-assigned).tenant:{tenantId}— scopes tenant-wide broadcasts (likePresenceChanged) so one tenant’s connect/disconnect churn never fans out to other tenants.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.
Connect-time joins only cover channels that existed at connect time. A channel created — or a membership granted — after the socket is live is covered by the JoinChannel(channelId) hub method: clients call it when they open a conversation and on reconnect. It’s membership-checked and idempotent, so calling it freely is safe.
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){ var userId = GetUserId(); if (!await _membership.IsMemberAsync(channelId, userId, Context.ConnectionAborted).ConfigureAwait(false)) return;
var key = $"typing:{channelId}:{userId}"; if (!string.IsNullOrEmpty(await _cache.GetStringAsync(key, Context.ConnectionAborted).ConfigureAwait(false))) return; // throttled
await _cache.SetStringAsync(key, "1", new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3) }, Context.ConnectionAborted).ConfigureAwait(false);
await Clients.OthersInGroup($"channel:{channelId}") .SendAsync("ChatTypingStarted", new { channelId, userId }, Context.ConnectionAborted) .ConfigureAwait(false);}The IDistributedCache marker key expires after 3 seconds, so the broadcast fires at most once per (channel, user) per window. With Valkey, the throttle is cluster-wide — chatty clients can’t bypass it by reconnecting to a different node. Note OthersInGroup — the typist doesn’t need their own indicator.
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('/api/v1/realtime/hub', { accessTokenFactory: () => getAccessToken() }) .withAutomaticReconnect() .build();The factory returns the JWT; the SignalR client appends it as ?access_token=.... The kit’s ConfigureJwtBearerOptions has an OnMessageReceived handler that accepts ?access_token= only on a narrow path allow-list — /api/v1/realtime/hub and /notifications — so query-string tokens can’t leak into ordinary endpoints. The standard JWT pipeline validates the token from there.
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 (this is the hub’s actual helper):
private string? GetUserId(){ var user = Context.User; if (user?.Identity?.IsAuthenticated != true) return null; return user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub") ?? user.FindFirstValue("uid");}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 connection = new HubConnectionBuilder() .WithUrl($"http://localhost/api/v1/realtime/hub?access_token={Uri.EscapeDataString(accessToken)}", options => { options.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler(); // TestServer has no WebSocket transport — force long-polling. options.WebSocketFactory = (_, _) => throw new NotSupportedException(); options.Transports = HttpTransportType.LongPolling; options.Headers["tenant"] = TestConstants.RootTenantId; }) .Build();The Chat integration tests (src/Tests/Integration.Tests/Tests/Chat/ — RealtimeEventsTests, TypingIndicatorTests, PresenceTests) all carry this ConnectAsync helper. Use it as the template for any new realtime integration test.
Configuration
{ "CachingOptions": { "Redis": "localhost:6379" // enables the SignalR backplane }, "CorsOptions": { "AllowAll": false, "AllowedOrigins": ["https://app.example.com"] }}Two important config notes:
- CORS for credentialed requests.
AllowAnyOrigin()silently breaks SignalR while REST keeps working — the CORS spec forbids*with credentialed requests, and SignalR’s negotiate always runs credentialed. The kit’sAddHeroCorshandles this:AllowAll: trueusesSetIsOriginAllowed(_ => true) + AllowCredentials()(echoes the request origin instead of*);AllowAll: falseuses an explicit origin allow-list withAllowCredentials(). Use the 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.