Server-Sent Events — Xây dựng Real-time Dashboard với .NET 10, Vue 3 & Redis

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

Table of contents

  1. 1. Server-Sent Events là gì?
    1. 💡 Tại sao SSE đang quay lại mạnh mẽ?
    2. 1.1. Giao thức SSE hoạt động như thế nào?
    3. 1.2. SSE vs WebSocket vs Long Polling
      1. ⚠️ Giới hạn 6 kết nối trên HTTP/1.1
  2. 2. Kiến trúc tổng quan
    1. 2.1. Luồng dữ liệu chi tiết
  3. 3. Implement SSE Server với .NET 10 Minimal API
    1. 3.1. Cấu trúc project
    2. 3.2. Model và Service
      1. 🔑 Điểm quan trọng trong SseClientManager
    3. 3.3. Redis Pub/Sub Integration
    4. 3.4. SSE Endpoint
    5. 3.5. Program.cs — Kết nối tất cả
    6. 3.6. Metrics Publisher (giả lập dữ liệu)
  4. 4. Vue 3 Client — Composable SSE Pattern
    1. 4.1. Composable useSse
      1. 💡 Tại sao dùng Composable thay vì Pinia Store?
    2. 4.2. Dashboard Component
  5. 5. Scale-Out với Redis Pub/Sub
    1. 5.1. Tại sao cần Redis?
    2. 5.2. Redis Pub/Sub vs Redis Streams
    3. 5.3. Nâng cấp: Buffered SSE với Redis Streams
  6. 6. Production Checklist
    1. 6.1. Nginx Configuration
      1. ⚠️ Sai lầm phổ biến #1: Quên tắt proxy buffering
    2. 6.2. Cloudflare Configuration
    3. 6.3. Connection Monitoring
  7. 7. Performance Benchmark
    1. 7.1. SSE vs WebSocket vs SignalR — Throughput
      1. 🔑 Khi nào chọn SSE thay vì SignalR?
    2. 7.2. HTTP/2 Multiplexing — Game Changer
  8. 8. Patterns nâng cao
    1. 8.1. Event Filtering — Chỉ gửi data client cần
    2. 8.2. Backpressure — Khi client chậm hơn server
      1. 💡 DropOldest vs DropWrite
    3. 8.3. Authentication với SSE
  9. 9. Use Cases thực tế
  10. 10. Kết luận
    1. 💡 Quy tắc chọn công nghệ real-time
  11. Tài liệu tham khảo

1. Server-Sent Events là gì?

Server-Sent Events (SSE) là một công nghệ web cho phép server đẩy dữ liệu đến client qua một kết nối HTTP đơn hướng (one-way). Khác với WebSocket — giao thức full-duplex phức tạp — SSE sử dụng chính giao thức HTTP/1.1 tiêu chuẩn với Content-Type: text/event-stream, giúp triển khai đơn giản hơn nhiều trong hầu hết các use case thực tế.

98% Browser hỗ trợ SSE (caniuse.com 2026)
<1ms Overhead so với raw TCP
0 byte Client-to-server frame overhead
~6 Kết nối SSE đồng thời / domain (HTTP/1.1)

SSE được thiết kế cho mô hình server push — nơi server là nguồn phát dữ liệu và client chỉ lắng nghe. Đây chính xác là pattern của dashboard real-time, notification feed, live score, AI streaming response, và log tailing.

💡 Tại sao SSE đang quay lại mạnh mẽ?

Với sự bùng nổ của AI streaming (ChatGPT, Claude, Gemini đều dùng SSE cho token streaming), cộng đồng developer đang nhận ra rằng SSE giải quyết 80% use case real-time mà không cần độ phức tạp của WebSocket. OpenAI API, Anthropic API, và hầu hết LLM API đều chọn SSE làm giao thức streaming chính.

1.1. Giao thức SSE hoạt động như thế nào?

SSE sử dụng một HTTP response dạng streaming với format đặc biệt:

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 (giữ kết nối sống)

Mỗi event bao gồm các field tuỳ chọn: id (định danh để resume), event (tên sự kiện), data (payload), và retry (thời gian reconnect tính bằng ms). Dòng bắt đầu bằng : là comment, thường dùng làm heartbeat.

1.2. SSE vs WebSocket vs Long Polling

Tiêu chí SSE WebSocket Long Polling
Hướng truyền Server → Client (đơn hướng) Hai chiều (full-duplex) Server → Client (qua request)
Giao thức HTTP/1.1 hoặc HTTP/2 ws:// / wss:// (upgrade) HTTP
Auto-reconnect ✅ Browser tự xử lý ❌ Phải tự implement ❌ Phải tự implement
Event ID & Resume ✅ Built-in với Last-Event-ID ❌ Phải tự implement ❌ Phải tự implement
Load Balancer ✅ HTTP chuẩn, dễ config ⚠️ Cần sticky session/upgrade ✅ HTTP chuẩn
Proxy/CDN ✅ Hầu hết hỗ trợ ⚠️ Cần config đặc biệt ✅ Hoạt động bình thường
Binary data ❌ Chỉ text (UTF-8) ✅ Text + Binary frames ✅ Tuỳ encoding
Kết nối đồng thời ~6/domain (HTTP/1.1), không giới hạn (HTTP/2) Không giới hạn ~6/domain
Phù hợp cho Dashboard, notifications, AI streaming Chat, game, collaborative editing Legacy systems

⚠️ Giới hạn 6 kết nối trên HTTP/1.1

Browser giới hạn ~6 kết nối HTTP/1.1 đồng thời đến cùng một domain. Nếu bạn mở 6 tab SSE, tab thứ 7 sẽ bị block. Giải pháp: sử dụng HTTP/2 (multiplexing trên 1 TCP connection) hoặc gom nhiều stream vào 1 endpoint duy nhất.

2. Kiến trúc tổng quan

Trong thực tế, một real-time dashboard cần xử lý nhiều nguồn dữ liệu (CPU, memory, request count, error rate...) và phân phối đến nhiều client đồng thời. Kiến trúc dưới đây sử dụng Redis Pub/Sub làm message bus để scale-out nhiều instance .NET, kết hợp Vue 3 Composition API ở phía client.

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

Kiến trúc Real-time Dashboard: Data Sources → Redis Pub/Sub → .NET SSE → Vue 3 Client

2.1. Luồng dữ liệu chi tiết

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 Mỗi giây
        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: Mất kết nối (network drop)
    Browser->>API: GET /api/sse/dashboard (Last-Event-ID: 42)
    API->>Redis: SUBSCRIBE dashboard:metrics
    API->>Browser: Resume từ event 43

Sequence diagram: Luồng SSE với auto-reconnect và Last-Event-ID

3. Implement SSE Server với .NET 10 Minimal API

3.1. Cấu trúc project

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 và 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
        {
            // Giữ kết nối sống bằng 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);
}

🔑 Điểm quan trọng trong SseClientManager

ConcurrentDictionary đảm bảo thread-safe khi nhiều client kết nối/ngắt đồng thời. PeriodicTimer (.NET 6+) dùng cho heartbeat — tránh dùng Task.Delay trong loop vì nó tạo Timer mới mỗi lần. Header X-Accel-Buffering: no tắt buffering của Nginx reverse proxy.

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");

        // Giữ service chạy cho đến khi app shutdown
        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 — Kết nối tất cả

// 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 (giả lập dữ liệu)

// 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. Composable useSse

Thay vì xử lý EventSource trực tiếp trong component, ta tạo một composable tái sử dụng — pattern chuẩn trong 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
    }

    // Lắng nghe từng event type
    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 }
}

💡 Tại sao dùng Composable thay vì Pinia Store?

SSE connection gắn với lifecycle của component (connect khi mount, disconnect khi unmount). Pinia store phù hợp cho global state, nhưng SSE stream là per-view concern. Nếu nhiều view cùng lắng nghe, mỗi view tạo composable riêng — mỗi cái quản lý EventSource của mình. Nếu thực sự cần share 1 connection cho toàn app, có thể wrap composable trong 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. Scale-Out với Redis Pub/Sub

5.1. Tại sao cần Redis?

Khi chạy một instance .NET duy nhất, SseClientManager trong memory là đủ. Nhưng trong production, bạn thường có nhiều instance phía sau load balancer. Client A kết nối đến Instance 1, Client B đến Instance 2 — nếu metrics chỉ publish đến Instance 1, Client B sẽ không nhận được gì.

graph TD
    subgraph Problem["❌ Không có Redis"]
        P_LB["Load Balancer"]
        P_I1["Instance 1
Client A, C ✅"] P_I2["Instance 2
Client B ❌"] P_Pub["Metrics Publisher"] P_Pub -->|"Chỉ gửi đến"| P_I1 P_LB --> P_I1 P_LB --> P_I2 end subgraph Solution["✅ Có Redis Pub/Sub"] S_LB["Load Balancer"] S_I1["Instance 1
Client A, C ✅"] S_I2["Instance 2
Client B ✅"] 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

So sánh kiến trúc có và không có Redis Pub/Sub cho multi-instance

5.2. Redis Pub/Sub vs Redis Streams

Tiêu chí Pub/Sub Streams
Delivery Fire-and-forget Persistent, có ACK
Lịch sử Không lưu Lưu trữ, query được
Consumer Group Không
Latency ~0.1ms ~0.2ms
Memory Không tốn (no buffer) Tốn theo retention
Use case Real-time broadcast Event log, task queue

Với dashboard real-time, Pub/Sub là lựa chọn tốt hơn vì: dữ liệu metrics có tính chất ephemeral — giá trị cũ không quan trọng khi đã có giá trị mới. Nếu cần replay event (ví dụ: hiển thị chart 30 phút gần nhất khi client mới kết nối), hãy kết hợp Redis Streams hoặc lưu time-series vào ClickHouse.

5.3. Nâng cấp: Buffered SSE với Redis Streams

// Khi client kết nối, gửi 30 giây dữ liệu gần nhất trước
public async Task SendRecentMetrics(HttpResponse response,
    string lastEventId, CancellationToken ct)
{
    var db = _redis.GetDatabase();

    // Lấy 30 entry gần nhất từ 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 "";

    # Tắt buffering — QUAN TRỌNG cho SSE
    proxy_buffering off;
    proxy_cache off;

    # Tắt gzip cho event-stream (đã là text nhỏ)
    gzip off;

    # Timeout dài cho long-lived connection
    proxy_read_timeout 86400s;
    proxy_send_timeout 86400s;

    # Chunked transfer
    chunked_transfer_encoding on;
}

⚠️ Sai lầm phổ biến #1: Quên tắt proxy buffering

Nginx mặc định bật proxy_buffering on, nghĩa là nó buffer response từ upstream trước khi gửi cho client. Với SSE, điều này khiến client không nhận được event cho đến khi buffer đầy (thường 4KB-8KB). Kết quả: dashboard "đơ" rồi bất ngờ nhận hàng loạt event cùng lúc.

6.2. Cloudflare Configuration

Nếu dùng Cloudflare proxy (bật đám mây cam), cần lưu ý:

Page Rules hoặc Configuration Rules:
- URL: example.com/api/sse/*
  - Cache Level: Bypass
  - Rocket Loader: Off
  - Auto Minify: Off

Hoặc dùng Response Header trong Worker:
  headers.set('Cache-Control', 'no-cache, no-transform')

Cloudflare có timeout mặc định 100 giây cho response idle (không có data). Heartbeat mỗi 15 giây trong code ở trên giải quyết vấn đề này. Nếu dùng Cloudflare Enterprise, có thể tăng timeout lên đến 6000 giây.

6.3. Connection Monitoring

// Endpoint theo dõi số lượng kết nối SSE
app.MapGet("/api/sse/stats", (ISseClientManager manager) =>
    Results.Ok(new
    {
        ConnectedClients = manager.ConnectedClients,
        ServerTime = DateTimeOffset.UtcNow,
        Uptime = Environment.TickCount64 / 1000
    }));

// Health check cho SSE
app.MapHealthChecks("/health/sse", new()
{
    Predicate = check => check.Tags.Contains("sse")
});

7. Performance Benchmark

7.1. SSE vs WebSocket vs SignalR — Throughput

Metric SSE (.NET 10) WebSocket (.NET 10) SignalR (.NET 10)
Kết nối đồng thời (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 Thấp Trung bình Trung bình-Cao
Framework dependency Không (raw HTTP) Không Microsoft.AspNetCore.SignalR

🔑 Khi nào chọn SSE thay vì SignalR?

Chọn SSE khi: Client chỉ cần nhận dữ liệu từ server (dashboard, notification, live feed, AI streaming). Không cần thêm library nào ở cả server và client.
Chọn SignalR khi: Cần giao tiếp hai chiều (chat, collaborative editing), cần fallback tự động (WebSocket → SSE → Long Polling), hoặc cần strongly-typed hub methods.

7.2. HTTP/2 Multiplexing — Game Changer

Trên HTTP/1.1, mỗi SSE stream chiếm 1 TCP connection (giới hạn ~6/domain). Trên HTTP/2, tất cả SSE streams chia sẻ 1 TCP connection qua 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 ❌"] -.- 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: Từ 6 connection limit đến unlimited multiplexing

Trong .NET 10, Kestrel hỗ trợ HTTP/2 mặc định với HTTPS. Chỉ cần đảm bảo client (browser) kết nối qua HTTPS để tận dụng HTTP/2.

8. Patterns nâng cao

8.1. Event Filtering — Chỉ gửi data client cần

// Client gửi filter qua 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 chỉ subscribe CPU và Memory
const { data } = useSse<Metric>({
  url: '/api/sse/dashboard?metrics=cpu,memory',
  events: ['metric-update']
})

8.2. Backpressure — Khi client chậm hơn server

// Dùng Channel với BoundedChannelOptions
private readonly Channel<string> _messageChannel =
    Channel.CreateBounded<string>(new BoundedChannelOptions(100)
    {
        FullMode = BoundedChannelFullMode.DropOldest
    });

// Producer ghi vào channel
public async Task EnqueueMessage(string message)
{
    await _messageChannel.Writer.WriteAsync(message);
}

// Consumer đọc và gửi cho 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: Khi buffer đầy, bỏ message cũ nhất — client luôn nhận data mới nhất. Phù hợp cho metrics/dashboard.
DropWrite: Khi buffer đầy, bỏ message mới đến — bảo toàn thứ tự. Phù hợp cho event log cần consistency.

8.3. Authentication với SSE

EventSource API không hỗ trợ custom headers, nên không thể gửi JWT token trong header Authorization. Có 3 cách giải quyết:

// Cách 1: Token qua query string (đơn giản nhưng log URL sẽ chứa token)
app.MapGet("/api/sse/dashboard", async (
    HttpContext context,
    [FromQuery] string token,
    ISseClientManager manager,
    CancellationToken ct) =>
{
    // Validate JWT token manually
    var principal = ValidateToken(token);
    if (principal is null)
    {
        context.Response.StatusCode = 401;
        return;
    }
    await manager.AddClientAsync(
        principal.Identity!.Name!, context.Response, ct);
});

// Cách 2: Cookie-based auth (được recommend)
// Browser tự gửi cookie theo domain — EventSource hỗ trợ
var sse = new EventSource('/api/sse/dashboard', {
    withCredentials: true
});

// Cách 3: Ticket endpoint — lấy short-lived ticket,
// dùng ticket đó để mở 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. Use Cases thực tế

Real-time Dashboard (bài viết này)
Hiển thị metrics hệ thống (CPU, memory, RPS, error rate) cập nhật mỗi giây. Kết hợp .NET 10 backend + Redis Pub/Sub + Vue 3 frontend.
AI Token Streaming
OpenAI, Anthropic, Google Gemini API đều dùng SSE để stream token response. Khi bạn thấy ChatGPT "gõ chữ" từng từ — đó chính là SSE event data: {"delta": {"content": "Hello"}}.
Notification Feed
Thay vì polling mỗi 30 giây, SSE đẩy notification ngay khi có — giảm latency từ trung bình 15s xuống dưới 100ms, đồng thời giảm 95% số HTTP request.
Live Sport Score / Stock Price
Cập nhật tỷ số trận đấu, giá cổ phiếu real-time. SSE phù hợp vì data chỉ đi một chiều (server → client) và cần broadcast cho hàng triệu người xem.
CI/CD Pipeline Status
GitHub Actions, GitLab CI dùng SSE để stream log build real-time. Client mở 1 SSE connection, nhận log line-by-line thay vì refresh trang.
Collaborative Cursor / Presence
Hiển thị ai đang online, cursor position của đồng nghiệp (Figma, Notion style). SSE broadcast presence updates, client gửi cursor position qua REST/WebSocket riêng.

10. Kết luận

Server-Sent Events là công nghệ "ít được nói đến nhưng cực kỳ hữu ích" cho real-time web. Với sự bùng nổ của AI streaming và nhu cầu dashboard real-time ngày càng tăng, SSE đang trải qua giai đoạn phục hưng mạnh mẽ.

Trong bài viết này, chúng ta đã xây dựng một hệ thống real-time dashboard hoàn chỉnh:

  • .NET 10 Minimal API — SSE server với ConcurrentDictionary, PeriodicTimer heartbeat, và dead client cleanup
  • Redis Pub/Sub — Message bus cho multi-instance scale-out, so sánh với Redis Streams cho buffered replay
  • Vue 3 ComposableuseSse<T> reusable, auto-reconnect, typed data
  • Production config — Nginx proxy buffering, Cloudflare timeout, HTTP/2 multiplexing
  • Advanced patterns — Event filtering, backpressure với Channel, authentication strategies

💡 Quy tắc chọn công nghệ real-time

SSE: Server → Client đơn hướng (80% use cases). Dashboard, notifications, AI streaming, live feed.
WebSocket: Cần giao tiếp hai chiều thực sự. Chat, game, collaborative editing.
SignalR: Cần abstraction layer, auto-fallback, và strongly-typed hub trong .NET ecosystem.

Tài liệu tham khảo