Server-Sent Events — Building a Real-time Dashboard with .NET 10, Vue 3 & Redis

Posted on: 4/21/2026 3:20:51 PM

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.

98% Browser SSE support (caniuse.com 2026)
<1ms Overhead vs raw TCP
0 bytes Client-to-server frame overhead
~6 Concurrent SSE connections / domain (HTTP/1.1)

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

Real-time Dashboard (this article)
Display system metrics (CPU, memory, RPS, error rate) updating every second. Combines .NET 10 backend + Redis Pub/Sub + Vue 3 frontend.
AI Token Streaming
OpenAI, Anthropic, and Google Gemini APIs all use SSE for streaming token responses. When you see ChatGPT "typing" word by word — that's the SSE event data: {"delta": {"content": "Hello"}}.
Notification Feed
Instead of polling every 30 seconds, SSE pushes notifications the instant they arrive — reducing latency from an average of 15s to under 100ms while cutting HTTP requests by 95%.
Live Sport Scores / Stock Prices
Real-time score updates, stock price streams. SSE is ideal because data flows in one direction (server → client) and needs to broadcast to millions of viewers.
CI/CD Pipeline Status
GitHub Actions, GitLab CI use SSE to stream build logs in real-time. Client opens 1 SSE connection and receives log lines one by one instead of refreshing the page.
Collaborative Cursor / Presence
Display who's online and teammates' cursor positions (Figma, Notion style). SSE broadcasts presence updates while the client sends cursor position via a separate REST/WebSocket channel.

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