Skip to content
fullstackhero

Concept

Server-Sent Events

Built-in SSE endpoints — token exchange + authenticated stream — with a connection manager for one-way pushes when SignalR is overkill.

views 0 Last updated

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 service
app.UseHeroPlatform(p => p.MapSseEndpoints = true); // maps the two endpoints below

The shipped host enables both.

The two endpoints

EndpointAuthWhat it does
POST /api/v1/sse/tokenJWT (standard Authorization header)Issues a short-lived opaque stream token: { "token": "<guid>" }
GET /api/v1/sse/stream?token=<guid>The token from aboveOpens 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 :heartbeat comment 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 user
connectionManager.TrySend(userId, new SseEvent("usage-updated", json));
// tenant-wide
connectionManager.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 DropOldest channel, a slow or briefly-disconnected client can miss events. If you need catch-up, set SseEvent.Id and 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.