Server-Sent Events vs WebSocket: khi nào dùng cái nào — hoatq.dev

cat blog/.md

Server-Sent Events vs WebSocket: khi nào dùng cái nào

date: tags: backend, realtime, fastapi, http, architecture

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ặc EventSource. 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 Upgrade riê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 BroadcastChannel API. 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-cacheX-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íSSEWebSocket
Server → client một chiềuƯu tiênQuá mức
Bidirectional, low-latencyKhông phù hợpƯu tiên
Text payload (JSON, token)TốtTốt
Binary payloadKhôngBắt buộc
Auto-reconnect + resumeBuilt-inTự code
Auth, CORS, load balancerHTTP như thườngCần config riêng
Chạy sau Nginx/CDNCần tắt bufferCần upgrade header
Debug với curlcurl -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.

// reactions


cat comments.log


hoatq@dev : ~/blog $