GĐ03 — Nền tảng: HTTP / Node runtime / async / networking

Study note cho FE engineer (mạnh JS/TS) chuyển sang Backend. Mỗi concept: định nghĩa → tại sao quan trọng → cơ chế → ví dụ → pitfall. Thuật ngữ giữ tiếng Anh.

Kiểm chứng ngày 2026-10-05: các mặc định của http.Server và mọi snippet mới ở mục 11a, 11b, 12a, 12b đã chạy trên Node 24.21 (macOS) trong thư mục tạm; undici 8.x và piscina 5.x là bản latest lúc cài. server.keepAliveTimeoutBuffer có từ Node 24.6.0 (tài liệu http). Hành vi của localhost (IPv4/IPv6) là kết quả đo trên macOS; Linux và container có thể khác vì phụ thuộc /etc/hosts và resolver. Chưa kiểm: hành vi trên Windows.


Phần A — HTTP protocol#

1. HTTP method semantics & idempotency#

Định nghĩa ngắn. HTTP method là "động từ" mô tả ý định thao tác lên một resource: GET (đọc), POST (tạo/hành động), PUT (thay thế toàn bộ), PATCH (sửa một phần), DELETE (xoá).

  • safe = không thay đổi state trên server (chỉ đọc). GET, HEAD là safe.
  • idempotent = gọi 1 lần hay N lần (cùng payload) cho ra cùng một server state cuối. GET, PUT, DELETE idempotent. POST không idempotent. PATCH thường không đảm bảo.

Tại sao quan trọng với backend. Backend sống trong môi trường có retry: client timeout rồi gửi lại, load balancer replay, mobile mạng chập chờn. Nếu endpoint idempotent thì retry an toàn — không tạo 2 order, không trừ tiền 2 lần. Method semantics còn quyết định cache (chỉ GET được cache), routing, và cả việc proxy có được phép tự động retry hay không.

Cơ chế / cách hoạt động.

  • GET: params ở URL/query, không body semantic. Được cache, được prefetch. → tuyệt đối không được có side-effect.
  • POST: tạo resource mới (server sinh id) HOẶC trigger một action (gửi email, thanh toán). Mỗi lần gọi = một tác động mới.
  • PUT /users/42: client biết trước id, gửi toàn bộ representation. Gọi lại → ghi đè cùng nội dung → state không đổi thêm ⇒ idempotent.
  • PATCH /users/42: gửi delta ({ "email": "x" }). Idempotent nếu delta là "set field = value"; KHÔNG idempotent nếu delta kiểu { "$inc": { "credits": 5 } }.
  • DELETE /users/42: xoá lần 1 → 200/204; lần 2 → 404 (status khác nhau nhưng state vẫn là "đã xoá") ⇒ vẫn idempotent.

Ví dụ thực tế — idempotency key cho POST. Cách ngây thơ là "tra key, chưa có thì charge rồi lưu". Hai request retry đến cùng lúc đều tra thấy "chưa có" rồi cùng charge — đây là lỗi check-then-act: khoảng hở nằm giữa lúc kiểm tra và lúc ghi. Cách sửa là giành khoá bằng một câu INSERT và để UNIQUE (key) của database quyết định ai thắng:

typescriptReady
// POST /payments không idempotent → key + unique constraint để chống double-charge khi client retry// Bảng (cùng cột với GĐ10 mục 3, thêm request_hash)://   idempotency_key(key PK, request_hash, status 'PENDING'|'DONE', locked_until timestamptz, lock_token uuid, result jsonb)// charge() gọi Stripe với { timeout: 15_000, maxNetworkRetries: 2 } ⇒ tối đa ~45s + backoff.// lockSeconds ≥ timeout × (1 + maxNetworkRetries) + backoff + đệm; mặc định stripe-node (80s, 2 retry) đòi hơn 4 phút.const LOCK_SECONDS = 75app.post("/payments", async (req, res) => {  const key = req.header("Idempotency-Key"); // client sinh UUID, giữ nguyên khi retry  if (!key) return res.status(400).json({ error: "Idempotency-Key required" });  const hash = sha256(JSON.stringify(req.body)); // gắn key với nội dung request (xem lưu ý về thứ tự key bên dưới)  // Bước 1: giành khoá. Chỉ một request nhận được 1 row (kèm lock_token), mọi request khác nhận 0 row.  // Khoá PENDING hết hạn (process chết giữa chừng) thì request sau giành lại được và nhận token mới.  const claim = await db.query(    `INSERT INTO idempotency_key (key, request_hash, status, locked_until, lock_token)     VALUES ($1, $3, 'PENDING', now() + make_interval(secs => $2), gen_random_uuid())     ON CONFLICT (key) DO UPDATE       SET locked_until = now() + make_interval(secs => $2), lock_token = gen_random_uuid()       WHERE idempotency_key.status = 'PENDING'         AND idempotency_key.locked_until < now()         AND idempotency_key.request_hash = EXCLUDED.request_hash     RETURNING lock_token`,    [key, LOCK_SECONDS, hash],  );  if (claim.rowCount === 0) {    const { rows } = await db.query(      "SELECT status, request_hash, result FROM idempotency_key WHERE key = $1", [key]);    const row = rows[0];    if (row.request_hash !== hash) return res.status(422).json({ error: "key đã dùng cho request khác" });    if (row.status === "DONE") return res.status(200).json(row.result); // trả kết quả cũ, không charge lại    return res.status(409).set("Retry-After", "1").json({ error: "đang xử lý" });  }  const token = claim.rows[0].lock_token;  // Bước 2: chỉ request giữ khoá mới charge, rồi đánh dấu DONE  let p;  try {    p = await charge(req.body, { idempotencyKey: key }); // gửi key sang provider nữa (xem giới hạn bên dưới)  } catch (err) {    // lỗi tạm: nhả khoá ngay để client retry được liền, thay vì chờ hết LOCK_SECONDS.    // lock_token: nếu khoá của ta đã hết hạn và bị giành lại, câu này ảnh hưởng 0 dòng (không mở khoá của người khác)    await db.query(      "UPDATE idempotency_key SET locked_until = now() WHERE key = $1 AND lock_token = $2 AND status = 'PENDING'",      [key, token]);    throw err;  }  await db.query(    "UPDATE idempotency_key SET status = 'DONE', result = $3 WHERE key = $1 AND lock_token = $2 AND status = 'PENDING'",    [key, token, p]);  res.status(201).json(p);});

Hai lưu ý nhỏ. Thứ nhất, JSON.stringify(req.body) phụ thuộc thứ tự key: cùng nội dung nhưng client gửi key theo thứ tự khác sẽ ra hash khác và bị 422 oan; muốn chặt hơn thì hash bytes thô của body (lấy từ express.json({ verify }) hoặc express.raw()). Thứ hai, nhả khoá khi charge ném lỗi chỉ an toàn vì key đã được gửi sang provider: lần gọi lại với cùng key không thể charge thêm lần nữa.

Giới hạn cần nói thẳng: nếu process chết giữa charge() và UPDATE … DONE, khoá hết hạn rồi request sau sẽ charge lần hai. Vì vậy phải truyền luôn idempotencyKey sang nhà cung cấp (Stripe có sẵn): lúc đó provider tự trả kết quả cũ cho lần gọi thứ hai. Điều kiện của "1 lần trừ tiền": chỉ đúng trong thời hạn provider còn giữ key (Stripe tối thiểu 24 giờ) và khi tham số gọi y hệt lần đầu; request bị phát lại sau thời hạn đó (ví dụ job chạy lại từ DLQ), sau một lần process chết giữa charge và DONE, có thể trừ tiền lần hai. Lý do không dùng "insert key rồi charge" đơn giản (lỗi tạm sau khi giữ khoá làm retry bị bỏ qua, mất giao dịch), cách xử lý job queue và phần còn lại: xem GĐ10 mục 3.

Mức đã kiểm chứng: logic (20 request đồng thời chỉ charge 1 lần, retry sau khi xong trả 200, cùng key khác body trả 422, lỗi tạm thì khoá được nhả và retry ngay được charge lại đúng 1 lần; process chết giữa chừng thì retry nhận 409 cho đến khi hết khoá) chạy thật trên node:sqlite. Các câu SQL (claim có lock_token và request_hash, nhả khoá, DONE) chạy thật trên PostgreSQL 17.9 với nhiều connection tranh chấp: 20 claim song song chỉ 1 thắng, 5 worker chạy đủ luồng chỉ 1 charge, khoá cũ hết hạn rồi bị giành lại thì nhả khoá hoặc ghi DONE bằng token cũ ảnh hưởng 0 dòng, cùng key khác request_hash không giành được khoá. Chưa chạy qua driver/ORM của dự án và chưa gọi Stripe thật (nhà cung cấp là bản giả lập).

Sơ đồ và kết quả mong đợi: vòng đời khoá idempotency
textReady
 INSERT ... ON CONFLICT (giành khoá, một câu SQL)   │   ├─ 0 row, key khác request_hash ───────────► 422 (key dùng cho request khác)   ├─ 0 row, status DONE ─────────────────────► 200 trả kết quả cũ, KHÔNG charge   ├─ 0 row, PENDING còn hạn ─────────────────► 409 Retry-After (đang xử lý)   └─ 1 row (giữ lock_token) ──► charge()          ├─ thành công ─► UPDATE ... DONE WHERE lock_token = token ─► 201          ├─ lỗi tạm ────► nhả khoá (locked_until = now) ─► retry được liền          └─ process chết giữa charge và DONE                ─► sau LOCK_SECONDS khoá hết hạn, request sau giành lại                   (token mới) ─► charge lần hai, trừ khi provider cũng                   nhận idempotencyKey và trả kết quả cũ

Kịch bản tự thử trên bản cài của bạn, kết quả mong đợi (suy ra từ SQL ở trên): gửi cùng Idempotency-Key hai lần liên tiếp thì lần hai là 200 với body lần một và charge chỉ được gọi 1 lần; gửi hai request song song thì một bên 201, bên kia 409; đổi body mà giữ key thì 422. Phần "mức đã kiểm chứng" ngay trên là kết quả chạy thật của người viết bài, không phải của khối này.

Pitfall hay gặp.

  • Dùng GET cho action có side-effect (vd GET /users/42/delete) → crawler/browser prefetch quét sạch data. Kinh điển.
  • Nghĩ "PUT với PATCH giống nhau, dùng cái nào cũng được". PUT thiếu field → nhiều framework hiểu là "set field đó về null" → mất dữ liệu.
  • Cho phép LB tự retry POST non-idempotent → double order. Fix: idempotency key hoặc chỉ retry method idempotent.

2. HTTP status codes đúng chuẩn#

Định nghĩa ngắn. Số 3 chữ số báo kết quả: 2xx thành công, 3xx redirect, 4xx lỗi phía client, 5xx lỗi phía server.

Tại sao quan trọng. Status code là contract máy-đọc-được: client, LB, monitoring, retry logic đều dựa vào nó. Trả sai (vd luôn 200 kèm {error:...}) khiến alert không kêu, client không biết retry hay không, cache hỏng.

Cơ chế — chọn cái nào.

Success:

  • 200 OK — thành công, có body trả về (GET, hoặc POST/PUT trả entity).
  • 201 Created — tạo resource mới; nên kèm header Location: /users/42.
  • 204 No Content — thành công nhưng không có body (DELETE, hoặc PUT chỉ cần xác nhận). Client không được parse body.

Client error (4xx — lỗi do request, đừng retry y nguyên):

  • 400 Bad Request — request malformed: JSON parse fail, thiếu field bắt buộc, sai kiểu ở tầng syntax.
  • 422 Unprocessable Entity — syntax OK nhưng vi phạm business/validation rule (email đúng format nhưng đã tồn tại, tuổi = -5). Nhiều team gộp hết vào 400; tách 422 rõ ràng hơn cho validation.
  • 401 Unauthorized — chưa xác thực / token sai/hết hạn ("anh là ai?"). → client nên login lại.
  • 403 Forbidden — đã biết anh là ai nhưng không đủ quyền ("biết rồi, nhưng cấm"). → login lại vô ích.
  • 404 Not Found — resource không tồn tại (hoặc cố tình giấu sự tồn tại vì lý do security).
  • 409 Conflict — xung đột state: tạo trùng unique, optimistic-lock version mismatch, xoá cái đang được reference.
  • 429 Too Many Requests — rate limit; nên kèm header Retry-After.

Server error (5xx — lỗi phía server, thường có thể retry):

  • 500 Internal Server Error — bug/exception không lường trước trong app của mình.
  • 502 Bad Gateway — mình là proxy/gateway, upstream trả response hỏng.
  • 503 Service Unavailable — server tạm quá tải / đang deploy / maintenance; kèm Retry-After.
  • 504 Gateway Timeout — proxy chờ upstream quá lâu, hết giờ.

Ví dụ.

typescriptReady
if (!body.email) return res.status(400).json({ error: "email required" });      // syntaxif (!isEmail(body.email)) return res.status(422).json({ error: "email invalid" }); // semanticif (await db.exists(body.email)) return res.status(409).json({ error: "taken" }); // conflict

Pitfall.

  • 200 với error body — "always 200" là anti-pattern lớn nhất: monitoring thấy 100% success trong khi user fail hết.
  • Nhầm 401/403: trả 403 khi token hết hạn → client không tự refresh token.
  • Trả 500 cho lỗi validation → client tưởng server sập và retry, làm nặng thêm.
  • Trả 404 khi thực ra là 403 đôi khi cố ý (không lộ resource tồn tại) — biết để không nhầm là bug.

3. HTTP headers quan trọng#

Định nghĩa ngắn. Header là các cặp key-value metadata đi kèm request/response, mô tả nội dung, xác thực, cache, độ dài body.

Tại sao quan trọng. Backend đọc header để biết parse body kiểu gì, ai đang gọi, có được trả cache không; và set header để điều khiển cache/CDN, bảo mật, streaming.

Cơ chế.

  • Content-Type — kiểu body đang gửi: application/json, application/x-www-form-urlencoded, multipart/form-data, text/plain. Server chọn parser theo header này. Sai Content-Type → parse fail.
  • Accept — client nói "tôi muốn nhận định dạng gì" (application/json). Server dùng cho content negotiation.
  • Authorization — credential: thường Bearer <jwt> hoặc Basic <base64>. Đây là nơi token nằm, không phải cookie (trừ session-cookie flow).
  • Cache-Control — chỉ thị cache: no-store (không lưu), no-cache (lưu nhưng phải revalidate), max-age=60, private/public.
  • ETag — "fingerprint" của một version resource. Client gửi lại If-None-Match: <etag>; nếu chưa đổi server trả 304 Not Modified (không body) → tiết kiệm băng thông.
  • Content-Length vs Transfer-Encoding: chunked — cách báo độ dài body. Content-Length = biết trước tổng bytes (buffer sẵn). chunked = stream, gửi từng chunk, không cần biết tổng size trước (dùng khi tạo dữ liệu on-the-fly). Không dùng đồng thời cả hai.

Ví dụ — ETag revalidation.

typescriptReady
const etag = hash(user);                 // "W/\"abc123\""if (req.header("if-none-match") === etag) return res.status(304).end();res.setHeader("ETag", etag).json(user);
Kết quả mong đợi: ETag và 304

Chưa chạy: suy ra từ code và hành vi HTTP chuẩn.

bashReady
curl -i localhost:3000/users/42                                  # lần 1curl -i localhost:3000/users/42 -H 'If-None-Match: W/"abc123"'   # lần 2, gửi lại ETag

Lần 1: 200 kèm ETag: W/"abc123" và body JSON. Lần 2 (dữ liệu chưa đổi): 304 Not Modified, không có body, tiết kiệm băng thông nhưng server vẫn phải tính ETag hoặc kiểm tra version. Sửa user rồi gửi lại với ETag cũ thì được 200 và ETag mới. Ghi chú: Express tự sinh ETag yếu cho res.json() và tự trả 304 khi req.fresh, nên đoạn mã thủ công chỉ để hiểu cơ chế; trong code thật, ETag nên suy ra từ version/updated_at chứ không băm lại toàn bộ dữ liệu. Lỗi hay gặp: so sánh === với header có nhiều giá trị (If-None-Match: "a", "b") hoặc dạng yếu/mạnh khác nhau.

Pitfall.

  • Quên set Content-Type: application/json khi trả JSON → client/browser hiểu nhầm là text, không parse.
  • Cache dữ liệu per-user với Cache-Control: public → CDN phục vụ data của user A cho user B (data leak nghiêm trọng). Data cá nhân phải private + no-store.
  • Set cả Content-Length sai lệch so với body thực → response bị cắt cụt hoặc treo.

4. CORS phía server#

Định nghĩa ngắn. CORS (Cross-Origin Resource Sharing) là cơ chế của browser: một trang ở origin A gọi API ở origin B (khác scheme/host/port) sẽ bị chặn đọc response trừ khi server B chủ động cho phép qua các header Access-Control-*.

Tại sao quan trọng. FE hay báo "bị CORS chặn" — nhưng CORS được fix ở server, không phải ở FE. BE dev phải hiểu để cấu hình đúng, không tắt bừa (*) gây lỗ hổng.

Cơ chế.

  • Với request "đơn giản" (GET/POST đơn giản), browser gửi thẳng nhưng chỉ cho JS đọc response nếu Access-Control-Allow-Origin khớp origin.
  • Với request "phức tạp" (method PUT/DELETE, có Authorization hay Content-Type: application/json), browser gửi preflight OPTIONS trước để hỏi server "tôi được phép gọi không?". Server phải trả:
    • Access-Control-Allow-Origin: https://app.example.com (hoặc *)
    • Access-Control-Allow-Methods: GET,POST,PUT,DELETE
    • Access-Control-Allow-Headers: Content-Type,Authorization
    • Access-Control-Allow-Credentials: true (nếu gửi cookie/credential)
  • Chỉ khi preflight OK, browser mới gửi request thật.

Ví dụ — set CORS bằng http thuần.

typescriptReady
res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");if (req.method === "OPTIONS") return res.writeHead(204).end(); // trả preflight sớm
Kết quả mong đợi: tự kiểm preflight bằng curl

Chưa chạy: suy ra từ code ở trên và quy tắc CORS.

textReady
 Browser (origin https://app.example.com)           Server   │  OPTIONS /users                                   │   │  Origin, Access-Control-Request-Method: PUT       │   │  Access-Control-Request-Headers: authorization    │   │ ─────────────────────────────────────────────────►│   │ ◄── 204 + Access-Control-Allow-Origin/Methods/Headers   │  PUT /users/42  (request thật, kèm Authorization) │   │ ─────────────────────────────────────────────────►│   │ ◄── 200 + Access-Control-Allow-Origin (phải có lại)
bashReady
curl -i -X OPTIONS localhost:3000/users \  -H 'Origin: https://app.example.com' \  -H 'Access-Control-Request-Method: PUT' \  -H 'Access-Control-Request-Headers: authorization,content-type'

Mong đợi: HTTP/1.1 204 cùng ba header Access-Control-Allow-Origin: https://app.example.com, Access-Control-Allow-Methods, Access-Control-Allow-Headers. Nếu thiếu Allow-Headers chứa authorization thì browser từ chối request thật, dù curl vẫn gọi được (CORS chỉ ràng buộc browser). Khi cho phép nhiều origin, so từng origin với allowlist rồi echo lại đúng origin đó, kèm Vary: Origin để cache không trộn kết quả giữa các origin.

Ví dụ — allowlist nhiều origin và Vary. Ví dụ trên cố định một origin. Khi có nhiều FE (app, admin), so origin của request với một tập cho phép rồi echo lại đúng origin đó:

typescriptReady
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com'])/** Trả true nếu đã trả lời xong (preflight), false nếu handler chính nên chạy tiếp. */function applyCors(req: IncomingMessage, res: ServerResponse): boolean {  res.setHeader('Vary', 'Origin') // luôn đặt, kể cả origin lạ: cache không được trộn các câu trả lời  const origin = req.headers.origin  if (!origin || !ALLOWED.has(origin)) return false // không thêm Allow-Origin: browser tự chặn  res.setHeader('Access-Control-Allow-Origin', origin) // echo đúng origin trong allowlist, không dùng '*'  res.setHeader('Access-Control-Allow-Credentials', 'true')  const isPreflight = req.method === 'OPTIONS' && req.headers['access-control-request-method']  if (!isPreflight) return false  res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE')  res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,Idempotency-Key')  res.setHeader('Access-Control-Max-Age', '600') // browser cache preflight 10 phút  res.writeHead(204).end()  return true}createServer((req, res) => {  if (applyCors(req, res)) return  res.setHeader('Content-Type', 'application/json')  res.end('{"ok":true}')}).listen(4301, '127.0.0.1')
Kết quả đã chạy: allowlist và Vary

Đã chạy trên Node 24.21 (node cors.ts, type stripping), bốn lệnh curl -si và chỉ lọc các header HTTP, Vary, Access-Control-*:

textReady
 OPTIONS, Origin hợp lệ  -> 204, Vary: Origin,                            Allow-Origin: https://app.example.com,                            Allow-Credentials: true, Allow-Methods,                            Allow-Headers, Max-Age: 600 OPTIONS, Origin lạ      -> 200, chỉ có Vary: Origin (không có Access-Control-*) GET, Origin admin       -> 200, Vary: Origin,                            Allow-Origin: https://admin... GET, không có Origin    -> 200, Vary: Origin

Vì sao Vary: Origin luôn có mặt: cùng một URL trả Allow-Origin khác nhau tuỳ Origin của request, nên CDN/cache phải khoá theo Origin. Thiếu Vary, cache có thể phát lại câu trả lời dành cho origin A cho origin B (B bị chặn dù được phép) hoặc ngược lại. Origin lạ không nhận Access-Control-* nên browser chặn việc đọc response; curl vẫn gọi được vì CORS chỉ ràng buộc browser. Tự kiểm preflight bằng đúng lệnh curl ở khối trên, đổi Origin sang một giá trị ngoài allowlist.

Pitfall.

  • Allow-Origin: * cùng Allow-Credentials: true — browser từ chối, và về bảo mật là sai. Với credentials phải echo lại đúng origin cụ thể (không được *).
  • Server không handle method OPTIONS (route chỉ có GET/POST) → preflight nhận 404/405 → FE thấy "CORS error" dù logic đúng.
  • Nghĩ CORS là bảo mật server → không. CORS chỉ bảo vệ browser user; curl/backend vẫn gọi được thoải mái. Auth vẫn phải làm riêng.

Phần B — Node.js runtime & async#

5. Node.js event loop#

Định nghĩa ngắn. Event loop là vòng lặp đơn luồng (single main thread) của Node, liên tục lấy callback đã "chín" ra chạy. Nó cho phép xử lý nhiều kết nối đồng thời mà chỉ 1 thread JS.

Tại sao quan trọng. Một Node process = một thread chạy JS. Hiểu event loop = hiểu vì sao một đoạn tính toán nặng đồng bộ có thể làm treo toàn bộ server (mọi request khác chờ), và vì sao thứ tự setTimeout vs Promise không như trực giác.

Cơ chế — các phase (một tick):

  1. timers — chạy callback của setTimeout/setInterval đã tới hạn.
  2. pending callbacks — một số I/O callback bị hoãn.
  3. poll — chờ và xử lý I/O mới (đọc file, socket). Phase Node "nằm chờ" ở đây.
  4. check — chạy setImmediate.
  5. close — callback close (vd socket đóng).

Microtask queue (Promise .then, queueMicrotask, process.nextTick) không phải một phase — nó được flush sạch sau mỗi callback và giữa các phase. Nghĩa là microtask luôn ưu tiên hơn macrotask kế tiếp.

  • macrotask: setTimeout, setImmediate, I/O callback.
  • microtask: Promise.then, await (phần sau await), queueMicrotask, process.nextTick (còn ưu tiên hơn cả Promise microtask).

Ví dụ — thứ tự in ra.

typescriptReady
console.log("1");setTimeout(() => console.log("2-timeout"), 0); // macrotaskPromise.resolve().then(() => console.log("3-promise")); // microtaskconsole.log("4");// Output: 1, 4, 3-promise, 2-timeout// sync trước → microtask flush hết → mới tới macrotask
Sơ đồ và kết quả mong đợi: các phase và thứ tự
textReady
   ┌─► timers ─► pending ─► poll (chờ I/O) ─► check (setImmediate) ─► close ─┐   └─────────────────────────────────────────────────────────────────────────┘   Sau MỖI callback (và giữa các phase): flush nextTick, rồi flush microtask   (Promise.then, await, queueMicrotask) cho tới khi cạn, rồi mới chạy   callback macrotask kế tiếp.

Đã chạy trên Node 24.21 (sáu lần liên tiếp, CommonJS; thêm một lần với ESM). Thử trong một file CommonJS:

typescriptReady
const fs = require('node:fs')setTimeout(() => console.log('timeout'), 0)setImmediate(() => console.log('immediate'))process.nextTick(() => console.log('nextTick'))Promise.resolve().then(() => console.log('promise'))console.log('sync')fs.readFile(__filename, () => {  setTimeout(() => console.log('io: timeout'), 0)  setImmediate(() => console.log('io: immediate'))})

Mong đợi: sync, nextTick, promise luôn đứng đầu theo thứ tự đó; timeout và immediate của module chính có thể đảo thứ tự giữa các lần chạy (phụ thuộc thời điểm vào vòng lặp); trong callback I/O thì io: immediate luôn đứng trước io: timeout, vì sau phase poll là phase check. Lưu ý: trong ESM, mã cấp cao chạy bên trong một microtask, nên thứ tự nextTick so với promise khác đi; ví dụ ở trên dùng CommonJS để tránh nhầm.

Quan sát được: CommonJS cả sáu lần ra sync, nextTick, promise, timeout, immediate, io: immediate, io: timeout (timeout đứng trước immediate ở module chính trên máy này, nhưng tài liệu Node nói thứ tự đó không được bảo đảm); cùng bốn dòng đầu trong file .mjs ra sync, promise, nextTick, timeout, tức promise đứng trước nextTick.

Hệ quả của việc nextTick được flush trước mọi thứ khác: gọi đệ quy process.nextTick giữ vòng lặp mãi ở bước flush. Đã chạy: một hàm tự gọi lại bằng process.nextTick 3.000.000 lần làm timer setTimeout(0) chỉ chạy sau khoảng 100 ms, khi chuỗi đã xong; cùng vòng lặp dùng setImmediate thì timer chạy sau khoảng 1 ms, giữa các lần lặp. Muốn nhường event loop giữa các phần việc dài, dùng setImmediate, không dùng nextTick.

Pitfall.

  • CPU nặng đồng bộ block cả server: for loop 5 tỷ vòng hoặc JSON.parse payload 50MB chặn event loop → mọi request khác đứng hình, health check fail, LB tưởng server chết.
typescriptReady
app.get("/heavy", (req, res) => {  let s = 0; for (let i = 0; i < 5e9; i++) s += i; // chặn ~vài giây, toàn bộ server treo  res.json({ s });});
  • Lạm dụng process.nextTick đệ quy → microtask queue không bao giờ cạn → starve I/O (poll phase không tới lượt) → server "sống" nhưng không nhận request mới.

6. libuv & thread pool#

Định nghĩa ngắn. libuv là thư viện C dưới Node lo phần async I/O + cung cấp một thread pool (mặc định 4 thread) để chạy các tác vụ không có async OS-native.

Tại sao quan trọng. "Node single-threaded" chỉ đúng cho JS. I/O thật ra chạy song song nhờ libuv. Hiểu điều này để biết cái gì thực sự song song, cái gì vẫn nghẽn, và tune UV_THREADPOOL_SIZE.

Cơ chế.

  • Network I/O (TCP/HTTP socket) dùng cơ chế OS non-blocking (epoll/kqueue/IOCP) → không tốn thread pool, scale rất tốt.

  • Một số tác vụ không có async OS API phải chạy trên thread pool: fs.* (file), crypto.pbkdf2/bcrypt, zlib (nén), DNS lookup. Pool mặc định = 4 → chỉ 4 tác vụ loại này chạy song song, phần còn lại xếp hàng.

  • UV_THREADPOOL_SIZE (env, đặt trước khi Node khởi động) tăng số thread pool.

  • I/O-bound: phần lớn thời gian chờ (DB, network, disk) → Node xử lý cực tốt, nhiều nghìn connection.

  • CPU-bound: phần lớn thời gian tính (hash, resize ảnh, ML) → chặn thread → cần worker_threads/tách service.

Ví dụ.

bashReady
UV_THREADPOOL_SIZE=16 node server.js   # nhiều thao tác bcrypt/fs song song hơn
Kết quả mong đợi: thread pool bốn thread

Chưa chạy: suy ra từ kích thước pool mặc định và cơ chế xếp hàng; số mili giây tuỳ máy.

typescriptReady
// pool.mjsimport { pbkdf2 } from 'node:crypto'const start = Date.now()for (let i = 1; i <= 8; i++) {  pbkdf2('password', 'salt', 300_000, 64, 'sha512', () => {    console.log(`#${i} xong sau ${Date.now() - start} ms`)  })}
bashReady
node pool.mjs                          # pool mặc định 4UV_THREADPOOL_SIZE=8 node pool.mjs     # đặt trước khi process khởi động

Mong đợi với pool 4: bốn dòng đầu xong gần như cùng lúc (cỡ T ms), bốn dòng sau xong cỡ 2T (xếp hàng chờ thread rảnh). Với UV_THREADPOOL_SIZE=8 và máy có từ 8 core trở lên, cả tám xong quanh T; máy ít core hơn thì các tác vụ tranh CPU và thời gian không giảm tương ứng. Cùng lúc đó event loop vẫn rảnh (một setInterval log đều đặn không bị trễ), đó là điều khiến lỗi này khó thấy. Bài học: đặt UV_THREADPOOL_SIZE khi khởi động, không đặt trong code sau khi process đã chạy.

Pitfall.

  • Login endpoint hash bcrypt (CPU + dùng thread pool). 4 thread → chỉ 4 login đồng thời, request thứ 5 chờ, mà event loop vẫn có vẻ rảnh → latency tăng khó hiểu. Fix: tăng pool size / offload.
  • Tưởng tăng UV_THREADPOOL_SIZE giúp mọi thứ nhanh → không giúp network I/O (vốn không dùng pool), và nếu > số CPU core thì tranh CPU.

7. Blocking vs non-blocking#

Định nghĩa ngắn. Blocking call giữ thread lại chờ kết quả trước khi làm gì khác; non-blocking trả về ngay và báo kết quả qua callback/Promise sau.

Tại sao quan trọng. Trên một event loop dùng chung, một call blocking = tất cả request đóng băng trong thời gian đó. Đây là lỗi FE-chuyển-sang-BE hay mắc nhất.

Cơ chế.

  • fs.readFileSync(path) — chặn event loop tới khi đọc xong file → không request nào khác được phục vụ.
  • await fs.promises.readFile(path) — non-blocking; libuv đọc trên thread pool, event loop tiếp tục phục vụ request khác, callback resume khi xong.

Ví dụ — 1 vòng lặp/1 sync call làm treo request khác.

typescriptReady
import { readFileSync } from "node:fs";import { readFile } from "node:fs/promises";// SAI: blocking: mọi request khác chờ đọc file xongapp.get("/bad", (req, res) => res.send(readFileSync("./big.json", "utf8")));// ĐÚNG: non-blocking: các request khác vẫn chạy trong lúc I/Oapp.get("/good", async (req, res) => res.send(await readFile("./big.json", "utf8")));

Pitfall.

  • Dùng *Sync "cho nhanh, cho tiện" trong request handler — chạy local 1 user thấy ổn, lên prod tải cao thì throughput sụp.
  • JSON.parse/JSON.stringify payload khổng lồ là blocking đồng bộ (không có bản async) → giới hạn size body, hoặc stream parse.
  • *Sync chỉ nên dùng ở startup (load config một lần), không dùng trong hot path.

8. Buffer & Stream#

Định nghĩa ngắn. Buffer là vùng bộ nhớ nhị phân cố định (mảng byte) — cách Node biểu diễn binary data. Stream là abstraction xử lý dữ liệu theo từng khối tuần tự thay vì nạp hết vào RAM.

Tại sao quan trọng. Backend xử lý file lớn, upload, response khổng lồ, proxy. Đọc nguyên file 2GB vào RAM (Buffer) = OOM crash. Stream cho phép xử lý dữ liệu lớn với RAM nhỏ và trả bytes cho client ngay khi có.

Cơ chế.

  • Buffer: dữ liệu nhị phân trọn vẹn trong RAM. Dùng cho dữ liệu nhỏ/vừa (đọc file config, encode/decode).
  • Stream: Readable (nguồn), Writable (đích), Transform (biến đổi giữa chừng, vd gzip). Dữ liệu chảy theo chunk.
  • Backpressure: khi đích ghi chậm hơn nguồn đọc, stream báo "chậm lại" để RAM không phình. pipe()/pipeline() xử lý backpressure tự động; nếu tự viết .on("data") phải tự quản (write() trả false → dừng đọc, chờ sự kiện drain).
  • pipe() nối Readable → Writable và lo backpressure + kết thúc, nhưng không chuyển lỗi: nguồn lỗi thì đích không bị đóng, và error không có handler sẽ làm crash process.
  • pipeline() (node:stream/promises) nối cả chuỗi, bắt lỗi ở mọi mắt xích và destroy tất cả stream còn lại khi một cái lỗi hoặc đích đóng sớm (client ngắt).

Ví dụ — stream file thay vì nạp hết.

typescriptReady
import { createReadStream } from "node:fs";import { pipeline } from "node:stream/promises";// ĐÚNG: chảy từng chunk, RAM thấp, client nhận sớm; pipeline lo backpressure + lỗi + dọn streamconst src = createReadStream("./video.mp4");src.once("error", (e) => {          // lỗi mở file (ENOENT...) xảy ra trước khi gửi byte nào  if (!res.headersSent) res.writeHead((e as NodeJS.ErrnoException).code === "ENOENT" ? 404 : 500).end();});src.once("open", async () => {  res.writeHead(200, { "Content-Type": "video/mp4" });  try {    await pipeline(src, res);       // client ngắt giữa chừng → pipeline destroy src, đóng file descriptor  } catch (err) {    logger.warn({ err }, "stream aborted"); // lỗi giữa chừng: header đã gửi, chỉ còn cách đóng socket  }});// SAI: pipe(): file không tồn tại → "Unhandled 'error' event" làm crash cả process// createReadStream("./video.mp4").pipe(res);// SAI: nạp cả file vào RAM rồi mới gửi → OOM với file lớn, nhiều user// res.end(await readFile("./video.mp4"));

Đã chạy thật trên Node 24: với pipe(), request file không tồn tại làm process thoát với Unhandled 'error' event (ENOENT); với đoạn trên server trả 404 và request kế tiếp vẫn 200. Cho client ngắt kết nối giữa chừng 20 lần trên file 64 MB: pipe() để lại 20 file descriptor còn mở, pipeline() để lại 0.

Pitfall.

  • Quên backpressure khi tự làm (readable.on("data", d => writable.write(d))) — nếu write() trả false mà vẫn đọc tiếp → buffer nội bộ phình → RAM nổ. Dùng pipe/pipeline.
  • Không handle error trên stream → uncaught exception làm crash process, và pipe() còn bỏ rơi file descriptor khi client ngắt. Dùng pipeline (bản promise ở node:stream/promises) để bắt lỗi mọi mắt xích và dọn stream.
  • Buffer + string encoding: cắt Buffer giữa một ký tự multi-byte (UTF-8) rồi toString → ký tự vỡ. Dùng StringDecoder khi cần.

9. process.env & config, NODE_ENV#

Định nghĩa ngắn. process.env là object chứa environment variables — cách chuẩn để đưa config (secret, URL DB, port) vào app từ bên ngoài code.

Tại sao quan trọng. 12-factor: config tách khỏi code. Cùng một artifact chạy dev/staging/prod chỉ khác env. Secret (DB password, API key) không được hardcode/commit — phải qua env.

Cơ chế.

  • Mọi giá trị process.env.X là string (hoặc undefined). Phải tự parse số/boolean.
  • NODE_ENV quy ước: development | production | test. Nhiều lib (Express, React) bật tối ưu/tắt log chi tiết khi production.
  • Load từ file .env (vd package dotenv) ở dev; prod thường inject qua orchestrator (Docker/K8s/PaaS).

Ví dụ — validate config sớm khi boot.

typescriptReady
const PORT = Number(process.env.PORT ?? 3000);       // env là string → ép Numberconst isProd = process.env.NODE_ENV === "production";if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL missing"); // fail fast

Pitfall.

  • process.env.PORT === 3000 → luôn false (string vs number).
  • Quên set NODE_ENV=production trên prod → chạy chậm, log verbose, lộ stack trace.
  • Commit .env chứa secret lên git → rò rỉ. .env phải nằm trong .gitignore.
  • Config đọc rải rác process.env.X khắp code → nên gom vào một module config.ts validate một lần.

10. worker_threads vs cluster vs child_process#

Định nghĩa ngắn. Ba cách chạy code ngoài main thread/process:

  • worker_threads — nhiều thread JS trong cùng process, share memory được (SharedArrayBuffer).
  • cluster — fork nhiều process Node giống nhau, cùng listen 1 port (LB nội bộ) để dùng nhiều CPU core.
  • child_process — spawn process khác (Node hoặc chương trình bất kỳ: ffmpeg, python) và giao tiếp qua stdio/IPC.

Tại sao quan trọng. Node 1 thread JS → không tự dùng hết nhiều core, và CPU-bound làm nghẽn event loop. Ba công cụ này giải bài toán "scale multi-core" và "offload CPU nặng".

Cơ chế / khi nào dùng.

  • CPU-bound trong app (hash, parse lớn, image/video, tính toán): worker_threads. Đẩy việc nặng sang worker để main thread rảnh phục vụ request.
  • Tận dụng nhiều core cho throughput HTTP: cluster (hoặc chạy N instance sau reverse proxy / PM2 / K8s). Mỗi process 1 event loop độc lập.
  • Chạy chương trình ngoài / cô lập crash: child_process (spawn cho output stream lớn, exec cho lệnh ngắn). Vd gọi ffmpeg.

Ví dụ — offload CPU sang worker.

typescriptReady
import { Worker } from "node:worker_threads";app.get("/report", (req, res) => {  const w = new Worker("./heavy-worker.js", { workerData: req.query });  w.on("message", (out) => res.json(out));  // main thread không bị chặn  w.on("error", (e) => res.status(500).json({ error: String(e) }));});

Ví dụ trên thiếu hai thứ: file worker và việc không tạo thread mới cho mỗi request. File worker nhận việc qua message và trả kết quả bằng postMessage:

typescriptReady
// fib-worker.ts: file chạy trong worker thread, nhận việc qua messageimport { parentPort } from 'node:worker_threads'const fib = (n: number): number => (n < 2 ? n : fib(n - 1) + fib(n - 2))parentPort!.on('message', ({ id, n }: { id: number; n: number }) => {  parentPort!.postMessage({ id, result: fib(n) })})
Code: pool worker tối giản, kết quả đã chạy và bản dùng piscina

Pool cố định N worker; hết worker rảnh thì việc xếp hàng, worker chết thì báo lỗi cho đúng tác vụ và được thay mới.

typescriptReady
// mini-pool.tsimport { Worker } from 'node:worker_threads'type Task = { n: number; resolve: (v: number) => void; reject: (e: Error) => void }export class FibPool {  private readonly idle: Worker[] = []  private readonly queue: Task[] = []  private readonly running = new Map<Worker, { id: number; task: Task }>()  private nextId = 0  constructor(size: number) {    for (let i = 0; i < size; i++) this.idle.push(this.spawn())  }  private spawn(): Worker {    const worker = new Worker(new URL('./fib-worker.ts', import.meta.url))    worker.on('message', ({ result }: { id: number; result: number }) => {      this.running.get(worker)!.task.resolve(result)      this.running.delete(worker)      this.idle.push(worker)      this.drain()    })    worker.on('error', (err) => {      const job = this.running.get(worker)      this.running.delete(worker)      job?.task.reject(err) // worker chết: báo lỗi cho đúng tác vụ đang chạy      this.idle.push(this.spawn()) // thay worker mới để pool không teo dần      this.drain()    })    return worker  }  private drain(): void {    while (this.idle.length > 0 && this.queue.length > 0) {      const worker = this.idle.pop()!      const task = this.queue.shift()!      const id = this.nextId++      this.running.set(worker, { id, task })      worker.postMessage({ id, n: task.n })    }  }  run(n: number): Promise<number> {    return new Promise((resolve, reject) => {      this.queue.push({ n, resolve, reject })      this.drain()    })  }  async close(): Promise<void> {    await Promise.all([...this.idle, ...this.running.keys()].map((w) => w.terminate()))  }}

Kiểm tra bằng một server có /fib (đẩy fib(40) vào pool 4 worker) và /ping: gửi 8 request /fib cùng lúc, sau 100 ms gọi /ping.

textReady
 ping khi 8 việc nặng đang chạy: 3 ms 8 việc xong sau (ms): 742 748 758 761 | 1374 1394 1394 1418

Đã chạy trên Node 24.21, máy 12 core: /ping vẫn trả trong vài ms khi tám việc nặng đang chạy (main thread rảnh); bốn việc đầu xong cùng lúc (cỡ 750 ms), bốn việc sau phải chờ worker rảnh nên xong sau cỡ 1400 ms, đúng hình dạng "pool bốn thread, hàng đợi". tsc --strict sạch. Số ms tuỳ máy.

Bản dùng piscina (npm i piscina, đã chạy với 5.3.2), không cần tự viết pool:

typescriptReady
// fib-task.mjsconst fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2))export default (n) => fib(n)// main.mjsimport Piscina from 'piscina'const pool = new Piscina({ filename: new URL('./fib-task.mjs', import.meta.url).href, maxThreads: 4 })const results = await Promise.all(Array.from({ length: 8 }, () => pool.run(40)))await pool.destroy()

Đã chạy: tám việc fib(40) xong sau khoảng 1,3 giây với maxThreads: 4 (cùng hình dạng như pool tự viết). Dùng piscina trong dự án thật: nó lo hàng đợi, thread idle, huỷ việc và thông số maxQueue; pool tự viết ở trên chỉ để hiểu cơ chế. Giới hạn của pool tự viết (suy ra từ đọc code, chưa chạy kịch bản lỗi): worker đang rảnh mà phát error vẫn nằm trong idle dù đã có worker thay thế, và worker thoát bằng exit không kèm error làm việc đang chạy treo; bản dùng thật cần lọc worker ra khỏi idle và nghe exit.

Pitfall.

  • Dùng cluster để giải CPU-bound trong 1 request → không giúp: request nặng vẫn chặn cả 1 worker-process của nó. cluster chỉ tăng số request song song, không tăng tốc 1 request.
  • Tạo new Worker mỗi request → chi phí spawn cao → nên dùng worker pool (piscina).
  • child_process với input là user data không sanitize → command injection. Dùng spawn với mảng args (không qua shell), tránh exec(\cmd ${userInput}`)`.

11. Async mastery#

Định nghĩa ngắn. Điều phối nhiều Promise và xử lý lỗi async đúng cách: chạy song song vs tuần tự, gộp kết quả, và bắt mọi rejection.

Tại sao quan trọng. Backend thường gọi nhiều I/O (DB + cache + service ngoài). Làm tuần tự khi có thể song song = latency cộng dồn. Bỏ lọt một rejection = crash process hoặc request treo.

Cơ chế — các combinator:

  • Promise.all([...]) — chạy song song, chờ tất cả; fail-fast: một cái reject → cả all reject ngay (các cái khác vẫn chạy tiếp nhưng kết quả bị bỏ).
  • Promise.allSettled([...]) — chờ tất cả xong, trả mảng {status, value|reason} — không fail-fast; dùng khi muốn biết cái nào ok/lỗi.
  • Promise.race([...]) — resolve/reject theo cái xong đầu tiên (kể cả lỗi). Dùng cho timeout.
  • Promise.any([...]) — resolve theo cái thành công đầu tiên; chỉ reject (AggregateError) khi tất cả fail. Dùng cho "thử nhiều nguồn, lấy cái nào nhanh".

Error propagation trong async/await. await "unwrap" Promise; nếu Promise reject, await throw → bắt bằng try/catch. Lỗi lan lên caller như exception đồng bộ.

Ví dụ — sequential vs parallel & timeout.

typescriptReady
// SAI: tuần tự: tổng thời gian = a + b + cconst u = await getUser(id);const o = await getOrders(id);const p = await getPrefs(id);// ĐÚNG: song song: tổng thời gian = max(a,b,c)const [u2, o2, p2] = await Promise.all([getUser(id), getOrders(id), getPrefs(id)]);// timeout bằng raceconst withTimeout = <T>(pr: Promise<T>, ms: number) =>  Promise.race([pr, new Promise<never>((_, rej) => setTimeout(() => rej(new Error("timeout")), ms))]);
Kết quả mong đợi: bốn combinator và timeout

Chưa chạy: suy ra từ ngữ nghĩa của Promise.

typescriptReady
const wait = <T>(ms: number, v: T, fail = false) =>  new Promise<T>((res, rej) => setTimeout(() => (fail ? rej(new Error(String(v))) : res(v)), ms))const a = () => wait(100, 'A')const b = () => wait(300, 'B')const c = () => wait(50, 'C', true) // lỗi sau 50 msconsole.time('all');        await Promise.all([a(), b()]).then(console.log); console.timeEnd('all')// ['A', 'B'] sau ~300 ms (= max, không phải tổng 400 ms)await Promise.all([a(), b(), c()]).catch((e) => console.log('all lỗi:', e.message))// 'all lỗi: C' sau ~50 ms; A và B vẫn chạy tiếp nhưng kết quả bị bỏconsole.log(await Promise.allSettled([a(), c()]))// [{status:'fulfilled',value:'A'}, {status:'rejected',reason:Error('C')}] sau ~100 msconsole.log(await Promise.any([c(), b(), a()]))// 'A' sau ~100 ms: bỏ qua lỗi C, lấy cái thành công đầu tiênawait Promise.any([c(), wait(60, 'D', true)]).catch((e) => console.log(e.constructor.name))// AggregateError khi tất cả đều lỗiawait withTimeout(b(), 100).catch((e) => console.log(e.message))// 'timeout' sau ~100 ms; b() vẫn chạy tiếp (race không huỷ việc thua)

Điểm cần nhớ từ kết quả: race chỉ chọn người thắng, không huỷ người thua; muốn huỷ thật thì truyền AbortSignal vào việc bên dưới (AbortSignal.timeout(ms)). Timer của withTimeout còn sống tới khi hết hạn, nên xoá bằng clearTimeout khi pr xong nếu gọi nhiều lần. Muốn giới hạn concurrency khi Promise.all(items.map(...)) có hàng nghìn phần tử thì chia lô hoặc dùng p-limit (xem câu hỏi mở cuối bài).

Pitfall.

  • Sequential await pitfall: await trong for khi các vòng độc lập → cộng dồn latency. Dùng Promise.all(items.map(...)) (nhưng coi chừng bắn 10k request song song → cần giới hạn concurrency).
  • Quên await (floating promise): db.save(x) không await → lỗi thành unhandledRejection, và code chạy tiếp trước khi save xong.
  • unhandledRejection: Promise reject không ai .catch → Node (mặc định) sẽ crash process. Luôn try/catch quanh await, hoặc .catch cho fire-and-forget; và đặt handler cuối cùng:
typescriptReady
process.on("unhandledRejection", (e) => { logger.error(e); /* rồi graceful shutdown */ });
  • Promise.all fail-fast làm 1 lỗi nhỏ giết cả batch — khi muốn "cố hết sức" dùng allSettled.

11a. EventEmitter#

Định nghĩa ngắn. EventEmitter (node:events) là bus sự kiện đồng bộ trong một process; http.Server, Stream, process, Worker đều là emitter.

Tại sao quan trọng. Nếu không biết ba luật dưới đây, bạn sẽ gặp process chết vì error không ai nghe, listener chạy sai thứ tự so với kỳ vọng, hoặc rò rỉ bộ nhớ do on trong vòng lặp.

Cơ chế.

  • emit() gọi các listener đồng bộ, theo thứ tự đăng ký, rồi mới trả về. Listener không hề "chạy nền".
  • Event tên error không có listener thì emit('error', err) ném err; không bắt thì process chết.
  • once tự gỡ sau lần đầu; events.once(emitter, 'x') trả Promise để await.
  • Quá 10 listener cho cùng một event thì Node in MaxListenersExceededWarning (gợi ý rò rỉ).
typescriptReady
import { EventEmitter, once } from 'node:events'const bus = new EventEmitter()bus.on('order', (id: string) => console.log('listener 1', id))bus.on('order', (id: string) => console.log('listener 2', id))console.log('trước emit')bus.emit('order', 'A1')console.log('sau emit')try {  bus.emit('error', new Error('hỏng')) // không có listener 'error'} catch (err) {  console.log('emit error không listener ->', (err as Error).message)}setTimeout(() => bus.emit('go', 42), 10)const [value] = await once(bus, 'go') // Promise, tự gỡ listenerconsole.log('await once ->', value)const leaky = new EventEmitter()for (let i = 0; i < 11; i++) leaky.on('tick', () => {}) // listener thứ 11 -> cảnh báo
Kết quả đã chạy và cách tránh lỗi

Đã chạy trên Node 24.21, tsc --strict sạch:

textReady
trước emitlistener 1 A1listener 2 A1sau emitemit error không listener -> hỏngawait once -> 42MaxListenersExceededWarning: ... 11 tick listeners added ... (rút gọn)

Điều rút ra: hai listener nằm giữa trước emit và sau emit (đồng bộ); emit('error') ném ngay tại chỗ gọi. Luôn đăng ký emitter.on('error', ...) cho mọi stream/socket/emitter bạn tự tạo, và gỡ listener (off, hoặc dùng once) khi đối tượng sống lâu hơn handler. Muốn nhiều hơn 10 listener hợp lệ thì gọi setMaxListeners(n) thay vì bỏ qua cảnh báo.

Pitfall.

  • Listener async ném lỗi thì lỗi thành unhandledRejection, không đi vào try/catch quanh emit.
  • emit đồng bộ nên một listener chậm làm chậm cả người gọi.
  • on trong handler của request (mỗi request thêm một listener vào emitter dùng chung) là nguồn rò rỉ kinh điển.

11b. Gọi dịch vụ ngoài: fetch, AbortSignal.timeout và undici#

Định nghĩa ngắn. fetch (Node 18+, chạy trên undici) kèm AbortSignal để đặt hạn chót và để huỷ khi bên gọi bỏ đi; undici.Agent để cấu hình pool kết nối.

Tại sao quan trọng. Mục 12 đã nêu: không đặt timeout cho outbound call thì một dịch vụ chậm kéo cạn cả request pool của bạn. Promise.race ở mục 11 chỉ bỏ rơi việc thua chứ không huỷ nó; AbortSignal mới huỷ thật.

Ví dụ.

typescriptReady
async function callUpstream(path: string, timeoutMs: number, parent?: AbortSignal) {  // hủy khi hết giờ HOẶC khi bên gọi (ví dụ client của chúng ta) bỏ đi  const timeout = AbortSignal.timeout(timeoutMs)  const signal = parent ? AbortSignal.any([parent, timeout]) : timeout  try {    const res = await fetch(`http://127.0.0.1:4302${path}`, { signal })    return { ok: true as const, body: await res.text() }  } catch (err) {    const name = (err as Error).name    if (name === 'TimeoutError') return { ok: false as const, why: 'upstream quá chậm' } // 504    if (name === 'AbortError') return { ok: false as const, why: 'bên gọi đã hủy' } // không cần trả lời    throw err // ECONNREFUSED...: lỗi mạng thật -> 502  }}

Dùng signal huỷ khi client bỏ đi (tín hiệu res.on('close') kèm !res.writableFinished, xem GĐ22 mục 5) làm parent để cuộc gọi tới dịch vụ ngoài cũng dừng theo.

Kết quả đã chạy, và cấu hình undici.Agent

Đã chạy trên Node 24.21 với một upstream giả (/fast trả sau 10 ms, /slow sau 2 giây), tsc --strict sạch:

textReady
{ ok: true, body: '{"ok":true}' }          23 ms   (/fast, hạn 500 ms){ ok: false, why: 'upstream quá chậm' }   302 ms  (/slow, hạn 300 ms){ ok: false, why: 'bên gọi đã hủy' }      102 ms  (/slow, parent hủy ở 100 ms)

Hết giờ cho TimeoutError, bên gọi hủy cho AbortError: hai trường hợp cần hai cách xử lý khác nhau (trả 504, hay chỉ dừng vì không còn ai chờ).

Cấu hình pool kết nối bằng undici (đã chạy với 8.11.2; npm i undici):

typescriptReady
import { Agent, request } from 'undici'const agent = new Agent({  connections: 10, // tối đa 10 socket tới mỗi origin  keepAliveTimeout: 4_000, // nhỏ hơn keepAliveTimeout của server/LB phía kia  connect: { timeout: 3_000 }, // chờ TCP/TLS handshake  headersTimeout: 5_000, // chờ header phản hồi  bodyTimeout: 5_000, // khoảng nghỉ tối đa giữa hai chunk body})const { statusCode, body } = await request('http://127.0.0.1:4304/', { dispatcher: agent })await body.text() // PHẢI đọc hoặc bỏ body, nếu không socket không về pool

Hai mươi request tuần tự tới một server cục bộ mở ít kết nối TCP hơn rất nhiều so với 20 (đo được 2 trong hai lần chạy, trên undici 8.11.2), chứng tỏ kết nối được tái dùng. Con số chính xác (2 thay vì 1) phụ thuộc vào thời điểm socket được trả về pool so với lúc request kế tiếp bắt đầu; đừng coi nó là quy tắc cố định, và tôi chưa đào sâu cơ chế đó. Lưu ý chiều ngược lại với mục 12a: keepAliveTimeout phía client phải nhỏ hơn của server, vì bên nào đóng trước thì bên kia không bị bắt gặp ghi vào kết nối đã chết.

Pitfall.

  • fetch không có hạn chót cho cả cuộc gọi: mặc định của undici 8.11.2 (gói npm, đọc từ mã nguồn) chỉ có headersTimeout và bodyTimeout 300 giây cho phần phản hồi (timeout kết nối là một cơ chế riêng, chưa đọc kỹ). fetch đi kèm Node 24.21 dùng bản undici bundle 7.29.1 (process.versions.undici), chưa đọc mã bản đó. Vì vậy không đặt AbortSignal.timeout thì một dịch vụ treo giữ bạn tới 5 phút.
  • Quên đọc hoặc cancel() body response: socket kẹt, pool cạn.
  • Bắt mọi lỗi chung một nhánh rồi retry kể cả TimeoutError của lệnh ghi không idempotent: xem idempotency ở mục 1.

Phần C — Networking cơ bản#

12. DNS, TCP, TLS, port, reverse proxy#

Định nghĩa ngắn. Chuỗi bước để một request HTTP thực sự tới được server: phân giải tên miền → bắt tay TCP → bắt tay TLS (HTTPS) → truyền dữ liệu; và lớp reverse proxy đứng trước app.

Tại sao quan trọng. Latency, timeout, "connection refused", chứng chỉ hết hạn, cấu hình proxy — tất cả bug này BE phải debug. Hiểu chuỗi để biết lỗi nằm ở tầng nào.

Cơ chế.

  • DNS resolve: đổi api.example.com → IP (vd 52.x.x.x). Có cache (TTL) nhiều tầng: OS, resolver, ISP. Chậm/hỏng DNS = request "đứng" trước cả khi kết nối.
  • Port: một IP có 65535 port; server "listen" trên một port (HTTP 80, HTTPS 443, app dev 3000). IP:port xác định đúng process.
  • TCP 3-way handshake: SYN (client) → SYN-ACK (server) → ACK (client). Sau đó mới có kênh tin cậy, có thứ tự. Tốn 1 round-trip trước khi gửi data.
  • TLS handshake (HTTPS, tóm tắt): sau TCP, client/server trao đổi để (1) xác thực server qua certificate, (2) thỏa thuận cipher, (3) sinh session key đối xứng. Từ đó dữ liệu được mã hoá. Tốn thêm round-trip(s) (TLS 1.3 nhanh hơn, 1-RTT).
  • Reverse proxy (Nginx): đứng trước app, nhận request từ internet rồi forward vào backend. Vai trò: TLS termination (giải mã HTTPS 1 chỗ), load balancing nhiều instance, phục vụ static file, rate limit, caching, che cấu trúc nội bộ, buffering request chậm.

Ví dụ — sơ đồ đường đi.

textReady
Browser  → DNS: api.example.com → 52.10.1.5  → TCP handshake tới 52.10.1.5:443 (SYN/SYN-ACK/ACK)  → TLS handshake (verify cert, agree keys)  → HTTP request  → [Nginx :443] TLS termination + LB  →  Node app :3000 (cluster/instances)

Pitfall.

  • App Node listen 127.0.0.1 thay vì 0.0.0.0 trong container → Nginx/host không tới được → "connection refused".
  • Quên rằng reverse proxy che IP thật của client → phải đọc X-Forwarded-For (và cấu hình trust proxy) nếu cần IP/log/rate-limit đúng.
  • Certificate hết hạn / thiếu intermediate chain → browser báo lỗi TLS dù server "chạy".
  • DNS TTL cao + đổi IP → client vẫn gọi IP cũ một thời gian.
  • Không đặt timeout cho outbound call (gọi service ngoài) → DNS/TCP treo kéo cả request pool cạn kiệt.

12a. Timeout của HTTP server và kết nối keep-alive#

Định nghĩa ngắn. http.Server có bốn mốc thời gian độc lập; mỗi mốc chặn một kiểu client chậm hoặc im lặng.

Tại sao quan trọng. Mặc định của chúng quyết định server bị "slowloris" kéo sập thế nào, và quyết định có gặp lỗi 502/ECONNRESET ngẫu nhiên khi đứng sau load balancer hay không (xem sơ đồ timeout budget ở GĐ01 mục 14).

Mặc định đọc thật trên Node 24.21:

Thuộc tínhMặc địnhChặn cái gì
keepAliveTimeout5000 mskết nối rảnh giữa hai request; hết giờ server đóng
headersTimeout60000 msthời gian nhận ĐỦ header; hết giờ trả 408 rồi đóng
requestTimeout300000 msthời gian nhận đủ cả request (header + body)
timeout0 (tắt)socket rảnh (cũ; 0 là không giới hạn)
connectionsCheckingInterval30000 mschu kỳ quét hai mốc trên
keepAliveTimeoutBuffer1000 mscộng thêm vào keepAliveTimeout (từ Node 24.6.0)

Vì connectionsCheckingInterval mặc định 30 giây, một request quá hạn headersTimeout có thể bị đóng trễ tới 30 giây; muốn thấy hiệu ứng nhanh khi thử thì giảm nó (truyền qua option của createServer; thuộc tính này không có trong kiểu của @types/node).

typescriptReady
const server = createServer({ connectionsCheckingInterval: 100 }, handler)server.headersTimeout = 500 // mặc định 60000// client gửi dở header rồi im lặng ("slowloris" thu nhỏ):socket.write('GET / HTTP/1.1\r\nHost: x\r\n') // thiếu dòng trống cuối header
Kết quả đã chạy: 408, và cuộc đua keep-alive với load balancer

Đã chạy trên Node 24.21. Thí nghiệm slowloris thu nhỏ:

textReady
505 ms: HTTP/1.1 408 Request Timeout505 ms: server đóng kết nối

Cuộc đua keep-alive. Quy tắc: timeout rảnh của server phải lớn hơn của LB phía trước. Nếu nhỏ hơn, server có thể đóng kết nối đúng lúc LB đang dùng lại nó: LB ghi request vào socket mà FIN của server còn trên đường đi, và người dùng thấy 502. Để thấy cơ chế trần trụi, tôi đặt keepAliveTimeout 200 ms và keepAliveTimeoutBuffer: 0, rồi cho một "LB" (client TCP thô, không đọc gợi ý Keep-Alive) dùng lại kết nối sau 200 ms, 100 lần mỗi lượt. Mã đầy đủ ở thí nghiệm 9 của GĐ01; một lượt cho:

textReady
ECONNRESET 15, đóng giữa chừng 13, ok 28, thấy FIN trước 44

Ba lượt chạy cho 11, 33 và 28 lần lỗi trên 100 (số lần dao động theo thời điểm). Với keepAliveTimeoutBuffer mặc định 1000 ms và LB vẫn dùng lại sau 200 ms thì được 100 trên 100 ok, vì 200 ms còn xa mốc đóng thật 1200 ms (LB thô này không đọc gợi ý Keep-Alive). Buffer tồn tại để client có đọc gợi ý Keep-Alive: timeout (như http.Agent) rời đi trước khi server đóng; thí nghiệm này không đo điều đó. Buffer không xoá cuộc đua mà dời mốc đóng thật thành keepAliveTimeout + buffer; LB dùng lại đúng mốc đó thì lỗi quay lại (số liệu ở GĐ01). Đo thêm: keepAliveTimeout = 200 thực tế đóng kết nối rảnh sau khoảng 1201 ms (200 + buffer 1000: socket timeout bằng keepAliveTimeout + keepAliveTimeoutBuffer), đúng như bảng trên.

Quy tắc áp dụng: ALB có idle timeout 60 giây nên đặt server.keepAliveTimeout cao hơn 60 giây (65 giây, xem GĐ16). Còn client gọi ra ngoài thì ngược lại: keepAliveTimeout của client nhỏ hơn của server (mục 11b).

Pitfall.

  • Để headersTimeout/requestTimeout mặc định rồi mở thẳng ra Internet: một client chậm giữ socket tới 5 phút. Đặt phía trước một proxy có timeout riêng.
  • keepAliveTimeout của Node (5 giây) nhỏ hơn idle timeout của ALB (60 giây) là nguyên nhân kinh điển của 502 rải rác.
  • Đọc server.connectionsCheckingInterval bằng TypeScript báo lỗi vì @types/node chưa khai báo thuộc tính này; đặt giá trị qua option của createServer.

12b. localhost, IPv4 và IPv6#

Định nghĩa ngắn. localhost không phải một địa chỉ mà là một tên có thể phân giải ra 127.0.0.1 (IPv4) lẫn ::1 (IPv6); server bind vào địa chỉ nào thì chỉ nhận kết nối tới địa chỉ đó.

Tại sao quan trọng. "Chạy trên máy tôi nhưng curl báo connection refused" thường là lệch họ địa chỉ, nhất là trong container và health check.

Đã đo trên macOS với Node 24.21: dns.lookup('localhost', { all: true }) trả ::1 trước rồi 127.0.0.1. Bảng dưới là server bind một địa chỉ (hàng) và client dùng một URL (cột):

Server bind127.0.0.1[::1]localhost
127.0.0.1okECONNREFUSEDok
::1ECONNREFUSEDokok
localhost (ra ::1)ECONNREFUSEDokok
0.0.0.0okECONNREFUSEDok
::okokok

Cột localhost luôn ok vì fetch của Node 24 thử lần lượt cả hai địa chỉ (happy eyeballs, autoSelectFamily bật mặc định từ Node 20); hàng localhost bind chỉ nghe địa chỉ đầu tiên (::1). Kết luận thực dụng: nói chuyện giữa các process cục bộ thì dùng một địa chỉ cụ thể ở cả hai phía, và trong container bind 0.0.0.0 hoặc :: (xem pitfall ở mục 12). Đây là kết quả của macOS; Linux và container phụ thuộc /etc/hosts và resolver nên có thể khác, chưa kiểm.


Thực hành & Done khi#

Bài thực hành — HTTP server thuần bằng http module#

Yêu cầu: routing tay, parse JSON body, trả JSON đúng status/header. Không dùng framework.

typescriptReady
import { createServer, IncomingMessage } from "node:http";// đọc & parse body (stream các chunk → gộp BYTE → decode UTF-8 → JSON.parse)const MAX_BODY = 1e6; // byteconst httpError = (status: number, message: string) => Object.assign(new Error(message), { status });function readJson(req: IncomingMessage): Promise<any> {  return new Promise((resolve, reject) => {    const chunks: Buffer[] = [];    let size = 0;    req.on("data", (c: Buffer) => {      size += c.length;                                    // đếm byte, không phải số ký tự      if (size > MAX_BODY) {                               // chặn body khổng lồ        chunks.length = 0;                                 // thả bộ nhớ đã gom        req.pause();                                       // ngừng đọc thêm        return reject(httpError(413, "payload too large"));      }      chunks.push(c);    });    req.on("end", () => {      // ghép Buffer rồi mới decode: một ký tự UTF-8 (tiếng Việt, emoji) có thể bị cắt giữa 2 chunk      const raw = Buffer.concat(chunks).toString("utf8");      if (!raw) return resolve({});      try { resolve(JSON.parse(raw)); } catch { reject(httpError(400, "invalid json")); }    });    req.on("error", reject);  });}const users: { id: number; name: string }[] = [];let nextId = 1;const server = createServer(async (req, res) => {  const json = (code: number, body: unknown) => {    res.writeHead(code, { "Content-Type": "application/json" });    res.end(JSON.stringify(body));  };  try {    const { method = "GET", url = "/" } = req;    if (method === "GET" && url === "/users") return json(200, users);    if (method === "POST" && url === "/users") {      const type = req.headers["content-type"]?.split(";")[0]?.trim().toLowerCase();      if (type !== "application/json") return json(415, { error: "content-type must be application/json" });      const body = await readJson(req);      if (!body.name) return json(400, { error: "name required" });   // validation      const u = { id: nextId++, name: String(body.name) };      users.push(u);      res.setHeader("Location", `/users/${u.id}`);      return json(201, u);                                            // 201 Created    }    const m = url.match(/^\/users\/(\d+)$/);    if (m && method === "GET") {      const u = users.find((x) => x.id === Number(m[1]));      return u ? json(200, u) : json(404, { error: "not found" });    // 404    }    if (m && method === "DELETE") {      const i = users.findIndex((x) => x.id === Number(m[1]));      if (i === -1) return json(404, { error: "not found" });      users.splice(i, 1);      res.writeHead(204).end();                                       // 204 No Content      return;    }    const allow = url === "/users" ? "GET, POST" : m ? "GET, DELETE" : undefined;    if (allow) {                                                      // đường dẫn có thật nhưng sai method      res.setHeader("Allow", allow);      return json(405, { error: "method not allowed" });              // 405 + Allow    }    return json(404, { error: "route not found" });  } catch (e) {    const status = (e as { status?: number }).status ?? 500;    if (status === 413) {                                             // body quá lớn: trả lời rồi đóng kết nối      res.setHeader("Connection", "close");                           // đừng chờ nốt phần body còn lại      res.once("finish", () => req.destroy());    }    return json(status, { error: (e as Error).message });          // 400 / 413 / 500  }});server.listen(Number(process.env.PORT ?? 3000), "0.0.0.0");

Vì sao không viết raw += chunk: += gọi toString() trên từng Buffer, nên chunk nào kết thúc giữa một ký tự nhiều byte sẽ sinh ký tự lỗi U+FFFD. Đã chạy thật trên Node 24: gửi {"name":"Nguyễn Thị Ánh 👨‍👩‍👧"} cắt trước mọi byte tiếp nối UTF-8 (24 chunk): bản += trả Nguy���n Th��� ..., bản ghép Buffer trả đúng chuỗi gốc. (Cách khác: req.setEncoding("utf8") để Node tự dùng StringDecoder; khi đó size đếm ký tự chứ không phải byte, nên nếu giữ cách này hãy đếm bằng Buffer.byteLength(chunk).)

Body quá giới hạn phải ra 413 (không phải 500, và không phải 400 vì JSON không hề sai), rồi đóng kết nối thay vì đọc nốt phần còn lại. Đã chạy thật: head -c 2000000 /dev/zero | tr '\0' a | curl -X POST localhost:3000/users -H 'Content-Type: application/json' --data-binary @- trả 500 với bản cũ, 413 với bản trên; JSON hỏng vẫn 400 và request hợp lệ ngay sau đó vẫn 201.

Hai mã nữa của bài này: body không phải JSON thì 415 (client gửi sai Content-Type, không phải JSON hỏng nên không phải 400), và đường dẫn có thật nhưng sai method thì 405 kèm header Allow (không phải 404).

Test nhanh:

bashReady
curl -s localhost:3000/userscurl -s -XPOST localhost:3000/users -H 'Content-Type: application/json' -d '{"name":"An"}'curl -s -XDELETE localhost:3000/users/1 -i   # thấy 204
Lời giải và cách kiểm tra: HTTP server thuần

Tự làm trước, rồi mở. Đoạn code ở trên chính là lời giải tham chiếu của bài; khối này cho kịch bản kiểm tra và kết quả mong đợi. Đã chạy lại toàn bộ khối này trên Node 24.21 (server tạm, cổng riêng): mọi dòng ở "Kết quả mong đợi" khớp, kể cả 415, 405 và test UTF-8 (curl gửi Expect: 100-continue cho body lớn nên -i in thêm 100 Continue trước 413).

Hướng làm. Bốn việc theo thứ tự: (1) đọc body bằng cách gom Buffer, đếm byte, ghép rồi mới decode; (2) route bằng method + url; (3) mỗi nhánh trả đúng status (201 + Location, 204 không body, 404, 400, 413); (4) mọi lỗi đi qua một catch chung. Gọi từng case:

bashReady
curl -i -s -XPOST localhost:3000/users -H 'Content-Type: application/json' -d '{"name":"An"}'   # tạocurl -i -s localhost:3000/users/1                                # đọccurl -i -s -XDELETE localhost:3000/users/1                       # xoácurl -i -s -XDELETE localhost:3000/users/1                       # xoá lạicurl -i -s -XPOST localhost:3000/users -H 'Content-Type: application/json' -d '{bad'   # JSON hỏngcurl -i -s -XPOST localhost:3000/users -H 'Content-Type: application/json' -d '{}'     # thiếu namecurl -i -s -XPOST localhost:3000/users -d '{"name":"An"}'        # Content-Type form (mặc định của curl -d)curl -i -s -XPUT localhost:3000/users -H 'Content-Type: application/json' -d '{}'      # sai methodcurl -i -s localhost:3000/nope                                   # route lạ

Kết quả mong đợi.

textReady
 POST {"name":"An"}   -> 201 Created, Location: /users/1, {"id":1,"name":"An"} GET /users/1         -> 200 {"id":1,"name":"An"} DELETE /users/1      -> 204 No Content (không có body) DELETE lần hai       -> 404 {"error":"not found"}   (state vẫn là "đã xoá") POST {bad            -> 400 {"error":"invalid json"} POST {}              -> 400 {"error":"name required"} POST Content-Type form   -> 415 {"error":"content-type must be ..."} PUT /users           -> 405, Allow: GET, POST GET /nope            -> 404 {"error":"route not found"} body > 1 MB          -> 413 {"error":"payload too large"}, Connection: close

Test body lớn: head -c 2000000 /dev/zero | tr '\0' a | curl -i -XPOST localhost:3000/users -H 'Content-Type: application/json' --data-binary @-. Server đóng kết nối sớm nên tuỳ phiên bản curl có thể in thêm một cảnh báo lỗi gửi; điều cần kiểm là mã 413 và request kế tiếp vẫn nhận 201.

Test UTF-8 bị cắt giữa chunk (gửi từng byte để ép cắt giữa ký tự):

typescriptReady
import net from 'node:net'const body = Buffer.from(JSON.stringify({ name: 'Nguyễn Thị Ánh' }))const socket = net.connect(3000, '127.0.0.1')socket.setNoDelay(true) // không gộp các lần ghi nhỏsocket.write(`POST /users HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`)let i = 0const timer = setInterval(() => {  if (i < body.length) socket.write(body.subarray(i, ++i))  else clearInterval(timer)}, 5)socket.on('data', (chunk) => process.stdout.write(chunk))

Mong đợi: body trả về chứa đúng "name":"Nguyễn Thị Ánh". Thay phần decode bằng raw += chunk thì xuất hiện ký tự U+FFFD ở chỗ có dấu (nếu mạng cắt đúng giữa ký tự, điều script này cố ép xảy ra).

Lỗi hay gặp. JSON.parse ném lỗi thì trả 500 thay vì 400 (quên bắt riêng); sai method trả 404 thay vì 405 + Allow; không kiểm Content-Type nên nhận form-encoded rồi báo "invalid json"; đếm độ dài body bằng số ký tự thay vì byte; gọi reject rồi vẫn push thêm chunk; trả 200 kèm {error}; không đặt Content-Type cho response lỗi; quên listen trên 0.0.0.0 khi chạy trong container.

Tiêu chí hoàn thành GĐ03 (Done khi bạn tự tin làm được)#

  • Chọn đúng method + status code cho mọi case CRUD (200/201/204/400/401/403/404/409/422/429/500) và giải thích được vì sao.

    Đáp án

    Tạo: POST trả 201 + Location; đọc: GET 200; thay toàn bộ: PUT 200 hoặc 204; sửa một phần: PATCH 200; xoá: DELETE 204. Lỗi: 400 request hỏng cú pháp, 422 đúng cú pháp nhưng vi phạm rule, 401 chưa xác thực, 403 đủ danh tính nhưng thiếu quyền, 404 không tồn tại (hoặc cố ý giấu), 409 xung đột state, 429 rate limit kèm Retry-After, 500 bug server. Sai thường gặp: luôn trả 200 kèm {error}; nhầm 401 với 403. Xem GĐ03 mục 1 và 2.

  • Giải thích idempotency và thiết kế được POST an toàn với retry (idempotency key).

    Đáp án

    Idempotent: gọi N lần ra cùng state cuối (GET, PUT, DELETE có; POST không). Thiết kế: client gửi Idempotency-Key, server giành khoá bằng một câu INSERT ... ON CONFLICT (không "tra rồi mới ghi"), PENDING rồi DONE kèm kết quả, khoá hết hạn để process chết không kẹt mãi, và truyền key sang nhà cung cấp. Tự kiểm: cùng key hai lần chỉ một lần tác dụng phụ; cùng key khác body là 422. Xem mục 1 (có sơ đồ) và GĐ10 mục 3.

  • Set/đọc đúng các header cốt lõi; hiểu ETag/304 và chunked vs Content-Length.

    Đáp án

    Content-Type là kiểu body đang gửi (server chọn parser theo nó), Accept là kiểu client muốn nhận. ETag + If-None-Match cho 304 không body. Content-Length báo trước độ dài; Transfer-Encoding: chunked dùng khi stream không biết trước độ dài; không dùng cả hai. Dữ liệu riêng tư dùng Cache-Control: private hoặc no-store, không public. Xem mục 3.

  • Tự cấu hình CORS cho một FE origin, handle preflight OPTIONS, biết cạm bẫy * + credentials.

    Đáp án

    Server trả Access-Control-Allow-Origin đúng origin, trả preflight OPTIONS (204) với Allow-Methods và Allow-Headers; với cookie/credentials phải echo origin cụ thể chứ không dùng * cùng Allow-Credentials: true. Tự kiểm: curl -X OPTIONS với header Origin và Access-Control-Request-* (khối "Kết quả mong đợi: tự kiểm preflight"). CORS bảo vệ người dùng browser, không phải server. Xem mục 4.

  • Vẽ được event loop (phase + micro/macrotask), dự đoán đúng thứ tự setTimeout vs Promise, và giải thích vì sao CPU-bound treo server.

    Đáp án

    Vẽ năm phase (timers, pending, poll, check, close) và nhớ rằng nextTick và microtask được flush sau mỗi callback. Ví dụ: sync, rồi Promise.then, rồi setTimeout(0). CPU nặng đồng bộ chiếm duy nhất một thread JS nên mọi request khác, kể cả health check, phải chờ. Xem mục 5 (có sơ đồ).

  • Phân biệt I/O-bound vs CPU-bound, biết vai trò libuv/thread pool và khi nào tune UV_THREADPOOL_SIZE.

    Đáp án

    I/O-bound chờ mạng/DB: Node xử lý tốt vì socket dùng cơ chế non-blocking của OS. CPU-bound tính toán thật: cần worker thread hoặc tách service. Thread pool (mặc định 4) phục vụ fs, crypto (pbkdf2/bcrypt), zlib, DNS lookup; tăng UV_THREADPOOL_SIZE khi các tác vụ này xếp hàng và đặt trước khi khởi động, nhưng không giúp network I/O. Xem mục 6.

  • Không bao giờ dùng *Sync/vòng lặp nặng trong request handler; biết offload bằng worker_threads.

    Đáp án

    readFileSync, vòng lặp nặng, JSON.parse cực lớn trong handler chặn cả server; *Sync chỉ ở lúc khởi động. Offload: new Worker (tốt hơn: worker pool như piscina) cho CPU-bound trong app, child_process.spawn với mảng args cho chương trình ngoài. Cách tự kiểm: thí nghiệm CPU-bound ở GĐ01, /ping phải trả nhanh khi /cpu-worker chạy. Xem mục 7 và 10.

  • Dùng stream + pipeline (không phải pipe) cho file lớn, hiểu backpressure.

    Đáp án

    pipeline (từ node:stream/promises) nối các stream, xử lý backpressure, bắt lỗi ở mọi mắt xích và huỷ các stream còn lại khi một cái lỗi hoặc client ngắt; pipe() không chuyển lỗi và để lại file descriptor khi client ngắt (đã đo trong bài). Backpressure: đích chậm thì write() trả false, nguồn phải dừng cho tới drain. Xem mục 8.

  • Quản config qua process.env + validate khi boot; không commit secret; set NODE_ENV.

    Đáp án

    process.env luôn là string (ép kiểu, so === 3000 luôn sai), NODE_ENV đặt production ở production, secret không commit (.env trong .gitignore), validate một lần khi boot trong một module. Xem mục 9 và GĐ02 mục 10.

  • Thành thạo Promise.all/allSettled/race/any, tránh sequential-await pitfall, bắt unhandledRejection.

    Đáp án

    all: song song, fail-fast; allSettled: chờ hết, không fail-fast; race: cái xong đầu tiên (kể cả lỗi), không huỷ người thua; any: thành công đầu tiên, AggregateError khi tất cả lỗi. Tránh await trong for khi các vòng độc lập; luôn await hoặc .catch để không có unhandledRejection. Tự kiểm: chạy khối "Kết quả mong đợi: bốn combinator" và đối chiếu thời gian. Xem mục 11.

  • Mô tả được đường đi DNS → TCP → TLS → HTTP và vai trò reverse proxy (Nginx).

    Đáp án

    DNS đổi tên ra IP (có cache theo TTL), TCP bắt tay 3 bước (SYN, SYN-ACK, ACK), TLS xác thực certificate và thoả thuận khoá, rồi mới tới request HTTP. Reverse proxy (Nginx) đứng trước app để kết thúc TLS, cân bằng tải, phục vụ static, giới hạn tốc độ; phía app cần trust proxy và đọc X-Forwarded-For để biết IP thật. Xem mục 12.

  • Viết được HTTP server thuần (routing + parse body + JSON + status) không cần framework.

    Đáp án

    Routing tay, đọc body bằng cách gom Buffer rồi decode, trả JSON đúng status/header, 413 cho body quá lớn, 400 cho JSON hỏng. Tự kiểm: kịch bản curl ở khối "Lời giải và cách kiểm tra: HTTP server thuần", gồm cả case lỗi và request hợp lệ ngay sau case lỗi.

  • Gọi dịch vụ ngoài với AbortSignal.timeout, phân biệt TimeoutError với AbortError, và hiểu vì sao Promise.race chưa đủ.

    Đáp án

    AbortSignal.timeout(ms) huỷ cuộc gọi fetch khi hết giờ và ném TimeoutError (trả 504); client bên trên bỏ đi thì AbortError (không cần trả lời). AbortSignal.any([parent, timeout]) gộp cả hai. Promise.race chỉ chọn người xong trước, việc thua vẫn chạy và giữ socket. Tự kiểm: chạy upstream giả /slow và đo 300 ms cho hạn 300 ms. Xem mục 11b.

  • Nêu bốn mốc timeout của http.Server, mặc định của chúng, và quy tắc keepAliveTimeout so với idle timeout của load balancer.

    Đáp án

    keepAliveTimeout 5 s, headersTimeout 60 s, requestTimeout 300 s, timeout 0 (tắt). Quy tắc: timeout rảnh của server phải lớn hơn của LB phía trước (ALB 60 s thì Node 65 s), nếu không thỉnh thoảng LB ghi vào kết nối vừa bị server đóng và ra 502. Tự kiểm: node -e "const s=require('http').createServer();console.log(s.keepAliveTimeout,s.headersTimeout,s.requestTimeout,s.timeout)". Xem mục 12a.

  • Giải thích error không có listener của EventEmitter và nguyên nhân của MaxListenersExceededWarning.

    Đáp án

    emit('error', err) mà không có listener error thì ném err ngay tại chỗ gọi; không bắt thì process chết, nên mọi stream/socket/emitter tự tạo cần .on('error'). Cảnh báo xuất hiện khi thêm quá 10 listener cùng một event, thường do on trong vòng lặp hoặc trong handler mỗi request mà không gỡ. Xem mục 11a.


Câu hỏi mở / chưa giải quyết#

  • Chuẩn chọn 400 vs 422 khác nhau theo team/framework — thống nhất convention nội bộ trước khi code.

    Hướng trả lời hiện tại

    Chưa chốt: tách 400 (JSON hỏng, thiếu field ở tầng cú pháp) và 422 (đúng cú pháp nhưng vi phạm rule) là lựa chọn rõ ràng nhất; điều quan trọng hơn là mọi endpoint theo cùng một quy ước và cùng một dạng body lỗi.

  • Concurrency limiting (khi Promise.all map hàng nghìn item) sẽ đào sâu ở GĐ sau (p-limit / queue / batching).

    Hướng trả lời hiện tại

    Chưa chốt: chia lô hoặc dùng p-limit cho việc trong một request, và dùng queue (GĐ10) cho việc lớn.

  • Graceful shutdown (drain connection, đóng DB pool) đề cập thoáng — nên thành mục riêng ở GĐ deployment.

    Hướng trả lời hiện tại

    Chưa chốt: mẫu đầy đủ đã có ở GĐ09 mục 18 và được áp dụng trong GĐ01; phần Kubernetes ở GĐ18.