cat blog/.md
Server-Sent Events vs WebSocket: khi nào dùng cái nào
Cứ hễ nói đến “realtime”, phản xạ đầu tiên của nhiều developer là WebSocket. Nhưng sau khi chạy production vài hệ thống có cả chat, notification và LLM streaming, tôi nhận ra phần lớn trường hợp không cần WebSocket. Và việc chọn sai giao thức khiến bạn phải trả giá bằng load balancer phức tạp hơn, reconnect logic rối, và debug mệt gấp đôi.
Bài này là những gì tôi rút ra sau khi chuyển một vài endpoint từ WebSocket sang Server-Sent Events (SSE) — và ngược lại.
Khác biệt cốt lõi
Cả SSE và WebSocket đều giải quyết cùng một vấn đề: server cần đẩy dữ liệu cho client mà không đợi client poll. Nhưng cách chúng làm thì khác hẳn.
WebSocket là một giao thức riêng (ws://, wss://), nâng cấp từ HTTP qua handshake Upgrade: websocket. Sau handshake, kết nối TCP giữ mở hai chiều, client và server gửi frame qua lại tự do.
SSE đơn giản hơn nhiều — nó chỉ là HTTP. Client mở một request GET bình thường, server trả Content-Type: text/event-stream và giữ response mở, ghi từng event ra body theo format text:
data: {"type": "token", "value": "Hello"}
data: {"type": "token", "value": " world"}
Client dùng EventSource API (built-in trình duyệt) để đọc. Server đóng stream, trình duyệt tự động reconnect. Đơn giản đến mức không thể đơn giản hơn.
Khi SSE thắng
1. LLM streaming
Đây là use case kinh điển cho SSE năm 2025–2026. OpenAI, Anthropic, Claude, tất cả đều dùng SSE cho streaming API. Lý do:
- Luồng dữ liệu một chiều: server gửi token, client chỉ nhận. Không cần bidirectional.
- Hoạt động qua HTTP/2 và HTTP/3, nghĩa là ké được HTTP caching, load balancer, CDN, auth middleware có sẵn.
- Client chỉ cần
fetch+ reader, hoặcEventSource. Không cần thêm library.
Ví dụ FastAPI:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio, json
app = FastAPI()
async def token_stream(prompt: str):
async for token in llm.stream(prompt):
payload = json.dumps({"token": token})
yield f"data: {payload}\n\n"
yield "data: [DONE]\n\n"
@app.get("/chat")
async def chat(q: str):
return StreamingResponse(
token_stream(q),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
Header X-Accel-Buffering: no quan trọng khi đứng sau Nginx — nếu không, Nginx sẽ buffer response và client thấy token đến theo từng cục thay vì streaming.
2. Notification feed, stock ticker, live dashboard
Tất cả những trường hợp server đẩy dữ liệu về client mà client không cần phản hồi gì ngược lại (trừ ACK ngầm qua TCP). SSE vừa đủ, và rẻ hơn WebSocket về mặt operational:
- Auth: SSE dùng cookie/header như HTTP bình thường. Middleware auth của FastAPI/Express hoạt động không cần thay đổi gì. WebSocket thì phải handle auth trong handshake hoặc message đầu.
- Load balancer: mọi load balancer trên đời đều hiểu HTTP. Nginx, ALB, Cloudflare — không cần config gì đặc biệt. WebSocket cần sticky session hoặc cấu hình
proxy_set_header Upgraderiêng. - Proxy, CDN, CORS: SSE trượt qua mọi tầng HTTP có sẵn.
3. Auto-reconnect miễn phí
EventSource tự reconnect khi mất kết nối. Thậm chí còn gửi kèm header Last-Event-ID để server biết đã gửi tới đâu — bạn chỉ cần đọc header này và resume:
@app.get("/events")
async def events(request: Request):
last_id = request.headers.get("last-event-id")
start_from = int(last_id) if last_id else 0
async def gen():
async for event in event_store.stream_from(start_from):
yield f"id: {event.id}\ndata: {event.json()}\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
Với WebSocket, reconnect + resume là code bạn phải tự viết, và rất dễ sai.
Khi WebSocket thắng
1. Chat thực sự hai chiều với độ trễ thấp
Nếu client gửi tin nhắn liên tục và cần phản hồi tức thì, WebSocket là lựa chọn đúng. Dùng SSE + POST riêng cho message gửi đi hoàn toàn được, nhưng có hai điểm yếu:
- Mỗi POST lại mở kết nối TCP mới (trừ khi HTTP/2 keep-alive giúp). Thêm vài ms RTT cho mỗi tin nhắn.
- Ordering giữa incoming và outgoing khó đảm bảo nếu có race condition.
Với chat đông người, typing indicator, presence — WebSocket gọn hơn.
2. Giao thức binary hoặc protocol tuỳ biến
SSE chỉ truyền text UTF-8. Nếu bạn cần gửi binary frames (ví dụ voice streaming, screen share, game state) — WebSocket (hoặc WebRTC/WebTransport) là bắt buộc.
3. Khi số lượng kết nối siêu lớn và cần tối ưu từng byte
WebSocket frame overhead nhỏ hơn SSE text format. Nếu bạn có 100k+ kết nối concurrent và mỗi giây bắn hàng trăm event nhỏ, WebSocket tiết kiệm bandwidth đáng kể.
Cạm bẫy SSE dễ dính
1. Giới hạn 6 kết nối đồng thời per domain trên HTTP/1.1
Đây là giới hạn hard-coded của trình duyệt. Nếu user mở 7 tab của cùng domain, tab thứ 7 sẽ treo chờ một kết nối SSE cũ đóng. Hai cách xử lý:
- Chạy HTTP/2 trở lên: multiplexing không còn giới hạn này. Trên production dùng Nginx/Caddy + TLS, bạn gần như miễn phí có HTTP/2.
- Shared BroadcastChannel: mở 1 SSE ở tab master và broadcast sang các tab khác qua
BroadcastChannelAPI. Phức tạp hơn nhưng xử lý được user mở nhiều tab.
2. Proxy buffering
Nginx mặc định buffer response. Bạn phải tắt cho route SSE:
location /events {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
proxy_http_version 1.1;
}
Cloudflare cũng có buffering mặc định — cần đặt response header Cache-Control: no-cache và X-Accel-Buffering: no, hoặc bật Cloudflare Streaming cho hostname đó.
3. Timeout ở mọi tầng
Load balancer, reverse proxy, và cả browser đều có idle timeout. SSE stream đi lâu mà không có byte nào truyền sẽ bị cắt. Giải pháp: gửi heartbeat mỗi 15–30 giây:
async def gen():
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=15.0)
yield f"data: {event}\n\n"
except asyncio.TimeoutError:
yield ": heartbeat\n\n" # SSE comment, client ignore
Dòng bắt đầu bằng : là comment trong SSE spec — client không emit event, nhưng đủ để giữ kết nối sống qua proxy.
4. Connection pool ở backend
Mỗi SSE connection giữ một request worker. FastAPI/Uvicorn với async thì OK, nhưng đừng quên:
- Đặt
limit_concurrencyđủ lớn trên uvicorn. - Nếu dùng DB connection trong generator, release connection trước khi yield loop dài — đừng giữ Postgres connection mở suốt một stream chat LLM 30 giây.
Bảng quyết định
| Tiêu chí | SSE | WebSocket |
|---|---|---|
| Server → client một chiều | Ưu tiên | Quá mức |
| Bidirectional, low-latency | Không phù hợp | Ưu tiên |
| Text payload (JSON, token) | Tốt | Tốt |
| Binary payload | Không | Bắt buộc |
| Auto-reconnect + resume | Built-in | Tự code |
| Auth, CORS, load balancer | HTTP như thường | Cần config riêng |
| Chạy sau Nginx/CDN | Cần tắt buffer | Cần upgrade header |
| Debug với curl | curl -N là đủ | Cần tool riêng |
Tổng kết
Quy tắc tôi dùng khi design realtime feature: mặc định dùng SSE, chỉ dùng WebSocket khi thật sự cần bidirectional hoặc binary. Lý do đơn giản — SSE tái dùng được toàn bộ HTTP infrastructure đã có: auth middleware, rate limiting, logging, tracing, CDN. WebSocket buộc bạn phải build lại các tầng đó ở một protocol khác.
Với LLM streaming và notification — hai use case realtime phổ biến nhất năm nay — SSE là lựa chọn đúng. Đừng vì quán tính mà chọn WebSocket cho mọi thứ.
Một mẹo cuối: khi debug SSE, curl -N https://api.example.com/events là bạn đọc được stream ngay ở terminal. Với WebSocket, bạn phải mở devtools hoặc cài wscat. Riêng cái đó đã tiết kiệm cho tôi vài giờ mỗi tháng.
cat comments.log