jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Real-Time Frontend: Long-Polling, SSE, WebSockets, WebRTC, and WebTransport Compared

A principal-level guide to choosing server push, full-duplex, P2P, and HTTP/3 transport on the frontend—with trade-offs, reconnection, and production scaling.

Trình duyệt được thiết kế cho mô hình request–response. Khi sản phẩm cần dashboard live, editor cộng tác, chat trong app, hay game state dưới 100ms, bạn đang chống lại mặc định đó. Năm cơ chế ở đây—long-polling, Server-Sent Events (SSE), WebSockets, WebRTC, và WebTransport—nằm ở các điểm khác nhau trên phổ directionality, reliability, latency và chi phí vận hành.

Bài viết dành cho engineer đã quen fetch và HTTP status codes; mục tiêu là giải thích tại sao mỗi protocol tồn tại, bạn phải tự build gì, và cách chọn dưới ràng buộc production.

The need for server push

HTTP/1.1 về bản chất do client khởi tạo: server không gửi data cho đến khi client mở connection và hỏi. UX real-time vì vậy cần push giả lập (client hỏi liên tục) hoặc kênh persistent nơi server ghi bất cứ khi nào có dữ liệu mới.

Short polling

Short polling là baseline đơn giản: client gọi endpoint mỗi N giây.

async function poll() {
  const res = await fetch('/api/notifications?since=' + lastId);
  const items = await res.json();
  if (items.length) applyUpdates(items);
  setTimeout(poll, 3000);
}
poll();

Nó đơn giản, thân thiện cache, và đi qua mọi corporate proxy. Chi phí rất nặng khi scale: hầu hết response rỗng, setup connection (TCP + TLS + HTTP) lặp liên tục, và latency bị giới hạn bởi interval—polling 3s nghĩa là data có thể stale tới 3 giây.

Quy tắc ngón tay cái: short polling chỉ chấp nhận được cho update tần suất thấp, ít quan trọng (vd. “check version mới” mỗi 60 giây).

Long polling

Long polling đảo ngược lãng phí: client mở request và server giữ mở đến khi có event hoặc timeout, rồi client mở request tiếp theo ngay.

async function longPoll() {
  try {
    const res = await fetch('/api/events/wait?timeout=30', {
      signal: AbortSignal.timeout(35000),
    });
    const event = await res.json();
    handleEvent(event);
  } catch (err) {
    // timeout or network error — backoff before retry
  }
  longPoll();
}
longPoll();

Latency cải thiện mạnh—event đến ngay khi server có, không chờ poll tick tiếp theo. Nhưng mỗi event vẫn trả giá một vòng request; dưới burst traffic bạn gặp connection churn và head-of-line blocking trên HTTP/1.1 (một hanging request mỗi tab mỗi resource).

ConcernShort pollingLong polling
LatencyUp to poll intervalNear real-time on event
Server loadHigh (constant empty hits)Medium (held connections)
Connection countBurstyOne per client, always open
HTTP/2 benefitMarginalMultiplexing helps, but still request-per-event
Client complexityTrivialRetry/backoff required

Long polling từng là công cụ chính của app Comet đầu thời (Gmail, Facebook chat khoảng 2010). Nó vẫn là fallback hợp lệ khi WebSocket bị chặn, nhưng bạn kế thừa toàn bộ HTTP overhead khi mở lại connection.

Server-Sent Events (SSE)

SSE là câu trả lời native của browser cho push một chiều server→client trên HTTP thông thường. Bạn subscribe bằng EventSource; server trả Content-Type: text/event-stream và stream UTF-8 text frames.

<script>
  const source = new EventSource('/api/stream', { withCredentials: true });

  source.addEventListener('price-update', (e) => {
    const data = JSON.parse(e.data);
    renderTicker(data);
  });

  source.onerror = () => {
    // browser auto-reconnects; check source.readyState
  };
</script>

Server side (Node/Express sketch):

app.get('/api/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  const id = subscribe(req.user.id, (payload) => {
    res.write(`event: price-update\n`);
    res.write(`id: ${payload.seq}\n`);
    res.write(`data: ${JSON.stringify(payload)}\n\n`);
  });

  req.on('close', () => unsubscribe(id));
});

Why SSE wins for many “live feed” products

Tự reconnect với Last-Event-ID. Khi connection rớt, browser reconnect và gửi id: header cuối cùng; server có thể replay event bị miss. Bạn không có sẵn điều này với WebSocket thuần—phải tự thiết kế.

Giữ nguyên HTTP semantics. Cookie, auth middleware chuẩn, CDN edge rules, và observability (access log, WAF) hoạt động như mọi GET.

HTTP/2 và HTTP/3 multiplexing. SSE stream chia sẻ một TCP/QUIC connection với REST API; không port thêm, không upgrade dance.

Đơn giản. Một chiều nghĩa là không framing client-send, không negotiate subprotocol, không heartbeat protocol tùy biến (dù gửi comment : keepalive\n\n mỗi ~15–30 giây tránh proxy timeout).

SSE limits you must accept

  • Chỉ text — binary payload cần Base64 hoặc kênh riêng.
  • Chỉ server→client — hành động client vẫn qua REST hoặc kênh thứ hai.
  • Giới hạn connection mỗi browser — lịch sử ~6 HTTP/1.1 concurrent mỗi origin; HTTP/2 giảm nhờ một connection, nhưng một số proxy buffer text/event-stream sai.
  • Không có backpressure signal tới client — nếu JS thread client stall, event xếp hàng trong browser; thiết kế handler idempotent.

Khi chọn SSE: notification live, ticker, log build/deploy, stream feature flag, presence chỉ đọc—mọi case client chủ yếu lắng nghe.

WebSockets

WebSocket cung cấp kênh full-duplex, persistent, có framing khởi tạo bằng HTTP Upgrade handshake.

GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

Server trả 101 Switching Protocols; sau đó hai bên gửi frame (text, binary, ping, pong, close) trên cùng TCP connection.

const ws = new WebSocket('wss://example.com/chat', ['json.v1']);

ws.onopen = () => {
  ws.send(JSON.stringify({ type: 'join', room: 'general' }));
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  dispatch(msg);
};

ws.onclose = (event) => {
  scheduleReconnect(event.code, event.reason);
};

Subprotocols and framing

Tham số thứ hai của WebSocket là danh sách subprotocol (vd. graphql-transport-ws, json.v1)—hợp đồng cho message shape, không phải mã hóa. WebSocket frame mang opcode + payload; ranh giới message được giữ (khác TCP byte stream).

Binary frame quan trọng cho protobuf, MessagePack, hay game state nén—SSE không làm native.

What the platform does not give you

WebSocket mạnh nhưng trần trụi.

Missing primitiveWhat you build
ReconnectionExponential backoff + jitter; resume token or snapshot sync
HeartbeatApp-level ping/pong or rely on WS protocol ping (server must implement)
BackpressureCheck bufferedAmount; pause send() or drop/coalesce
Auth refreshRe-authenticate on reconnect; short-lived tokens in first frame
Message orderingSequence numbers + idempotent handlers if multiple tabs/workers
MultiplexingOne socket per domain usually; multiplex logical channels in app layer

A minimal production reconnect wrapper:

function connectSocket(url: string, getToken: () => Promise<string>) {
  let ws: WebSocket | null = null;
  let attempt = 0;
  const queue: string[] = [];

  async function open() {
    const token = await getToken();
    ws = new WebSocket(`${url}?token=${token}`);

    ws.onopen = () => {
      attempt = 0;
      while (queue.length && ws!.readyState === WebSocket.OPEN) {
        ws!.send(queue.shift()!);
      }
    };

    ws.onclose = () => {
      const delay = Math.min(30_000, 1000 * 2 ** attempt) + Math.random() * 500;
      attempt++;
      setTimeout(open, delay);
    };
  }

  return {
    send(data: string) {
      if (ws?.readyState === WebSocket.OPEN) ws.send(data);
      else queue.push(data);
    },
    start: open,
  };
}

Backpressure in practice

WebSocket.bufferedAmount cho biết byte browser đã queue chưa lên wire. Nếu nó tăng liên tục khi bạn send(), bạn produce nhanh hơn network (hoặc peer) consume—gộp update (cursor position mới nhất thắng), drop frame không quan trọng, hoặc flow control ở tầng protocol.

function safeSend(ws, payload) {
  if (ws.bufferedAmount > 64 * 1024) {
    pendingPayload = payload; // coalesce to latest
    return;
  }
  ws.send(JSON.stringify(payload));
}

Khi chọn WebSocket: chat, input game multiplayer, sync CRDT cộng tác, terminal trading—mọi traffic client↔server hai chiều, latency thấp, tần suất cao.

WebRTC

WebRTC không thay WebSocket—nó là transport peer-to-peer cho media (audio/video qua RTCPeerConnection) và data tùy ý (RTCDataChannel). Signaling (SDP offer/answer, ICE candidate) vẫn qua server—thường WebSocket hoặc HTTP—nhưng media/data có thể đi trực tiếp giữa browser.

const pc = new RTCPeerConnection({
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});

const channel = pc.createDataChannel('game', { ordered: true });

channel.onopen = () => channel.send(JSON.stringify({ type: 'input', keys: [] }));
channel.onmessage = (e) => applyRemoteState(JSON.parse(e.data));

// signaling via your WebSocket
signal.on('candidate', (c) => pc.addIceCandidate(c));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signal.send({ type: 'offer', sdp: offer });

ICE, STUN, and TURN

ICE (Interactive Connectivity Establishment) gom candidate address: host (LAN local), server-reflexive (public IP qua STUN), relay (qua TURN). ~15–20% session fail nếu không có TURN trong môi trường NAT/firewall doanh nghiệp—cần budget TURN infrastructure.

Data channels vs media tracks

ChannelOrderedReliableTypical use
Ordered + reliable (default)YesYesFile transfer, game state sync
Unordered + maxRetransmits: 0NoNoVoice-like positional updates
Media (addTrack)N/AN/AVideo/audio with jitter buffers

Data channel unreliable unordered giống UDP—lý tưởng khi freshness quan trọng hơn completeness (vector aim FPS, cursor live 60 Hz).

Topology: mesh, SFU, MCU

  • Mesh: mỗi peer nối mọi peer khác—ổn ≤4 người; (O(n^2)) connection.
  • SFU: peer gửi một upstream lên server; server forward stream có chọn—chuẩn cho video nhóm (kiểu Zoom).
  • MCU: server mix stream—đắt, hiếm hiện nay.

Khi chọn WebRTC: gọi video/voice, transfer file P2P, multiplayer latency cực thấp khi relay server thêm RTT không chấp nhận được, trải nghiệm gần LAN. Đừng chọn cho dashboard push đơn giản—độ phức tạp vận hành cao hơn một bậc.

WebTransport (HTTP/3 / QUIC)

WebTransport là API hiện đại expose giao tiếp dựa QUIC: nhiều stream hai chiều, stream một chiều, và datagram unreliable—tất cả trên HTTP/3.

const transport = new WebTransport('https://example.com/wt');
await transport.ready;

const stream = await transport.createBidirectionalStream();
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();

await writer.write(new TextEncoder().encode('hello'));
const { value, done } = await reader.read();

// unreliable, unordered — like UDP within QUIC
transport.datagrams.writable.getWriter().write(new Uint8Array([1, 2, 3]));

Why QUIC changes the trade-off

TCP + TLS + HTTP/2 gặp head-of-line blocking ở tầng byte stream: một packet mất làm stall mọi stream multiplex trên connection đó. QUIC mã hóa từng packet và multiplex stream độc lập—mất trên một stream không block stream khác.

WebTransport datagram cho browser hành vi giống UDP với encryption và congestion control của QUIC, không cần mở raw UDP socket (browser cấm).

WebTransport vs WebSockets (2025–2026 landscape)

DimensionWebSocketWebTransport
Wire protocolTCP (often HTTP/1.1 upgrade)HTTP/3 / QUIC
MultiplexingSingle channel; app-layer demuxNative many streams + datagrams
Unreliable modeNo (app must drop stale msgs)First-class datagrams
Head-of-line blockingTCP-level affects all trafficPer-stream isolation
Browser supportUniversalChromium stable; Firefox/Safari catching up—verify caniuse before betting product
InfrastructureMature (nginx, AWS ALB, Cloudflare)Growing; needs HTTP/3-capable edge
FallbackOften pair with WebSocket for unsupported clients

Tính đến 2026, coi WebTransport là opt-in cho workload nhạy latency, multiplex nặng khi bạn kiểm soát edge (game backend, CDN tùy biến), không phải thay mặc định mọi WebSocket.

async function createRealtimeTransport() {
  if ('WebTransport' in window && await supportsHttp3()) {
    return new WebTransportTransport('/wt');
  }
  return new WebSocketTransport('/ws');
}

Server implementation có trên Node (@fails-components/webtransport, h3-webtransport), Cloudflare Workers, và Caddy—đều cần TLS 1.3 và HTTP/3 bật.

Protocol comparison

MechanismDirectionReliability / orderingTransportTypical latencyReconnectionBest fit
Short pollingClient pullPer HTTP requestHTTPInterval-boundManualRare checks
Long pollingSimulated pushPer HTTP requestHTTPLow on eventManualLegacy / restrictive proxies
SSEServer→clientOrdered, reliableHTTPLowBuilt-in + Last-Event-IDLive feeds, logs, notifications
WebSocketFull-duplexOrdered, reliable framesTCP (WS)Very lowDIYChat, sync, gaming vs server
WebRTC DataChannelPeer↔peerConfigurableUDP (SRTP/ SCTP)Lowest LAN/WAN P2PDIY + ICE restartAV, P2P data, mesh/SFU
WebTransportFull-duplex + datagramsStreams reliable; datagrams notQUIC/HTTP/3Very lowDIY (no standard yet)Multiplexed games, mixed reliability

Production concerns

Authentication and authorization

Cookie hoạt động trên SSE (withCredentials: true) và upgrade request WebSocket—nhưng nhiều API JWT-in-header hỏng vì WebSocket browser không set header tùy ý. Pattern phổ biến: token ngắn hạn trong query string (lộ qua log—rotate nhanh), session cookie, hoặc token Sec-WebSocket-Protocol (hacky). Re-auth mỗi lần reconnect; đừng giả định socket mở xuyên suốt token TTL.

Scaling and fan-out

Một process Node giữ socket map in-memory—ổn cho prototype.

  1. Sticky sessions (same client → same worker) for stateful connections, or
  2. Sticky session (cùng client → cùng worker) cho connection stateful, hoặc pub/sub backbone (Redis, NATS, Kafka) mỗi worker subscribe và push tới socket local.

Presence (“ai online”) phải eventually consistent—heartbeat với TTL key trong Redis tốt hơn quét danh sách socket.

Message ordering and idempotency

Network reorder; reconnect duplicate. Gán seq monotonic mỗi room/user; client bỏ qua seq <= lastApplied. Mutation mang idempotency key để frame retry không charge đôi hay duplicate message.

Offline and reconnect UX

User nhận ra khoảng trống.

  1. Hiện trạng thái disconnected (banner không chặn).
  2. Fetch snapshot qua REST (GET /chat/room/42?since=seq).
  3. Tiếp tục live stream (SSE Last-Event-ID hoặc WS với param since).
  4. Merge với CRDT/OT nếu cộng tác.

Exponential backoff với full jitter tránh thundering herd khi region online lại.

function backoffDelay(attempt) {
  const cap = 30_000;
  const base = Math.min(cap, 1000 * 2 ** attempt);
  return Math.random() * base;
}

Observability

Log lifecycle connection (open, close code, duration, bytes in/out). Close code 1006 (abnormal) thường là proxy timeout—sửa heartbeat. Correlate trace_id giữa REST và message socket để debug ticket “message không tới”.

Decision guide for principal engineers

Dùng flow này—không phải chân lý—để khung architecture review.

Need server → client only, text/JSON, standard HTTP stack?
  └─ YES → SSE (default for feeds)
  └─ NO ↓

Need bidirectional client ↔ server over the internet, broad browser support?
  └─ YES → WebSocket (+ DIY reconnect, heartbeat, backpressure)
  └─ NO ↓

Need peer ↔ peer media or lowest RTT data between users?
  └─ YES → WebRTC (+ TURN budget, signaling service, SFU for groups)
  └─ NO ↓

Need multiple reliability modes + stream multiplex on HTTP/3, you control edge?
  └─ YES → WebTransport with WebSocket fallback
  └─ NO ↓

Corporate proxies block everything except vanilla HTTP?
  └─ Long polling (last resort)

Anti-patterns worth rejecting in review

  • WebSocket cho dashboard analytics chỉ đọc — SSE giảm một nửa gánh ops.
  • WebRTC cho notification broadcast từ server — không lợi P2P; lãng phí signaling lớn.
  • Không strategy reconnect vì “dùng WebSocket rồi” — protocol không cứu bạn.
  • Bỏ qua bufferedAmount — memory tăng im lặng và tab crash dưới tải.
  • Giả định HTTP/2 sửa long polling — vẫn trả giá request semantics mỗi event.

Closing perspective

Kiến trúc real-time frontend ít về chọn API mới nhất mà về khớp directionality, reliability, và operational surface với failure mode sản phẩm. SSE vẫn là lựa chọn đúng bị dùng thiếu nhất cho server push. WebSocket vẫn là workhorse full-duplex mặc định—nếu bạn budget engineering cho mọi thứ TCP/HTTP từng cho miễn phí. WebRTC và WebTransport là accelerator chuyên dụng, không phải nâng cấp universal.

Ship kênh đơn giản nhất đáp latency và hướng yêu cầu; đo close code, tỷ lệ reconnect, và p99 delivery latency trước khi tối ưu protocol.