Home / Backend / REST, WebSocket hay SSE? Chọn protocol cho app TypeScript
Backend

REST, WebSocket hay SSE? Chọn protocol cho app TypeScript

Chọn REST, SSE hay WebSocket cho app TypeScript: fetch đúng cách, API client type-safe, so sánh và bảo mật realtime.

Có tính năng realtime cần làm, bạn định nhảy thẳng vào WebSocket. Nhưng nếu server chỉ cần đẩy thông báo một chiều xuống client, thì SSE đơn giản hơn nhiều: tự kết nối lại, chạy trên HTTP thường, không cần hạ tầng riêng. Còn phần lớn tính năng khác thì REST vẫn là lựa chọn đúng. Chọn sai protocol là gánh nguyên khối phức tạp đó suốt đời dự án.

Bài này giúp bạn chọn đúng REST, SSE hay WebSocket cho app TypeScript — và viết phần client cho gọn, type-safe.

1. HTTP: nền tảng bạn không được bỏ qua

Cả REST, SSE lẫn WebSocket đều dựng trên HTTP. Nắm chắc HTTP thì ba protocol kia chỉ là các cách dùng khác nhau của cùng một nền.

1.1. Một vòng request/response gồm những gì

Client gửi một request (method + URL + header + body tùy chọn), server trả một response (status code + header + body). Bốn thành phần đáng nhớ:

  • Method: GET (đọc), POST (tạo), PUT/PATCH (sửa), DELETE (xóa).
  • Status code: 2xx thành công, 3xx redirect, 4xx lỗi phía client (sai URL, thiếu quyền), 5xx lỗi phía server.
  • Header: metadata như Content-Type, Authorization, Cache-Control.
  • Body: dữ liệu đi kèm, thường là JSON.
1
2
3
4
GET /api/users/1 HTTP/2
Host: api.example.com
Authorization: Bearer <token>
Accept: application/json
1
2
3
4
HTTP/2 200 OK
Content-Type: application/json

{ "id": 1, "name": "An" }

1.2. HTTP/2 và cái chết của mẹo “gộp request”

Thời HTTP/1.1, trình duyệt chỉ mở tối đa khoảng 6 kết nối song song cho mỗi domain. Vượt qua đó là request phải xếp hàng chờ. Nên cả một thế hệ mẹo tối ưu ra đời: bundle mọi file JS vào một, gộp icon thành sprite, chia tài nguyên ra nhiều subdomain (domain sharding) để né giới hạn 6 kết nối.

HTTP/2 xóa gần hết lý do tồn tại của chúng. Nó multiplex nhiều request trên một kết nối TCP duy nhất, chạy song song thật sự thay vì xếp hàng. Kết quả: một bundle khổng lồ giờ lại có hại (đổi một dòng, người dùng tải lại cả khối), và domain sharding phản tác dụng vì mỗi domain mới lại tốn một lần bắt tay TLS. Nếu vẫn đang tối ưu theo tư duy HTTP/1.1, bạn đang tối ưu cho một thế giới không còn nữa.

2. fetch cho đúng: những cạm bẫy ai cũng dính

fetch có sẵn trong mọi trình duyệt và trong Node hiện đại, nhưng cách nó xử lý lỗi khác với trực giác của nhiều người — và đó là nguồn của bug kinh điển.

2.1. fetch KHÔNG throw khi 4xx/5xx

Nhiều người tưởng gọi fetch một URL trả về 404 thì nó ném lỗi. Không. fetch chỉ reject khi mạng chết hoặc bị hủy; còn server trả về 404 hay 500 thì với fetch vẫn là một response “đến nơi” — bạn phải tự check response.ok.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function loadPost() {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts/99999");

  console.log("Chạy tới đây dù server trả 404");
  console.log("ok:", res.ok, "| status:", res.status);

  if (!res.ok) {
    console.log("Phải tự check res.ok rồi mới xử lý lỗi");
    return;
  }

  const data = await res.json();
  console.log(data);
}

loadPost();

Bấm Run: dòng “Chạy tới đây dù server trả 404” vẫn in ra, chứng minh fetch không hề throw ở 404. res.okfalse khi status ngoài khoảng 200–299. Bỏ qua bước check này là code của bạn sẽ gọi thẳng res.json() trên một trang lỗi rồi crash ở một chỗ khác, xa điểm gây lỗi.

2.2. .json() trả về any, không phải unknown

Cạm bẫy thứ hai nằm ở type. res.json() trả về Promise<any> — không phải unknown. any tắt mọi kiểm tra type, nên data.foo.bar.baz compile ngon lành rồi mới nổ lúc runtime:

1
2
3
4
5
6
7
async function loadUser() {
  const res = await fetch("/api/user");
  const data = await res.json();
  // data có type `any` — không phải `unknown`!
  // `data.khong.ton.tai` vẫn qua được compiler rồi crash ở runtime
  return data;
}

Cách vá đúng chỗ này là parse response qua một schema để có type thật lúc runtime. Zod làm việc đó gọn nhất — chi tiết cách dùng có ở bài Zod: Validate Dữ Liệu Type-Safe Trong React, ở đây chỉ dùng đúng phần cần cho một API call:

1
2
3
4
5
6
7
8
9
10
11
import { z } from "zod";

const userSchema = z.object({ id: z.number(), name: z.string() });

async function loadUser() {
  const res = await fetch("/api/user");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);

  // parse() ném lỗi nếu dữ liệu sai hình dạng — sau dòng này type là thật
  return userSchema.parse(await res.json());
}

2.3. Timeout với AbortSignal.timeout()

fetch mặc định không có timeout — một request có thể treo mãi. AbortSignal.timeout(ms) trả về một signal tự hủy sau ms mili-giây, và khi hủy vì quá hạn thì fetch reject với một DOMException tên TimeoutError:

1
2
3
4
5
6
7
8
9
10
11
12
async function fetchWithTimeout(url: string, ms = 5000) {
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
    return await res.json();
  } catch (err) {
    // Quá hạn: err là DOMException với name === "TimeoutError"
    if (err instanceof DOMException && err.name === "TimeoutError") {
      throw new Error(`Request quá ${ms}ms, đã hủy`);
    }
    throw err;
  }
}

AbortSignal.timeout() đã là Baseline (hỗ trợ đồng đều trên Chrome, Firefox, Safari) từ 2024, nên dùng thẳng không cần polyfill.

2.4. Retry với exponential backoff

Lỗi mạng và 5xx thường là tạm thời — thử lại sau một nhịp là qua. Nhưng đừng thử lại 4xx: đó là lỗi của request (sai token, sai URL), thử lại bao nhiêu lần cũng vẫn hỏng. Và mỗi lần thử nên chờ lâu hơn (exponential backoff) để không dội bom server đang ốm:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function fetchRetry(url: string, retries = 3): Promise<Response> {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
      // 5xx thì thử lại; 4xx là lỗi của mình, retry vô nghĩa
      if (res.ok || res.status < 500) return res;
    } catch (err) {
      if (attempt === retries) throw err;
    }
    // Còn lượt thử thì chờ 200ms, 400ms, 800ms... rồi thử lại
    if (attempt < retries) {
      await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
    }
  }
  throw new Error("Hết số lần thử");
}

3. Xây API client có type (thay vì rải fetch khắp nơi)

Gọi fetch trực tiếp trong từng component là công thức của rối loạn: mỗi chỗ tự check res.ok một kiểu, base URL lặp lại, không nơi nào có type. Gom hết vào một client là bước dọn dẹp đáng giá nhất.

3.1. Gom fetch vào một wrapper có type

Một helper generic lo phần lặp lại — check res.ok, ném lỗi có ngữ cảnh, gắn type cho kết quả:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type User = { id: number; name: string; email: string };

async function fetchJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) {
    throw new Error(`HTTP ${res.status} khi gọi ${url}`);
  }
  return res.json() as Promise<T>;
}

async function main() {
  const user = await fetchJson<User>(
    "https://jsonplaceholder.typicode.com/users/1"
  );
  console.log(user.name, "-", user.email);
}

main();

Bấm Run: nó gọi endpoint thật và in ra Leanne Graham - Sincere@april.biz. Component chỉ cần fetchJson<User>(...), không phải lặp lại logic xử lý lỗi.

Lưu ý: type ép bằng as là lời hứa, không phải kiểm tra

res.json() as Promise<T> chỉ nói với compiler rằng dữ liệu có hình dạng T, chứ không kiểm tra thật. Với dữ liệu từ API bên ngoài mà bạn không kiểm soát, hãy thay as bằng một schema .parse() như ở mục 2.2 để bắt lỗi ngay tại biên.

3.2. Trả Result thay vì ném lỗi khắp nơi

Ném lỗi buộc mọi caller phải nhớ try/catch, mà quên thì lỗi lọt lên tận cùng. Một kiểu khác là trả về Result — một discriminated union buộc bạn phải kiểm nhánh thành công hay thất bại trước khi chạm vào dữ liệu:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type User = { id: number; name: string };

type Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function getUser(id: number): Promise<Result<User, string>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
    return { ok: true, value: (await res.json()) as User };
  } catch {
    return { ok: false, error: "Mất kết nối" };
  }
}

Cách phân loại lỗi bài bản hơn — wrap Error, giữ nguyên stack, phân biệt lỗi mạng với lỗi nghiệp vụ — là một chủ đề riêng. Điều cần nhớ ở đây: dù bạn chọn throw hay Result, hãy chọn một kiểu và dùng nhất quán trong cả client.

4. Polling và long-polling: đơn giản mà nhiều khi vẫn đủ

Trước khi với tay tới SSE hay WebSocket, hỏi lại: bạn có thật sự cần server chủ động đẩy dữ liệu không, hay chỉ cần cập nhật định kỳ?

4.1. Short polling

Cứ vài giây hỏi server một lần. Thô, nhưng cho một dashboard cập nhật chậm thì nó đủ, và không cần thêm bất cứ hạ tầng nào:

1
2
3
4
5
// Cứ 10 giây hỏi server một lần — đủ cho dashboard cập nhật chậm
const timer = setInterval(async () => {
  const res = await fetch("/api/stats");
  if (res.ok) updateDashboard(await res.json());
}, 10_000);

Đánh đổi rõ ràng: chu kỳ càng ngắn thì càng “realtime” nhưng càng tốn request; chu kỳ dài thì dữ liệu trễ. Khi độ trễ vài giây chấp nhận được, đây là lựa chọn rẻ nhất.

4.2. Long polling

Long polling tinh hơn: client gửi request, server giữ nó mở cho tới khi có dữ liệu mới rồi mới trả về; client nhận xong mở request tiếp theo ngay. Nhờ vậy dữ liệu tới gần như tức thì mà vẫn chỉ dùng HTTP thường. Nó là cầu nối hợp lý khi hạ tầng chưa hỗ trợ SSE/WebSocket, hoặc khi tần suất cập nhật thấp tới mức chưa đáng dựng thêm protocol mới.

5. SSE: khi server chỉ cần đẩy một chiều

Khi luồng dữ liệu chỉ đi một chiều từ server xuống client — thông báo, log, tiến độ — thì SSE (Server-Sent Events) là công cụ nhẹ nhất, và nó bị bỏ quên một cách oan uổng vì ai cũng nghĩ ngay tới WebSocket.

5.1. EventSource — một dòng là có realtime

EventSource mở một kết nối HTTP thường và giữ nó để nhận sự kiện. Điểm ăn tiền: nó tự kết nối lại khi mạng chập chờn, bạn không phải viết một dòng reconnect nào:

1
2
3
4
5
6
7
8
9
10
const events = new EventSource("/api/notifications");

events.addEventListener("message", (e) => {
  const data = JSON.parse(e.data);
  showToast(data);
});

events.addEventListener("error", () => {
  // chỉ cần theo dõi lỗi ở đây
});

Dữ liệu qua SSE luôn là string, nên JSON.parse xong bạn có any — đúng chỗ nên gắn type. Khai báo hình dạng sự kiện rồi (lý tưởng nhất) validate bằng Zod trước khi tin:

1
2
3
4
5
6
7
8
9
10
11
type ServerEvent =
  | { kind: "notification"; title: string }
  | { kind: "log"; line: string };

const events = new EventSource("/api/stream");

events.addEventListener("message", (e) => {
  // nên validate bằng Zod ở biên thay vì ép `as`
  const event = JSON.parse(e.data) as ServerEvent;
  if (event.kind === "notification") showToast(event.title);
});

5.2. SSE hợp với gì

SSE sinh ra cho: thông báo đẩy, stream log build, thanh tiến độ, và stream token của LLM — kiểu chữ hiện dần từng từ như ChatGPT chính là SSE. Chung một đặc điểm: server nói, client chỉ nghe.

Lưu ý: SSE và giới hạn 6 kết nối trên HTTP/1.1

Trên HTTP/1.1, trình duyệt chỉ cho mở tối đa 6 kết nối cho mỗi cặp browser + domain. Mỗi EventSource giữ một kết nối mở liên tục, nên chỉ cần người dùng mở 6 tab tới cùng domain là cạn — tab thứ 7 sẽ treo chờ. Chạy trên HTTP/2 thì giới hạn này biến mất: số stream đồng thời do server và client thỏa thuận, mặc định khoảng 100. Nếu app dựa nhiều vào SSE, hãy chắc chắn server chạy HTTP/2.

6. WebSocket: khi thực sự cần hai chiều

WebSocket mở một kết nối hai chiều luôn mở: cả client lẫn server đều chủ động gửi bất cứ lúc nào. Mạnh, nhưng cũng nặng hạ tầng hơn — nên chỉ rút ra khi thật sự cần.

6.1. Khi nào THỰC SỰ cần WebSocket

Dấu hiệu cần WebSocket: hai bên cùng gửi liên tục, tần suất cao. Chat, soạn thảo cộng tác (con trỏ của người khác nhảy realtime trên Google Docs, Figma), game nhiều người. Nếu chỉ server đẩy xuống còn client hầu như không gửi ngược, SSE đã đủ và rẻ hơn nhiều.

1
2
3
4
5
6
7
8
9
10
const socket = new WebSocket("wss://api.example.com/chat");

socket.addEventListener("open", () => {
  socket.send(JSON.stringify({ type: "join", room: "general" }));
});

socket.addEventListener("message", (e) => {
  const msg = JSON.parse(e.data);
  renderMessage(msg);
});

6.2. Type-safe message với discriminated union + Zod

Message đến từ socket là dữ liệu bên ngoài — đừng tin nó. Mô tả mọi loại message bằng một discriminated union rồi validate ở biên bằng Zod, để một frame hỏng không kéo sập cả handler. Ví dụ dưới giả lập vài frame nhận được (cái cuối thiếu field text) nên chạy được ngay mà không cần server thật:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { z } from "zod";

const messageSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("chat"), from: z.string(), text: z.string() }),
  z.object({ type: z.literal("typing"), from: z.string() }),
  z.object({ type: z.literal("presence"), online: z.number() }),
]);

type Message = z.infer<typeof messageSchema>;

function handleMessage(raw: unknown) {
  const result = messageSchema.safeParse(raw);
  if (!result.success) {
    console.log("Bỏ qua frame lỗi:", result.error.issues[0].message);
    return;
  }
  const msg = result.data;
  if (msg.type === "chat") console.log(`[chat] ${msg.from}: ${msg.text}`);
  if (msg.type === "typing") console.log(`[typing] ${msg.from} đang gõ...`);
  if (msg.type === "presence") console.log(`[presence] ${msg.online} người online`);
}

handleMessage({ type: "chat", from: "an", text: "Chào cả nhà" });
handleMessage({ type: "presence", online: 12 });
handleMessage({ type: "chat", from: "binh" });

Bấm Run: hai frame hợp lệ được xử lý đúng nhánh, còn frame thiếu text bị safeParse chặn với message Invalid input: expected string, received undefined. Ghép vào socket thật thì chỉ cần: socket.addEventListener("message", (e) => handleMessage(JSON.parse(e.data))). Chi tiết discriminatedUnion, safeParse với Zod nằm ở bài Zod.

6.3. Heartbeat và reconnect — WebSocket không tự lo

Khác SSE, WebSocket không tự kết nối lại. Tệ hơn, một kết nối có thể “chết mà chưa đóng” (proxy cắt ngầm) và bạn không hề biết. Nên phải tự gửi heartbeat định kỳ để phát hiện, và tự mở lại khi đứt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function connect(url: string) {
  const socket = new WebSocket(url);
  let heartbeat: ReturnType<typeof setInterval>;

  socket.addEventListener("open", () => {
    heartbeat = setInterval(() => socket.send('{"type":"ping"}'), 30_000);
  });

  socket.addEventListener("close", () => {
    clearInterval(heartbeat);
    // Tự mở lại sau 1 giây — thực tế nên tăng dần (backoff) để không dội server
    setTimeout(() => connect(url), 1000);
  });

  return socket;
}

Chính vì phải tự lo heartbeat, reconnect, và gom message lỗi, nhiều dự án dùng thư viện (Socket.IO và tương tự) thay vì WebSocket trần. Nhưng hiểu phần trần trước thì mới biết thư viện đang che giúp bạn cái gì.

7. Chọn protocol nào? Bảng so sánh

Đặt ba lựa chọn cạnh nhau theo những tiêu chí quyết định thực tế:

Tiêu chíREST / PollingSSE (EventSource)WebSocket
Chiều dữ liệuClient hỏi, server trảServer → client (1 chiều)Hai chiều
Tự kết nối lạiMỗi request độc lậpCó, tự độngKhông — tự code
Qua proxy/firewallMượt (HTTP thường)Mượt (HTTP thường)Đôi khi bị chặn
Độ phức tạp hạ tầngThấp nhấtThấp, nên chạy HTTP/2Cao: server giữ kết nối
Khi nào chọnCRUD, dữ liệu ít đổiNotification, log, AI token streamChat, cộng tác, game

Nếu còn phân vân, đi theo cây quyết định này:

View Mermaid diagram code
flowchart TD
    Start([Cần gì?]) --> Q1{Server có cần<br/>chủ động đẩy?}
    Q1 -->|Không| REST[REST + polling nếu cần refresh]
    Q1 -->|Có| Q2{Client có cần<br/>gửi ngược liên tục?}
    Q2 -->|Không| SSE[SSE / EventSource]
    Q2 -->|Có| WS[WebSocket]

    style REST fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
    style SSE fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
    style WS fill:#241832,stroke:#c08cff,stroke-width:1.5px,color:#d4b3ff

Còn WebTransport (chạy trên HTTP/3, hỗ trợ cả stream lẫn datagram hai chiều) đang dần được các trình duyệt hỗ trợ — đáng để mắt cho tương lai, nhưng WebSocket vẫn là mặc định vì hệ sinh thái thư viện và hạ tầng của nó chín hơn nhiều.

8. Bảo mật cho realtime protocol

Mở một kết nối luôn-mở tới server nghĩa là mở thêm một bề mặt tấn công. Hai điều dưới đây là phần bảo mật riêng của protocol; còn XSS, CSP, injection, session hijacking là chuyện chung của web, đã có trong loạt bài Ngăn Chặn XSS Với CSP, SQL và Command InjectionSession Hijacking.

8.1. Luôn wss:// và cẩn thận với token trên URL

Luôn dùng wss:// (WebSocket qua TLS), đúng như luôn dùng https://ws:// gửi message dạng plaintext, ai chặn giữa đường cũng đọc được.

Điểm đau riêng của WebSocket: từ trình duyệt, hàm khởi tạo WebSocket không cho set header tùy chỉnh, nên không đính được Authorization. Nhiều người bèn nhét token vào query string (wss://.../chat?token=...) — và token đó lập tức lộ vào access log của server, log của proxy, và lịch sử. Cách an toàn hơn:

  • Dựa vào cookie (same-site) — trình duyệt tự gửi kèm lúc bắt tay; kết hợp bảo vệ CSRF.
  • Lấy một ticket ngắn hạn (sống vài giây) qua một request HTTPS đã xác thực, rồi mới dùng nó mở socket.
  • Gửi token làm message đầu tiên ngay sau khi kết nối mở, trước khi server tin bất cứ message nào khác.

Đừng cất token dài hạn trong localStorage để rồi nhét lên URL — cân nhắc nơi lưu token phía client trong bài Web Storage APIs.

8.2. WebSocket không nằm trong CORS — server phải tự kiểm Origin

Đây là chỗ hay bị bỏ sót: CORS không áp cho WebSocket. Một trang độc hại có thể mở kết nối wss:// tới server của bạn, và nếu server chỉ dựa vào cookie để xác thực thì kết nối đó vẫn được tính là “đã đăng nhập” — kiểu tấn công Cross-Site WebSocket Hijacking, họ hàng của CSRF. Phòng thủ: server tự đọc header Origin của request bắt tay và từ chối mọi origin lạ. Cùng tư duy kiểm origin với postMessage trong bài Clickjacking và postMessage.

Nguyên tắc gọn để nhớ: mặc định là REST; server cần đẩy một chiều thì SSE; hai chiều tần suất cao mới tới WebSocket. Dù chọn cái nào, validate mọi dữ liệu ở biên và gom logic mạng vào một client có type thay vì rải fetch khắp component. Việc cần làm ngay: mở lại đoạn gọi API gần nhất của bạn — nó đã check res.ok chưa, response đã có type thật chưa? Sửa hai chỗ đó trước, rồi mới bàn tới realtime.

← Previous
Tự viết lại Pick, Omit, Partial: hiểu utility types từ gốc
Table of Contents
  1. 1. HTTP: nền tảng bạn không được bỏ qua
    1. 1.1. Một vòng request/response gồm những gì
    2. 1.2. HTTP/2 và cái chết của mẹo “gộp request”
  2. 2. fetch cho đúng: những cạm bẫy ai cũng dính
    1. 2.1. fetch KHÔNG throw khi 4xx/5xx
    2. 2.2. .json() trả về any, không phải unknown
    3. 2.3. Timeout với AbortSignal.timeout()
    4. 2.4. Retry với exponential backoff
  3. 3. Xây API client có type (thay vì rải fetch khắp nơi)
    1. 3.1. Gom fetch vào một wrapper có type
    2. 3.2. Trả Result thay vì ném lỗi khắp nơi
  4. 4. Polling và long-polling: đơn giản mà nhiều khi vẫn đủ
    1. 4.1. Short polling
    2. 4.2. Long polling
  5. 5. SSE: khi server chỉ cần đẩy một chiều
    1. 5.1. EventSource — một dòng là có realtime
    2. 5.2. SSE hợp với gì
  6. 6. WebSocket: khi thực sự cần hai chiều
    1. 6.1. Khi nào THỰC SỰ cần WebSocket
    2. 6.2. Type-safe message với discriminated union + Zod
    3. 6.3. Heartbeat và reconnect — WebSocket không tự lo
  7. 7. Chọn protocol nào? Bảng so sánh
  8. 8. Bảo mật cho realtime protocol
    1. 8.1. Luôn wss:// và cẩn thận với token trên URL
    2. 8.2. WebSocket không nằm trong CORS — server phải tự kiểm Origin