Server-Sent Events (SSE) are a small HTTP-streaming primitive: one connection, one direction, plain-text frames. The kit ships a complete SSE transport — a token-exchange endpoint, an authenticated stream endpoint, and a connection manager modules push events through — for the cases where SignalR is overkill: live feeds, usage gauges, status streams.
Opt in
builder.AddHeroPlatform(o => o.EnableSse = true); // SseConnectionManager + token serviceapp.UseHeroPlatform(p => p.MapSseEndpoints = true); // maps the two endpoints belowThe shipped host enables both.
The two endpoints
| Endpoint | Auth | What it does |
|---|---|---|
POST /api/v1/sse/token | JWT (standard Authorization header) | Issues a short-lived opaque stream token: { "token": "<guid>" } |
GET /api/v1/sse/stream?token=<guid> | The token from above | Opens the event stream |
Why the two-step dance: browsers’ EventSource (and plain fetch streaming) can’t attach an Authorization header the way XHR can, and putting a long-lived JWT in a URL leaks it into logs and proxies. So the client exchanges its JWT for a single-use token that expires in 30 seconds, then opens the stream with it. The token is stored in IDistributedCache (key sse:tok:{guid:N}), deleted on first consume, and carries only the user id + tenant id. An invalid or reused token gets a 401.
The token is checked only at the stream handshake — once the stream is open, its lifetime depends on the network/server, not the token. On reconnect the client simply requests a fresh token.
What the stream sends
On connect, the endpoint sets Content-Type: text/event-stream, Cache-Control: no-cache, and X-Accel-Buffering: no (disables nginx buffering), then immediately flushes a :connected comment frame. That eager flush matters: Kestrel buffers response headers until the first body write, and without it the client’s fetch() promise would sit pending until the first heartbeat — up to 15 seconds of “connecting…” on every reconnect.
After that the loop interleaves:
- Events — written in standard SSE framing (
id:when set,event:,data:per line, blank line), flushed per event. - Heartbeats — a
:heartbeatcomment every 15 seconds (fixed interval) so intermediaries (corporate proxies, load balancers) don’t kill idle-looking connections.
Two headers you might expect are deliberately absent: Connection: keep-alive is a hop-by-hop header that’s forbidden on HTTP/2+, so Kestrel would strip it and warn on every connect.
Pushing events from the backend
Resolve the singleton SseConnectionManager and write SseEvent records:
public sealed record SseEvent(string EventType, string Data, string? Id = null);
// targeted: every open tab/device of one userconnectionManager.TrySend(userId, new SseEvent("usage-updated", json));
// tenant-wideconnectionManager.Broadcast(tenantId, new SseEvent("maintenance-window", json));
// everyone (cross-tenant — use sparingly)connectionManager.BroadcastAll(new SseEvent("platform-notice", json));Each connection is backed by a bounded channel (100 events, DropOldest) — a slow client silently loses its oldest undelivered events instead of exerting backpressure on the producer. Per-instance only: the manager holds in-process connections, so in a multi-instance deployment an event raised on instance A doesn’t reach a client connected to instance B. For cross-instance fan-out, use SignalR (which has the Valkey backplane) or publish through your own pub/sub before calling the manager.
Client side
The kit’s dashboard (clients/dashboard/src/sse/) is the reference client. It uses fetch + a streaming reader rather than EventSource, so it can send the tenant header and drive its own reconnect/backoff loop:
const { token } = await apiFetch<{ token: string }>("/api/v1/sse/token", { method: "POST" });
const response = await fetch(`${apiBase}/api/v1/sse/stream?token=${encodeURIComponent(token)}`, { headers: { Accept: "text/event-stream", tenant }, signal: controller.signal,});
const reader = response.body.getReader();for await (const ev of parseSseStream(reader)) { // ... handle ev.event / ev.data}When the stream drops, the loop requests a fresh token and reconnects with exponential backoff. Native EventSource also works if you don’t need custom headers — point it at the stream URL with the token query parameter.
What SSE doesn’t do
- No bidirectional messaging. Server → client only. If the client needs to send anything back, use a separate HTTP request or switch to SignalR.
- No native delivery guarantees. Combined with the
DropOldestchannel, a slow or briefly-disconnected client can miss events. If you need catch-up, setSseEvent.Idand have the client send a “last seen” cursor on reconnect — that replay protocol is yours to build. - No cross-instance fan-out — see above.
When to reach for SignalR instead
- Chat-like patterns — bidirectional, presence, typing indicators, multi-channel.
- Need client-driven actions — start / stop / pause / resume from the client.
- Multi-instance fan-out — SignalR’s Valkey backplane handles it; the SSE manager doesn’t.
- You already have SignalR enabled — each extra mechanism is a maintenance surface. Pick one per feature.
Related
- Realtime (SignalR) — bidirectional mode.
- Chat module — SignalR usage example.
- Web building block —
EnableSsetoggle,SseConnectionManager,SseTokenService.