Server-Sent Events — Building a Real-time Dashboard with .NET 10, Vue 3 & Redis
Posted on: 4/21/2026 3:20:51 PM
Table of contents
- 1. What Are Server-Sent Events?
- 2. Architecture Overview
- 3. Implementing SSE Server with .NET 10 Minimal API
- 4. Vue 3 Client — Composable SSE Pattern
- 5. Scaling Out with Redis Pub/Sub
- 6. Production Checklist
- 7. Performance Benchmarks
- 8. Advanced Patterns
- 9. Real-World Use Cases
- 10. Conclusion
- References
1. What Are Server-Sent Events?
Server-Sent Events (SSE) is a web technology that allows servers to push data to clients over a unidirectional HTTP connection. Unlike WebSocket — a complex full-duplex protocol — SSE uses the standard HTTP/1.1 protocol with Content-Type: text/event-stream, making it significantly simpler to implement for most real-world use cases.
SSE is designed for the server push model — where the server is the data source and the client only listens. This is the exact pattern for real-time dashboards, notification feeds, live scores, AI streaming responses, and log tailing.
Why SSE is making a strong comeback
With the explosion of AI streaming (ChatGPT, Claude, and Gemini all use SSE for token streaming), the developer community is realizing that SSE solves 80% of real-time use cases without WebSocket complexity. OpenAI API, Anthropic API, and most LLM APIs chose SSE as their primary streaming protocol.
1.1. How Does the SSE Protocol Work?
SSE uses a streaming HTTP response with a special format:
GET /api/events HTTP/1.1
Accept: text/event-stream
Cache-Control: no-cache
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
id: 1
event: metric-update
data: {"cpu":45.2,"memory":72.1,"timestamp":"2026-04-21T10:00:00Z"}
id: 2
event: alert
data: {"level":"warning","message":"CPU spike detected"}
: heartbeat comment (keeps connection alive)
Each event consists of optional fields: id (identifier for resumption), event (event name), data (payload), and retry (reconnection time in ms). Lines starting with : are comments, commonly used as heartbeats.
1.2. SSE vs WebSocket vs Long Polling
| Criteria | SSE | WebSocket | Long Polling |
|---|---|---|---|
| Direction | Server → Client (unidirectional) | Bidirectional (full-duplex) | Server → Client (via request) |
| Protocol | HTTP/1.1 or HTTP/2 | ws:// / wss:// (upgrade) | HTTP |
| Auto-reconnect | Built-in browser handling | Manual implementation required | Manual implementation required |
| Event ID & Resume | Built-in with Last-Event-ID | Manual implementation required | Manual implementation required |
| Load Balancer | Standard HTTP, easy config | Needs sticky sessions/upgrade | Standard HTTP |
| Proxy/CDN | Most support it natively | Requires special configuration | Works normally |
| Binary data | Text only (UTF-8) | Text + Binary frames | Any encoding |
| Concurrent connections | ~6/domain (HTTP/1.1), unlimited (HTTP/2) | Unlimited | ~6/domain |
| Best for | Dashboards, notifications, AI streaming | Chat, games, collaborative editing | Legacy systems |
The 6-connection limit on HTTP/1.1
Browsers limit ~6 concurrent HTTP/1.1 connections to the same domain. If you open 6 SSE tabs, the 7th will be blocked. Solution: use HTTP/2 (multiplexing over 1 TCP connection) or consolidate multiple streams into a single endpoint.
2. Architecture Overview
In production, a real-time dashboard needs to process multiple data sources (CPU, memory, request count, error rate...) and distribute them to many concurrent clients. The architecture below uses Redis Pub/Sub as a message bus to scale out multiple .NET instances, combined with Vue 3 Composition API on the client side.
graph LR
subgraph Sources["Data Sources"]
S1["Metrics Collector"]
S2["Application Logs"]
S3["Health Checks"]
end
subgraph Backend[".NET 10 Backend"]
API1["API Instance 1"]
API2["API Instance 2"]
API3["API Instance N"]
end
subgraph Redis["Redis"]
PS["Pub/Sub Channel"]
end
subgraph Clients["Vue 3 Clients"]
C1["Dashboard 1"]
C2["Dashboard 2"]
C3["Dashboard N"]
end
S1 -->|Publish| PS
S2 -->|Publish| PS
S3 -->|Publish| PS
PS -->|Subscribe| API1
PS -->|Subscribe| API2
PS -->|Subscribe| API3
API1 -->|SSE| C1
API2 -->|SSE| C2
API3 -->|SSE| C3
style Sources fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style Backend fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
style Redis fill:#e94560,stroke:#fff,color:#fff
style Clients fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
Real-time Dashboard Architecture: Data Sources → Redis Pub/Sub → .NET SSE → Vue 3 Client
2.1. Detailed Data Flow
sequenceDiagram
participant Collector as Metrics Collector
participant Redis as Redis Pub/Sub
participant API as .NET 10 API
participant Browser as Vue 3 Client
Browser->>API: GET /api/sse/dashboard (Accept: text/event-stream)
API->>Redis: SUBSCRIBE dashboard:metrics
loop Every second
Collector->>Redis: PUBLISH dashboard:metrics {cpu, mem, rps}
Redis->>API: Message received
API->>Browser: event: metric-update\ndata: {cpu, mem, rps}
end
Note over Browser: Connection lost (network drop)
Browser->>API: GET /api/sse/dashboard (Last-Event-ID: 42)
API->>Redis: SUBSCRIBE dashboard:metrics
API->>Browser: Resume from event 43
Sequence diagram: SSE flow with auto-reconnect and Last-Event-ID
3. Implementing SSE Server with .NET 10 Minimal API
3.1. Project Structure
RealtimeDashboard/
├── RealtimeDashboard.Api/
│ ├── Program.cs
│ ├── Endpoints/
│ │ └── SseEndpoints.cs
│ ├── Services/
│ │ ├── ISseClientManager.cs
│ │ ├── SseClientManager.cs
│ │ └── RedisSubscriberService.cs
│ └── Models/
│ └── DashboardMetric.cs
├── dashboard-client/ (Vue 3 app)
│ ├── src/
│ │ ├── composables/
│ │ │ └── useSse.ts
│ │ └── components/
│ │ └── MetricDashboard.vue
3.2. Model and Service
// Models/DashboardMetric.cs
public sealed record DashboardMetric(
double CpuPercent,
double MemoryPercent,
long RequestsPerSecond,
int ActiveConnections,
double ErrorRate,
DateTimeOffset Timestamp);
// Services/ISseClientManager.cs
public interface ISseClientManager
{
Task AddClientAsync(string clientId, HttpResponse response,
CancellationToken ct);
Task BroadcastAsync(string eventType, string data,
CancellationToken ct = default);
int ConnectedClients { get; }
}
// Services/SseClientManager.cs
using System.Collections.Concurrent;
using System.Text;
public sealed class SseClientManager : ISseClientManager
{
private readonly ConcurrentDictionary<string, SseClient> _clients = new();
private long _eventId;
public int ConnectedClients => _clients.Count;
public async Task AddClientAsync(string clientId, HttpResponse response,
CancellationToken ct)
{
response.Headers.ContentType = "text/event-stream";
response.Headers.CacheControl = "no-cache";
response.Headers.Connection = "keep-alive";
response.Headers["X-Accel-Buffering"] = "no";
var client = new SseClient(clientId, response);
_clients.TryAdd(clientId, client);
try
{
// Keep connection alive with heartbeat
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(15));
while (await timer.WaitForNextTickAsync(ct))
{
await WriteSseComment(response, "heartbeat", ct);
}
}
catch (OperationCanceledException) { }
finally
{
_clients.TryRemove(clientId, out _);
}
}
public async Task BroadcastAsync(string eventType, string data,
CancellationToken ct = default)
{
var id = Interlocked.Increment(ref _eventId);
var message = FormatSseMessage(id, eventType, data);
var bytes = Encoding.UTF8.GetBytes(message);
var deadClients = new List<string>();
foreach (var (clientId, client) in _clients)
{
try
{
await client.Response.Body.WriteAsync(bytes, ct);
await client.Response.Body.FlushAsync(ct);
}
catch
{
deadClients.Add(clientId);
}
}
foreach (var id2 in deadClients)
_clients.TryRemove(id2, out _);
}
private static string FormatSseMessage(long id, string eventType,
string data)
{
var sb = new StringBuilder();
sb.Append("id: ").AppendLine(id.ToString());
sb.Append("event: ").AppendLine(eventType);
foreach (var line in data.Split('\n'))
sb.Append("data: ").AppendLine(line);
sb.AppendLine();
return sb.ToString();
}
private static async Task WriteSseComment(HttpResponse response,
string comment, CancellationToken ct)
{
var bytes = Encoding.UTF8.GetBytes($": {comment}\n\n");
await response.Body.WriteAsync(bytes, ct);
await response.Body.FlushAsync(ct);
}
private sealed record SseClient(string Id, HttpResponse Response);
}
Key Points in SseClientManager
ConcurrentDictionary ensures thread-safety when multiple clients connect/disconnect concurrently. PeriodicTimer (.NET 6+) handles heartbeat — avoid using Task.Delay in loops as it creates a new Timer each time. The X-Accel-Buffering: no header disables Nginx reverse proxy buffering.
3.3. Redis Pub/Sub Integration
// Services/RedisSubscriberService.cs
using StackExchange.Redis;
public sealed class RedisSubscriberService : BackgroundService
{
private readonly IConnectionMultiplexer _redis;
private readonly ISseClientManager _sseManager;
private readonly ILogger<RedisSubscriberService> _logger;
public RedisSubscriberService(
IConnectionMultiplexer redis,
ISseClientManager sseManager,
ILogger<RedisSubscriberService> logger)
{
_redis = redis;
_sseManager = sseManager;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var subscriber = _redis.GetSubscriber();
await subscriber.SubscribeAsync(
RedisChannel.Literal("dashboard:metrics"),
async (channel, message) =>
{
if (message.HasValue)
{
await _sseManager.BroadcastAsync(
"metric-update", message!, ct);
}
});
await subscriber.SubscribeAsync(
RedisChannel.Literal("dashboard:alerts"),
async (channel, message) =>
{
if (message.HasValue)
{
await _sseManager.BroadcastAsync(
"alert", message!, ct);
}
});
_logger.LogInformation(
"Redis subscriber started on channels: " +
"dashboard:metrics, dashboard:alerts");
await Task.Delay(Timeout.Infinite, ct);
}
}
3.4. SSE Endpoint
// Endpoints/SseEndpoints.cs
public static class SseEndpoints
{
public static void MapSseEndpoints(this WebApplication app)
{
app.MapGet("/api/sse/dashboard", async (
HttpContext context,
ISseClientManager manager,
CancellationToken ct) =>
{
var clientId = Guid.NewGuid().ToString("N");
context.Response.Headers.Append(
"Access-Control-Allow-Origin", "*");
await manager.AddClientAsync(
clientId, context.Response, ct);
});
app.MapGet("/api/sse/stats", (ISseClientManager manager) =>
Results.Ok(new { ConnectedClients = manager.ConnectedClients }));
}
}
3.5. Program.cs — Wiring Everything Together
// Program.cs
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IConnectionMultiplexer>(
ConnectionMultiplexer.Connect(
builder.Configuration.GetConnectionString("Redis")!));
builder.Services.AddSingleton<ISseClientManager, SseClientManager>();
builder.Services.AddHostedService<RedisSubscriberService>();
builder.Services.AddCors(options =>
options.AddDefaultPolicy(policy =>
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod()));
var app = builder.Build();
app.UseCors();
app.MapSseEndpoints();
app.Run();
3.6. Metrics Publisher (simulated data)
// Services/MetricsPublisherService.cs
public sealed class MetricsPublisherService : BackgroundService
{
private readonly IConnectionMultiplexer _redis;
public MetricsPublisherService(IConnectionMultiplexer redis)
=> _redis = redis;
protected override async Task ExecuteAsync(CancellationToken ct)
{
var publisher = _redis.GetSubscriber();
var random = new Random();
while (!ct.IsCancellationRequested)
{
var metric = new
{
cpu = Math.Round(30 + random.NextDouble() * 50, 1),
memory = Math.Round(55 + random.NextDouble() * 30, 1),
rps = random.Next(800, 2500),
activeConnections = random.Next(50, 300),
errorRate = Math.Round(random.NextDouble() * 2, 2),
timestamp = DateTimeOffset.UtcNow
};
await publisher.PublishAsync(
RedisChannel.Literal("dashboard:metrics"),
System.Text.Json.JsonSerializer.Serialize(metric));
await Task.Delay(1000, ct);
}
}
}
4. Vue 3 Client — Composable SSE Pattern
4.1. The useSse Composable
Instead of handling EventSource directly in components, we create a reusable composable — the standard pattern in Vue 3 Composition API:
// composables/useSse.ts
import { ref, onUnmounted, type Ref } from 'vue'
interface UseSseOptions {
url: string
events?: string[]
withCredentials?: boolean
autoReconnect?: boolean
}
interface UseSseReturn<T> {
data: Ref<T | null>
isConnected: Ref<boolean>
error: Ref<string | null>
eventCount: Ref<number>
close: () => void
reconnect: () => void
}
export function useSse<T = unknown>(
options: UseSseOptions
): UseSseReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const isConnected = ref(false)
const error = ref<string | null>(null)
const eventCount = ref(0)
let eventSource: EventSource | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
function connect() {
if (eventSource) eventSource.close()
eventSource = new EventSource(options.url, {
withCredentials: options.withCredentials ?? false
})
eventSource.onopen = () => {
isConnected.value = true
error.value = null
}
const events = options.events ?? ['message']
for (const eventName of events) {
eventSource.addEventListener(eventName, (e: MessageEvent) => {
try {
data.value = JSON.parse(e.data)
eventCount.value++
} catch {
data.value = e.data as unknown as T
}
})
}
eventSource.onerror = () => {
isConnected.value = false
error.value = 'Connection lost'
if (options.autoReconnect !== false) {
reconnectTimer = setTimeout(connect, 3000)
}
}
}
function close() {
if (reconnectTimer) clearTimeout(reconnectTimer)
eventSource?.close()
eventSource = null
isConnected.value = false
}
function reconnect() {
close()
connect()
}
connect()
onUnmounted(close)
return { data, isConnected, error, eventCount, close, reconnect }
}
Why Composable instead of Pinia Store?
SSE connections are tied to component lifecycle (connect on mount, disconnect on unmount). Pinia stores are suited for global state, but SSE streams are a per-view concern. If multiple views need to listen, each creates its own composable — each managing its own EventSource. If you truly need a shared connection for the entire app, you can wrap the composable inside a Pinia store.
4.2. Dashboard Component
<!-- components/MetricDashboard.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useSse } from '@/composables/useSse'
interface Metric {
cpu: number
memory: number
rps: number
activeConnections: number
errorRate: number
timestamp: string
}
const { data: metric, isConnected, eventCount } = useSse<Metric>({
url: '/api/sse/dashboard',
events: ['metric-update'],
autoReconnect: true
})
const cpuColor = computed(() => {
if (!metric.value) return '#4CAF50'
if (metric.value.cpu > 80) return '#f44336'
if (metric.value.cpu > 60) return '#ff9800'
return '#4CAF50'
})
const memoryColor = computed(() => {
if (!metric.value) return '#4CAF50'
if (metric.value.memory > 85) return '#f44336'
if (metric.value.memory > 70) return '#ff9800'
return '#4CAF50'
})
</script>
<template>
<div class="dashboard">
<header class="dashboard-header">
<h1>Real-time System Dashboard</h1>
<span :class="['status', { connected: isConnected }]">
{{ isConnected ? 'Connected' : 'Disconnected' }}
</span>
<span class="event-count">Events: {{ eventCount }}</span>
</header>
<div v-if="metric" class="metrics-grid">
<div class="metric-card">
<div class="metric-value" :style="{ color: cpuColor }">
{{ metric.cpu }}%
</div>
<div class="metric-label">CPU Usage</div>
<div class="metric-bar">
<div :style="{ width: metric.cpu + '%',
background: cpuColor }" />
</div>
</div>
<div class="metric-card">
<div class="metric-value" :style="{ color: memoryColor }">
{{ metric.memory }}%
</div>
<div class="metric-label">Memory Usage</div>
<div class="metric-bar">
<div :style="{ width: metric.memory + '%',
background: memoryColor }" />
</div>
</div>
<div class="metric-card">
<div class="metric-value">{{ metric.rps }}</div>
<div class="metric-label">Requests/sec</div>
</div>
<div class="metric-card">
<div class="metric-value">{{ metric.activeConnections }}</div>
<div class="metric-label">Active Connections</div>
</div>
<div class="metric-card">
<div class="metric-value" :style="{
color: metric.errorRate > 1 ? '#f44336' : '#4CAF50' }">
{{ metric.errorRate }}%
</div>
<div class="metric-label">Error Rate</div>
</div>
</div>
<div v-else class="loading">
Waiting for first event...
</div>
</div>
</template>
5. Scaling Out with Redis Pub/Sub
5.1. Why Redis?
When running a single .NET instance, the in-memory SseClientManager is sufficient. But in production, you typically run multiple instances behind a load balancer. Client A connects to Instance 1, Client B to Instance 2 — if metrics only publish to Instance 1, Client B receives nothing.
graph TD
subgraph Problem["Without Redis"]
P_LB["Load Balancer"]
P_I1["Instance 1
Client A, C OK"]
P_I2["Instance 2
Client B MISSED"]
P_Pub["Metrics Publisher"]
P_Pub -->|"Only sends to"| P_I1
P_LB --> P_I1
P_LB --> P_I2
end
subgraph Solution["With Redis Pub/Sub"]
S_LB["Load Balancer"]
S_I1["Instance 1
Client A, C OK"]
S_I2["Instance 2
Client B OK"]
S_Redis["Redis"]
S_Pub["Metrics Publisher"]
S_Pub -->|PUBLISH| S_Redis
S_Redis -->|SUBSCRIBE| S_I1
S_Redis -->|SUBSCRIBE| S_I2
S_LB --> S_I1
S_LB --> S_I2
end
style Problem fill:#fff5f5,stroke:#f44336,color:#2c3e50
style Solution fill:#f5fff5,stroke:#4CAF50,color:#2c3e50
style S_Redis fill:#e94560,stroke:#fff,color:#fff
Architecture comparison with and without Redis Pub/Sub for multi-instance deployment
5.2. Redis Pub/Sub vs Redis Streams
| Criteria | Pub/Sub | Streams |
|---|---|---|
| Delivery | Fire-and-forget | Persistent with ACK |
| History | Not stored | Stored and queryable |
| Consumer Group | No | Yes |
| Latency | ~0.1ms | ~0.2ms |
| Memory | None (no buffer) | Grows with retention |
| Use case | Real-time broadcast | Event log, task queue |
For real-time dashboards, Pub/Sub is the better choice because metrics data is ephemeral — old values become irrelevant once newer values arrive. If you need event replay (e.g., displaying a chart of the last 30 minutes when a new client connects), combine Redis Streams or store time-series data in ClickHouse.
5.3. Upgrade: Buffered SSE with Redis Streams
// When a client connects, send the last 30 seconds of data first
public async Task SendRecentMetrics(HttpResponse response,
string lastEventId, CancellationToken ct)
{
var db = _redis.GetDatabase();
// Get the 30 most recent entries from Redis Stream
var entries = await db.StreamRangeAsync(
"dashboard:metrics:stream",
minId: lastEventId ?? "-",
maxId: "+",
count: 30,
messageOrder: Order.Ascending);
foreach (var entry in entries)
{
var data = entry.Values
.First(v => v.Name == "payload").Value;
var message = FormatSseMessage(
long.Parse(entry.Id.ToString().Split('-')[0]),
"metric-update", data!);
await response.Body.WriteAsync(
Encoding.UTF8.GetBytes(message), ct);
}
await response.Body.FlushAsync(ct);
}
6. Production Checklist
6.1. Nginx Configuration
location /api/sse/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
# Disable buffering — CRITICAL for SSE
proxy_buffering off;
proxy_cache off;
# Disable gzip for event-stream (already small text)
gzip off;
# Long timeout for long-lived connections
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
# Chunked transfer
chunked_transfer_encoding on;
}
Common Mistake #1: Forgetting to disable proxy buffering
Nginx enables proxy_buffering on by default, meaning it buffers the upstream response before sending to the client. With SSE, this causes clients to receive no events until the buffer fills up (usually 4KB-8KB). Result: the dashboard appears "frozen" then suddenly receives a burst of events all at once.
6.2. Cloudflare Configuration
If using Cloudflare proxy (orange cloud enabled), keep these in mind:
Page Rules or Configuration Rules:
- URL: example.com/api/sse/*
- Cache Level: Bypass
- Rocket Loader: Off
- Auto Minify: Off
Or use Response Headers in a Worker:
headers.set('Cache-Control', 'no-cache, no-transform')
Cloudflare has a default 100-second idle response timeout (no data). The 15-second heartbeat in our code above solves this. Cloudflare Enterprise plans can extend this to 6000 seconds.
6.3. Connection Monitoring
// Endpoint to monitor SSE connection count
app.MapGet("/api/sse/stats", (ISseClientManager manager) =>
Results.Ok(new
{
ConnectedClients = manager.ConnectedClients,
ServerTime = DateTimeOffset.UtcNow,
Uptime = Environment.TickCount64 / 1000
}));
// Health check for SSE
app.MapHealthChecks("/health/sse", new()
{
Predicate = check => check.Tags.Contains("sse")
});
7. Performance Benchmarks
7.1. SSE vs WebSocket vs SignalR — Throughput
| Metric | SSE (.NET 10) | WebSocket (.NET 10) | SignalR (.NET 10) |
|---|---|---|---|
| Concurrent connections (1 instance) | ~50,000 | ~50,000 | ~30,000 |
| Memory per connection | ~2 KB | ~4 KB | ~8 KB |
| Messages/sec broadcast | ~200,000 | ~250,000 | ~150,000 |
| Latency (P99) | ~1.2ms | ~0.8ms | ~2.5ms |
| Setup complexity | Low | Medium | Medium-High |
| Framework dependency | None (raw HTTP) | None | Microsoft.AspNetCore.SignalR |
When to choose SSE over SignalR?
Choose SSE when: Clients only need to receive data from the server (dashboards, notifications, live feeds, AI streaming). No extra libraries needed on either server or client.
Choose SignalR when: You need bidirectional communication (chat, collaborative editing), automatic fallback (WebSocket → SSE → Long Polling), or strongly-typed hub methods.
7.2. HTTP/2 Multiplexing — Game Changer
On HTTP/1.1, each SSE stream occupies 1 TCP connection (limited to ~6/domain). On HTTP/2, all SSE streams share 1 TCP connection via multiplexing:
graph LR
subgraph HTTP1["HTTP/1.1"]
C1_1["Stream 1"] --- T1["TCP conn 1"]
C1_2["Stream 2"] --- T2["TCP conn 2"]
C1_3["Stream 3"] --- T3["TCP conn 3"]
C1_4["Stream 4"] --- T4["TCP conn 4"]
C1_5["Stream 5"] --- T5["TCP conn 5"]
C1_6["Stream 6"] --- T6["TCP conn 6"]
C1_7["Stream 7 BLOCKED"] -.- T7["Blocked!"]
end
subgraph HTTP2["HTTP/2"]
C2_1["Stream 1"]
C2_2["Stream 2"]
C2_3["Stream 3"]
C2_N["Stream N"]
C2_1 --- MUX["Multiplexer"]
C2_2 --- MUX
C2_3 --- MUX
C2_N --- MUX
MUX --- T8["1 TCP conn"]
end
style HTTP1 fill:#fff5f5,stroke:#f44336,color:#2c3e50
style HTTP2 fill:#f5fff5,stroke:#4CAF50,color:#2c3e50
style MUX fill:#e94560,stroke:#fff,color:#fff
HTTP/1.1 vs HTTP/2: From 6-connection limit to unlimited multiplexing
In .NET 10, Kestrel supports HTTP/2 by default with HTTPS. Just ensure the client (browser) connects via HTTPS to leverage HTTP/2.
8. Advanced Patterns
8.1. Event Filtering — Only Send What Clients Need
// Client sends filter via query string
app.MapGet("/api/sse/dashboard", async (
HttpContext context,
ISseClientManager manager,
[FromQuery] string? metrics,
CancellationToken ct) =>
{
var clientId = Guid.NewGuid().ToString("N");
var filter = metrics?.Split(',').ToHashSet()
?? new HashSet<string> { "cpu", "memory", "rps" };
await manager.AddClientAsync(clientId, context.Response,
filter, ct);
});
// Vue client subscribes to CPU and Memory only
const { data } = useSse<Metric>({
url: '/api/sse/dashboard?metrics=cpu,memory',
events: ['metric-update']
})
8.2. Backpressure — When Clients Are Slower Than the Server
// Use Channel with BoundedChannelOptions
private readonly Channel<string> _messageChannel =
Channel.CreateBounded<string>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropOldest
});
// Producer writes to channel
public async Task EnqueueMessage(string message)
{
await _messageChannel.Writer.WriteAsync(message);
}
// Consumer reads and sends to client
private async Task ProcessMessages(HttpResponse response,
CancellationToken ct)
{
await foreach (var message in
_messageChannel.Reader.ReadAllAsync(ct))
{
await response.Body.WriteAsync(
Encoding.UTF8.GetBytes(message), ct);
await response.Body.FlushAsync(ct);
}
}
DropOldest vs DropWrite
DropOldest: When the buffer is full, discard the oldest message — clients always receive the latest data. Ideal for metrics/dashboards.
DropWrite: When the buffer is full, discard the new incoming message — preserves ordering. Ideal for event logs requiring consistency.
8.3. Authentication with SSE
The EventSource API does not support custom headers, so you cannot send a JWT token in the Authorization header. There are 3 workarounds:
// Option 1: Token via query string (simple but URL logs will contain the token)
app.MapGet("/api/sse/dashboard", async (
HttpContext context,
[FromQuery] string token,
ISseClientManager manager,
CancellationToken ct) =>
{
var principal = ValidateToken(token);
if (principal is null)
{
context.Response.StatusCode = 401;
return;
}
await manager.AddClientAsync(
principal.Identity!.Name!, context.Response, ct);
});
// Option 2: Cookie-based auth (recommended)
// Browser sends cookies automatically — EventSource supports this
var sse = new EventSource('/api/sse/dashboard', {
withCredentials: true
});
// Option 3: Ticket endpoint — get a short-lived ticket,
// then use it to open the SSE connection
app.MapPost("/api/sse/ticket", [Authorize] (HttpContext ctx) =>
{
var ticket = GenerateShortLivedTicket(
ctx.User.Identity!.Name!, TimeSpan.FromSeconds(30));
return Results.Ok(new { ticket });
});
9. Real-World Use Cases
data: {"delta": {"content": "Hello"}}.10. Conclusion
Server-Sent Events is the "underrated yet extremely useful" technology for real-time web. With the explosion of AI streaming and the growing demand for real-time dashboards, SSE is experiencing a powerful renaissance.
In this article, we built a complete real-time dashboard system:
- .NET 10 Minimal API — SSE server with ConcurrentDictionary, PeriodicTimer heartbeat, and dead client cleanup
- Redis Pub/Sub — Message bus for multi-instance scale-out, compared with Redis Streams for buffered replay
- Vue 3 Composable — Reusable
useSse<T>, auto-reconnect, typed data - Production config — Nginx proxy buffering, Cloudflare timeout, HTTP/2 multiplexing
- Advanced patterns — Event filtering, backpressure with Channel, authentication strategies
The Real-time Technology Decision Rule
SSE: Server → Client unidirectional (80% of use cases). Dashboards, notifications, AI streaming, live feeds.
WebSocket: True bidirectional communication needed. Chat, games, collaborative editing.
SignalR: Need abstraction layer, auto-fallback, and strongly-typed hubs in the .NET ecosystem.
References
CQRS and Event Sourcing — When CRUD Is No Longer Enough
Container Security & Supply Chain 2026 — SBOM, Cosign, SLSA, Trivy for DevSecOps
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.