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. 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ế.
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 | Có |
| 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ế
data: {"delta": {"content": "Hello"}}.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 Composable —
useSse<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
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.