Bài này nói về networking frontend mức senior: browser nói chuyện với backend thế nào. Người phỏng vấn không thuê bạn vì “WebSocket is full-duplex”. Bạn ghi điểm khi chọn được REST hay GraphQL hay SSE hay WS, đặt auth đúng transport, và kể được loading, CORS, và abort. Vue 3 / Nuxt ($fetch, useAsyncData) trước; ràng buộc là HTTP và browser, không phải framework.
Đọc 13.1 trước (bảng so sánh và thuế socket). Rồi 13.2–13.3 cho contract và bốn state UI. 13.4 là CORS và cookie từ phía frontend. 13.5 là abort và timeout. Xem JavaScript 1.2.7 cho ví dụ search-race AbortController; topic này không lặp lại. React Query / Next dùng cùng rule HTTP.
Tóm tắt: REST (hoặc RPC-over-HTTP) là request/response. SSE đẩy một chiều. WebSocket hai chiều. GraphQL là query contract trên HTTP, không phải socket. Chọn transport cho sản phẩm, không vì mốt.
Vì sao quan trọng: Người phỏng vấn không thuê bạn vì “WebSocket is full-duplex”. Bạn ghi điểm khi từ chối socket chỉ để né REST, đặt auth trên transport browser hỗ trợ, và kể được chuyện sau khi đóng nắp laptop: reconnect với cursor, không replay toàn bộ.
- Cách hoạt động: Combo mặc định: REST (hoặc RPC-over-HTTP) cho lệnh và snapshot đầu. Thêm kênh push chỉ khi polling sẽ làm hỏng product, không phải vì “HTTP chậm”.
- HTTP/1.1 cho khoảng sáu connection mỗi origin và head-of-line blocking. HTTP/2 multiplex nhiều stream trên một TCP connection, nên
useAsyncDatasong song rẻ trên wire. Bạn vẫn trả thời gian server và kích thước payload. HTTP/2 không sửa JSON 2 MB cho table, và TCP HOL vẫn còn (HTTP/3 / QUIC là bước tiếp). - GraphQL là contract BFF (Backend for Frontend: một origin gom) ở edge, không phải lớp thay thế hổng cho mọi microservice. Nó thắng ở dashboard mà REST sẽ waterfall 12 call. Trang marketing cache trên CDN vẫn muốn REST/JSON hoặc HTML Nuxt. Persisted query trở thành bắt buộc khi graph public.
- SSE (Server-Sent Events: một HTTP GET server cứ ghi) đủ cho notification và log. WebSocket hai chiều: chat, collab, stream từ client lên server. Polling (hoặc long-polling khi SSE bị chặn) là fallback thành thật. Đừng thiết kế long-polling trước.
- Browser
new WebSocket(url)không gắn được headerAuthorization. Gửi cookie trên handshake khi FE và API chung site cha (refresh HttpOnly dùng được; CSRF phải nêu rõ). Token trên?access_token=rơi vào access log, proxy, và Referer. Ưu tiên connect, rồi message đầu{ type: "auth", token }; server từ chối mọi thứ khác đến khi auth xong, và timeout socket chưa auth. - Reconnect bằng exponential backoff kèm jitter, chặn trần delay, reset sau lần open ổn định. Heartbeat tầng application nếu nginx
proxy_read_timeoutlà 60s. Resume bằngLast-Event-ID(SSE) hoặc cursor bạn tự tạo (WS). Khi có BFF: browser → BFF → services. Một origin, cookie first-party, gom dữ liệu ở đây. SSR không giữ WS cho user.
export function connectChat(url: string, token: string, lastId: string) {
const ws = new WebSocket(url) // cookie đi theo handshake; không có header Authorization
let delayMs = 1000
let beat: ReturnType<typeof setInterval> | undefined
ws.addEventListener("open", () => {
ws.send(JSON.stringify({ type: "auth", token, lastId })) // auth bằng message đầu
delayMs = 1000
beat = setInterval(() => ws.send(JSON.stringify({ type: "ping" })), 25_000)
})
ws.addEventListener("close", () => {
clearInterval(beat)
const jitter = Math.random() * 400
setTimeout(() => connectChat(url, token, lastId), delayMs + jitter)
delayMs = Math.min(delayMs * 2, 30_000) // trần; reset sau lần open ổn định
})
return ws
}Điều gì xảy ra: Constructor không gửi được Authorization. Auth là frame JSON đầu; trước khi nó tới, server phải bỏ mọi message khác. Lúc close, delay nhân đôi kèm jitter đến 30s để reconnect hàng loạt không stampede. lastId là cursor resume: SSE thì gửi Last-Event-ID. Ping 25s giữ nginx khỏi giết timeout idle 60s.
| Transport | Hướng | Auth trên browser | Dùng khi |
|---|---|---|---|
| REST / HTTP | Request / response | Cookie hoặc header Authorization | Lệnh, snapshot đầu, GET cache được |
| GraphQL | Request / response, một round-trip | Giống REST trên HTTP | Nhiều REST resource mỗi màn; trả thuế BFF |
| SSE | Server → client stream | Cookie / header thường trên GET | Notification, log, tick một chiều |
| WebSocket | Hai chiều | Cookie handshake hoặc token message đầu | Chat, collab, stream client → server |
| Polling | Client pull | Cookie / header thường | Độ tươi chịu TTL; SSE bị chặn |
Góc nhìn senior
- HTTP/2 làm “mở thêm REST call” rẻ. Socket để tránh endpoint thường là thuế sai: bạn vẫn trả thời gian server và JSON 2 MB.
- GraphQL giúp một round-trip cho màn quá nặng và hại cache CDN. Coi nó là shape BFF. Persisted query khi graph không còn là admin nội bộ.
- Ba widget, ba socket là bug pin và sticky-session. Một kênh multiplex (hoặc SSE + REST) thắng một socket mỗi badge.
- Sticky session trên load-balancer kèm autoscaling “ngẫu nhiên” rớt user. Heartbeat tầng app; nginx
proxy_read_timeoutlà 60s trừ khi bạn đổi.
Lỗi hay gặp
- Token trên query string WebSocket. Triệu chứng: token trong access log và Referer. Nguyên nhân: constructor không có API header. Sửa: cookie handshake hoặc auth message đầu; đừng log frame.
- “Cần WS vì HTTP chậm”. Triệu chứng: stack chat cho refresh dashboard. Nguyên nhân: folklore HTTP/1. Sửa: đo payload và TTFB; stream HTTP/2 đã multiplex.
- Reconnect không jitter và replay hết. Triệu chứng: thundering herd sau blip; UI trùng hàng. Nguyên nhân:
delay = 1000cho mọi người, không cursor. Sửa: backoff + jitter +lastId.- Browser nói trực tiếp với farm WS inventory. Triệu chứng: CORS, domain cookie, authz từng service. Nguyên nhân: bỏ BFF. Sửa: một origin; BFF subscribe nội bộ.
Nhà tuyển dụng hay hỏi gì
Khi nào dùng WebSocket thay REST? Vì sao không GraphQL cho mọi thứ? Auth WebSocket từ browser thế nào? Chuyện gì khi đóng nắp laptop hai mươi phút?
Trả lời khi phỏng vấn
Mình dùng REST cho lệnh và snapshot đầu, SSE khi chủ yếu nghe, và WebSocket khi hai phía nói. HTTP/2 đã multiplex stream trên một connection, nên mình không bịa socket để tránh endpoint. GraphQL là contract BFF ở edge, không phải tôn giáo: trang marketing cache trên CDN vẫn muốn REST hoặc HTML. Constructor WebSocket trên browser không gắn được header Authorization, nên mình gửi cookie handshake khi team mình chung site, hoặc gửi frame auth làm message đầu và server timeout socket chưa auth. Sau khi đóng nắp laptop mình reconnect có jitter, trần, và cursor, không replay hết. BFF là một origin; browser không nói với farm socket inventory.
Nhớ nhanh
- REST cho lệnh và snapshot; SSE để nghe; WebSocket khi hai phía nói.
- HTTP/2 làm thêm REST call rẻ; socket không phải mánh tốc độ.
- WebSocket trên browser không có header Authorization: cookie hoặc auth message đầu.
- Reconnect có jitter, trần, và cursor. Heartbeat trước khi nginx giết socket idle.
Câu hỏi nối tiếp thường gặp
- Test WebSocket thế nào? Mock server trong component test; một path Playwright cho connect, frame auth, reconnect. Đừng
waitForTimeout. - Frame binary khác JSON? JSON đến khi profiler hiện phí parse. Binary cho tick và audio; ghi schema.
- HTTP/2 kèm nhiều REST call khác BFF resource dựng sẵn? Ưu tiên DTO theo màn ở BFF. GraphQL là DTO đó kèm ngôn ngữ query.
- Vì sao token trên query string WebSocket là footgun? Log, proxy, Referer. Cookie handshake hoặc gửi token trong frame đầu, không bao giờ trên URL.
Tóm tắt: Từ frontend, REST là contract của màn: id ổn định, filter trên query param share được, error envelope để UI rẽ nhánh, pagination khớp widget, và idempotency key trên tiền.
Vì sao quan trọng: Người phỏng vấn hỏi “nếu đổi được API, bạn nhờ backend điều gì?” Họ muốn màn hình, lỗi, pagination, và double-submit, không phải chuyện danh từ REST cho đúng sách. Browser sẽ retry, double-click, và mất tab.
- Cách hoạt động: Đặt tên resource bằng id ổn định (
ord_1, không phải index hàng). List DTO nên đã join ở BFF để table không bắn ba API mỗi hàng. Luôn trả{ items, nextCursor, total? }. - Pagination cursor / keyset cho feed và infinite scroll (không skip/trùng khi list đổi). Offset/limit cho bảng admin nhảy trang.
totallà ước lượng thì đừng render như số chính xác: giấu hoặc gắn nhãn. - Error envelope:
{ error: { code, message, fields } }. Map status cho luồng điều khiển: 401 session, 403 quyền, 404 đã mất khác với chưa từng có, 409/412 conflict, 422 lỗi field, 429 +Retry-After, 5xx chỉ retry nếu idempotent. Đừng parse chuỗimessagedành cho người đọc. - POST create/pay gửi
Idempotency-Keyvà retry cùng key.disabled={loading}chưa đủ: refresh vẫn double-post. PATCH +If-Matchcho conflict optimistic. Version additive;/v2chỉ khi reshape gãy. - Filter nằm trên query string để URL share được. Search phức tạp không thể ngắn và cache được thì thành POST
/search. Upload file: URL presigned, không JSON 200 MB xuyên Nuxt. Whitelist sort/filter: không query kiểu SQL từ URL.
type OrdersPage = {
items: Array<{ id: string; total: number; status: string }>
nextCursor: string | null
total: number | null // ước lượng: đừng render như số chính xác
}
type ErrorBody = {
error: { code: "QUOTE_EXPIRED" | "FORBIDDEN"; message: string; fields?: Record<string, string> }
}
// GET /api/orders?status=open&cursor=abc filter share được
// POST /api/pay header Idempotency-Key: <uuid> retry cùng key
// 422 → vẽ fields.coupon; đừng parse error.message cho luồng điều khiểnĐiều gì xảy ra: List có shape của màn: id, cursor, total tùy chọn. Quote hết hạn là QUOTE_EXPIRED, không phải toast câu tiếng Anh. Pay retry cùng Idempotency-Key nên 200 bị mất không charge hai lần. Filter trên query string để PM dán được URL.
| Nhu cầu | Shape | Tránh |
|---|---|---|
| Feed infinite scroll | Cursor / keyset + nextCursor | Offset skip/trùng khi insert |
| Bảng admin nhảy trang | Offset/limit; gắn nhãn total ước lượng | Cargo-cult cursor cho 20 hàng |
| POST tiền | Idempotency-Key + retry cùng key | Chỉ dựa disabled={loading} |
| Lỗi field | 422 + code error.fields | Parse chuỗi error.message |
Góc nhìn senior
- Senior muốn cả REST resource (reuse) và DTO theo màn ở BFF (trang tốn kém). Xin DTO trên table nếu không sẽ N+1.
fetchkhông throw lúc 404;$fetch/ ofetch thường có. Interceptor không được coi mọi non-2xx là “lỗi mạng”.- GET cá nhân hóa không bao giờ
Cache-Control: public. 204 kèm body, hoặc 200 bọc lỗi, biến client thành khảo cổ. - Kiểm chứng: p95 payload mỗi màn, tỷ lệ tạo trùng, empty khác error khác zero-result, thời gian tới hàng hữu ích đầu.
Lỗi hay gặp
- Ghép ba API mỗi hàng table. Triệu chứng: waterfall, N+1. Nguyên nhân: bám resource thuần REST. Sửa: list DTO ở BFF đã join.
- Offset pagination trên feed live. Triệu chứng: hàng bị skip hoặc trùng. Nguyên nhân: insert dịch offset. Sửa: cursor/keyset; giữ offset chỉ cho nhảy trang.
- Retry POST lúc 500 không key. Triệu chứng: charge hai lần. Nguyên nhân: “retry 5xx”. Sửa:
Idempotency-Key; chỉ retry đúng key.- Nhánh UI theo
messagetiếng Anh. Triệu chứng: i18n và highlight field chết sau lần sửa copy. Nguyên nhân: khôngcode. Sửa: envelope có code.
Nhà tuyển dụng hay hỏi gì
Nếu đổi được API, bạn nhờ backend điều gì? Họ muốn một contract phía frontend: màn hình, lỗi, pagination, idempotency. Không phải REST thuần sách giáo khoa.
Trả lời khi phỏng vấn
Mình xin list DTO đúng shape màn, filter trên query param, error envelope có code, và idempotency key trên Pay. Cursor cho infinite scroll, offset cho nhảy trang. Mình không bao giờ parse error message. Status map sang state trên product: 401 session, 403 quyền, 422 field, 429 backoff. Fetch không throw khi 404; mình không coi mọi non-2xx là lỗi mạng. Disabled loading trên Pay chưa đủ: cái cần là key. GET cá nhân hóa không bao giờ public cache. 204 kèm body là bug của interceptor, không phải tính năng. Ở bảng đơn admin mình xin tên customer đã join trên list DTO để hàng không bắn GET thứ hai.
Nhớ nhanh
- Thiết kế URL và payload theo màn, không theo sơ đồ danh từ.
- Cursor cho feed; offset cho nhảy trang; đừng render total ước lượng như số chính xác.
- Error envelope có code kèm Idempotency-Key trên tiền.
- List DTO ở BFF chặn waterfall từng hàng.
Câu hỏi nối tiếp thường gặp
- Version API thế nào? Ưu tiên JSON additive. Header hoặc
/v2chỉ khi reshape gãy. Deprecate trong envelope. - Query khác body cho search phức tạp? GET nếu cache được và ngắn. POST
/searchnếu body quá lớn hoặc có secret. - Vì sao disabled-loading chưa đủ trên Pay? Refresh và double-click vẫn bắn. Idempotency key mới là contract.
- Khi nào giấu total? Khi nó là ước lượng, rò quyền, hoặc chậm hơn page hàng đầu.
Tóm tắt: Mọi view async là một state machine nhỏ: loading, error, empty, success. Thêm stale khi refetch vẫn giữ hàng cũ, và optimistic khi bạn đoán trước. Encode state tường minh. Đừng trộn data && !error && !pending thành một súp boolean.
Vì sao quan trọng: “API chậm / flaky, UI chạy thế nào?” là câu hỏi product mặc áo networking. Spinner 150 ms còn tệ hơn không hiện gì. Hàng optimistic sai mà không rollback được còn tệ hơn chờ 300 ms. Empty không phải error, cũng không phải filter ra zero hàng.
- Cách hoạt động: Load lần đầu: skeleton khớp layout để CLS (Cumulative Layout Shift) thấp. Trễ spinner ~200 ms. Refetch: giữ hàng cũ kèm indicator nhẹ. Đó là stale-while-revalidate trên UI, không phải màn trống.
- Bỏ response thua race bằng
AbortController(xem 13.5 và JavaScript 1.2.7). Retry 5xx và lỗi mạng với backoff; đừng retry 4xx trừ 429. Lỗi cấp widget kèm nút retry; đừng làm trắng shell. - Copy empty (“chưa có đơn”), copy error (“không tải được đơn”), và copy zero-filter (“không đơn nào khớp”) là ba chuỗi.
if (!data)mà coi[]là error là chỗ sai kinh điển. - Optimistic UI chỉ khi rollback rẻ (toggle, rename). Thanh toán thì pessimistic. Snapshot cache, mutate, roll back lúc 409/5xx, toast sự thật. Nhiều tab cần BroadcastChannel hoặc query broadcast. Key cache theo user/tenant, không thì quyền stale sau login.
async function onToggleDone(id: string) {
const key = ["orders", userId] // key theo user, không thì quyền stale
const snapshot = queryClient.getQueryData(key)
queryClient.setQueryData(key, optimisticToggle(snapshot, id)) // đoán
try {
await $fetch(`/api/orders/${id}`, { method: "PATCH", body: { done: true } })
} catch (err) {
queryClient.setQueryData(key, snapshot) // rollback
toast.error("Could not update — still not marked done")
throw err
}
}
// thanh toán thì pessimistic: await POST /pay, rồi invalidate, không đoánĐiều gì xảy ra: Toggle vẽ ngay từ snapshot. 409 hoặc 5xx khôi phục snapshot và nói sự thật. Cache key gồm userId nên login bằng role khác không giữ được bộ quyền trước đó. Pay không nằm trong hàm này. Tiền phải chờ server.
| State | UI | Đừng |
|---|---|---|
| Load lần đầu | Skeleton khớp layout | Spinner trong 150 ms |
| Refetch (stale) | Giữ hàng + indicator nhẹ | Làm trống list |
| Empty / error / zero-filter | Ba copy khác nhau + retry | Coi [] là error |
| Optimistic | Toggle/rename; rollback khi fail | Đoán thanh toán |
Góc nhìn senior
- Spinner 150 ms còn tệ hơn không hiện gì. Skeleton giữ chỗ (CLS). Data đã cache có thể hiện luôn, không cần indicator.
- SSR success rồi client refetch không được nháy empty. Payload Nuxt lệch là bug hydration kèm state, không phải “API chậm”.
- Global query cache đúng cho list dùng ở hai chỗ, và cũng là cách dính bug quyền stale nếu không key theo user/tenant.
- Kiểm chứng: time-to-first-content, bucket thời gian spinner, tỷ lệ recovery lỗi, tỷ lệ rollback, RUM (Real User Monitoring) trang trắng.
Lỗi hay gặp
if (!data)lúc success mà empty. Triệu chứng: “không tải được” trên account mới. Nguyên nhân: nghĩ[]gần falsy. Sửa: pending, data, error là ba field riêng.- Optimistic Pay. Triệu chứng: charge hai lần hoặc success giả. Nguyên nhân: rollback không rẻ. Sửa: chờ 2xx; idempotency key; rồi invalidate.
- Cache không key theo user. Triệu chứng: sau login, hàng tenant trước nháy lên. Nguyên nhân:
["orders"]. Sửa:["orders", userId].- Lỗi cả shell. Triệu chứng: header và nav chết cùng widget. Nguyên nhân: một
pendingcho cả trang. Sửa: lỗi tại widget; retry tại đó.
Nhà tuyển dụng hay hỏi gì
API chậm hoặc flaky. UI chạy thế nào? Họ muốn bốn state kèm stale và optimistic như ngôn ngữ product, không phải một súp boolean.
Trả lời khi phỏng vấn
Mình nêu bốn state kèm stale và optimistic. Paint đầu là skeleton giữ chỗ; spinner chờ khoảng 200 ms. Refetch giữ hàng. “Chưa có đơn” không phải “không tải được đơn”, cũng không phải “không đơn nào khớp filter”. Mình toggle optimistic khi rollback rẻ: snapshot, mutate, khôi phục lúc 409. Mình không pay optimistic. Cache key gồm user nên quyền không rò xuyên login. Lỗi ở lại widget kèm retry, không trên shell. Hủy search in-flight là 13.5; mình không dán ví dụ đó ở đây. HTML SSR rồi refetch ra empty là một nháy; mình gọi đó là bug payload, không phải API chậm.
Nhớ nhanh
- Bốn state kèm stale và optimistic, nêu rõ từng cái.
- Skeleton lúc load đầu; giữ hàng lúc refetch; trễ spinner.
- Optimistic chỉ khi rollback rẻ. Thanh toán thì chờ.
- Empty, error, và zero-filter là ba copy. Key cache theo user.
Câu hỏi nối tiếp thường gặp
- Hủy search in-flight thế nào? AbortController lúc đổi query và rời route. Đó là 13.5, còn JavaScript 1.2.7 cho race.
- Cursor pagination để ở đâu? URL cho admin share được; query-cache key cho widget tạm. Chọn có chủ đích.
- Khi nào optimistic UI là tool sai? Tiền, xóa không đảo ngược được, và 409 bạn không khôi phục được từ snapshot.
- Empty khác zero-filter khác error? Ba chuỗi, ba hành động. Retry chỉ thuộc về error.
Tóm tắt: CORS (Cross-Origin Resource Sharing) là rule của browser: JavaScript trên origin A chỉ đọc được response từ origin B khi B opt-in. Ưu tiên browser → BFF cùng site để CORS và cookie third-party biến mất. CORS không chặn curl.
Vì sao quan trọng: “Chạy được trên Postman mà gãy trên Chrome” là topic này. “Vì sao cookie không được gửi?” cũng vậy. Gán Access-Control-Allow-Origin: * kèm cookie là bất hợp pháp trên browser và không phải authorization. Preview deploy làm nổ origin.
- Cách hoạt động: Origin là scheme + host + port.
localhostvà127.0.0.1khác nhau. Một GET/POST simple với Content-Type trong safelist và không custom header thì bỏ qua preflight.Authorization,Content-Type: application/json, hoặccredentials: "include"kích OPTIONS preflight. - Preflight hỏi
Access-Control-Request-MethodvàAccess-Control-Request-Headers. API phải trả mộtAccess-Control-Allow-Origincụ thể (không*nếu có cookie),Allow-Credentials: truekhi bạn gửi cookie, vàAllow-Headersliệt kê header của bạn. Browser rồi mới gửi GET thật. - Frontend:
fetch(..., { credentials: "include" })hoặc axioswithCredentials. Cờ cookie:Secure,HttpOnlycho session,SameSite=Laxmặc định;SameSite=None; Securechỉ cho case cross-site thật bạn không BFF được. Cookie third-party đang chết. Đừng thiết kế sản phẩm mới dựa vào chúng. - BFF cùng site (Nuxt/Nitro) để browser nói với một origin. Cookie vẫn first-party. Gom dữ liệu và auth cookie-sang-service nằm trên server. Proxy Vite giấu CORS lúc dev rồi bất ngờ lúc preview. Hãy test origin thật.
export async function loadMe() {
const res = await fetch("https://api.internal.example/me", {
method: "GET",
credentials: "include", // gửi cookie first-party
headers: { "X-Requested-With": "app" }, // custom header → preflight
})
if (!res.ok) throw new Error(String(res.status))
return res.json()
}
// Network: OPTIONS, rồi GET. Allow-Origin phải khớp, không phải *
// Allow-Credentials: true. CORS không chặn curl hoặc WebView mobile.Điều gì xảy ra: Custom header buộc OPTIONS. Nếu API trả * kèm credentials, Chrome chặn còn Postman vẫn trông ổn. credentials: "include" là cách cookie rời browser. BFF cùng site sẽ biến đây thành fetch("/api/me") và cuộc nói chuyện này kết thúc.
| Setup | Cookie / CORS | Dùng |
|---|---|---|
| BFF cùng site | First-party; hết chuyện CORS | Mặc định cho app Vue/Nuxt |
| Cross-origin + credentials | Origin khớp, không bao giờ * | API legacy không dời được |
| GET public, không cookie | * hợp lệ | JSON marketing, font |
| Bearer trong memory | CORS vẫn áp dụng; CSRF khác | XSS lúc đó mới cướp session |
Góc nhìn senior
- CORS không phải authz. Trang lạ không đọc được JSON vẫn bắn được POST có cookie nếu bạn không chống CSRF. Đó là doc bảo mật.
*kèmAllow-Credentials: truebị từ chối. Reflect mọiOriginlà cửa mở. Allowlist host preview tường minh, hoặc BFF cùng site.- Expo / WebView mobile không phải CORS của Chrome. Server-to-server không dính. Đừng debug những thứ đó bằng playbook này.
- Kiểm chứng trên Network: có OPTIONS không, header nào fail? Sau khi chuyển sang BFF, cookie còn first-party không?
Lỗi hay gặp
- Proxy Vite lúc dev, preview gãy. Triệu chứng: “CORS chỉ trên staging”. Nguyên nhân: proxy giấu preflight. Sửa: gọi origin API thật trên preview.
- Cookie
Domain=.example.comrò preview ↔ prod. Triệu chứng: lẫn session. Nguyên nhân: một domain cha. Sửa: cookie host-only; OAuth client tách riêng.- Quên
credentials: "include". Triệu chứng: 401 sau login “chạy được” trên Postman. Nguyên nhân: browser không gửi cookie. Sửa: include +SameSitekhớp navigation.- Coi CORS là tường lửa API. Triệu chứng: không CSRF, không authz,
*trên prod. Nguyên nhân: nhầm rule đọc của browser với bảo mật. Sửa: BFF + CSRF + authz thật.
Nhà tuyển dụng hay hỏi gì
Vì sao browser chặn API của bạn? Vì sao cookie không được gửi? Họ muốn bạn biết đây là browser, không phải axios khó tính, và CORS không bảo vệ API khỏi curl.
Trả lời khi phỏng vấn
Mình để browser và BFF trên cùng một site để khỏi phải bàn chuyện CORS nữa. Nếu phải cross-origin, mình allow đúng một origin kèm credentials và gửi credentials include. Content-Type JSON hoặc custom header bật OPTIONS; star kèm cookie thì bị từ chối. CORS là rule đọc của browser, không phải authorization. curl vẫn đụng API, nên CSRF và authz vẫn tồn tại. Localhost và 127.0.0.1 là origin khác nhau. Proxy Vite giấu chuyện này lúc dev; mình test các host preview thật. SameSite Lax là cookie mặc định; None là nước cuối, mình thà BFF cho xong. Host PR preview là origin mới: mình allowlist hoặc BFF, không reflect Origin nào.
Nhớ nhanh
- CORS là rule đọc của browser. Nó không chặn curl.
- Ưu tiên BFF cùng site. Không bao giờ star kèm cookie.
- JSON hoặc custom header bật OPTIONS. Allow-Origin phải khớp.
- credentials include kèm SameSite là cách cookie được gửi đi.
Câu hỏi nối tiếp thường gặp
- CORS có chặn curl đụng API không? Không. Server-to-server và Postman bỏ qua. Authz và CSRF vẫn cần.
- Auth cho WebView mobile thế nào? Không phải CORS của Chrome. Thường cookie first-party hoặc kho token native; test đúng WebView.
- Vì sao preview deploy gãy CORS? Mỗi PR là origin mới. Allowlist chúng hoặc BFF cùng site cho preview.
- Simple khác preflight: thứ gì bật OPTIONS? Custom header, content type JSON, method ngoài safelist, credentials.
Tóm tắt: AbortController hủy fetch đang bay (và mọi thứ nhận signal). Abort GET đã hết liên quan. Timeout mọi request, kể cả mutation, bằng AbortSignal.timeout. Đừng abort Pay lúc unmount: giữ idempotency key.
Vì sao quan trọng: User gõ nhanh hoặc rời trang. 200 muộn từ query #1 ghi đè query #2. fetch không timeout mặc định, nên TCP treo thành spinner vô hạn. Abort mù Pay lúc unmount là cách bị charge mà không còn UI. JavaScript 1.2.7 đã hiện race typeahead; topic này không lặp component đó.
- Cách hoạt động: Tạo controller, đưa
signalvào$fetch/fetch. Abort trong cleanupwatchhoặc lúc rời route cho search và filter. Ghép signal:AbortSignal.any([routeSignal, AbortSignal.timeout(8_000)])để route hoặc timer thắng. (AbortSignal.anycó trên Chromium / Firefox / Safari 17.4+ hiện đại; polyfill hoặc nốiAbortControllernếu phải hỗ trợ bản cũ.) AbortSignal.timeout(ms)abort vớiTimeoutError(mộtDOMException). NuốtAbortError/TimeoutErrorcho search; hiện 5xx thật. Adapter axios không chuyểnsignalsẽ im lặng bỏ abort. Hãy kiểm chứng một lần.- Đừng abort POST /pay /save lúc unmount. Giữ
Idempotency-Keyvà để request chạy xong; reconcile khi view mount lại. Optimistic UI kèm abort: nếu đã đoán rồi abort, bạn phải rollback, không thì là dối. - Timeout WebSocket là heartbeat, không phải
AbortController.Promise.racevới timer mà không abortfetchvẫn tốn connection và vẫn áp 200 muộn trừ khi bạn bỏ qua. Hãy abort signal.
export async function searchOrders(q: string, routeSignal: AbortSignal) {
const signal = AbortSignal.any([routeSignal, AbortSignal.timeout(8_000)])
try {
return await $fetch("/api/orders", { query: { q }, signal })
} catch (err) {
const name = (err as DOMException).name
if (name === "AbortError" || name === "TimeoutError") return null
throw err
}
}
// JavaScript 1.2.7 hiện watch() abort typeahead lần trước.
// Đừng abort POST /pay lúc unmount: hãy giữ Idempotency-Key.Điều gì xảy ra: Hoặc rời route hoặc timer 8s abort GET. AbortError / TimeoutError thành null để caller giữ lại các hàng tốt lần trước. 500 vẫn throw. Pay không nằm trong helper này: unmount không được hủy tiền. Pattern watch typeahead nằm ở JavaScript 1.2.7.
| Việc | Lúc rời / đổi | Timeout |
|---|---|---|
| GET search / filter | Abort signal trước | AbortSignal.timeout ~8s |
| Refetch list | Abort nếu query key đổi | Từng request, không 3s toàn cục |
| POST /pay /save | Đừng abort lúc unmount | Timeout dài hơn; giữ key |
| WebSocket | Close + backoff (13.1) | Heartbeat, không AbortController |
Góc nhìn senior
- Một timeout 3s toàn cục là cách giết upload file. Timeout theo loại: search ngắn, pay dài, upload dài hơn.
- Ghép signal hơn
seq++tạm bợ.seq++vẫn tốn network; abort giải phóng connection. - Unmount Vue không hủy gì trừ khi bạn abort trong
onBeforeUnmount/ cleanupwatch. React cũng vậy với cleanup effect. - Kiểm chứng: Network hiện search bị hủy là
(canceled); Pay sau khi đóng tab vẫn tới 2xx với cùng idempotency key. - AbortError lúc search không được toast. Người gõ nhanh sẽ thấy bão. Nuốt abort; hiện 5xx.
Lỗi hay gặp
- Abort Pay lúc unmount. Triệu chứng: đã charge, UI nghĩ đã hủy. Nguyên nhân:
onBeforeUnmount(ac.abort)dùng chung. Sửa: abort đọc, không phải tiền; giữ key.- Timeout
Promise.racekhông abort. Triệu chứng: spinner dừng, 200 muộn vẫn ghi. Nguyên nhân: fetch còn sống. Sửa: đưasignal.- Axios không chuyển
signal. Triệu chứng: abort “không làm gì”. Nguyên nhân: adapter. Sửa:signaltrên request; chứng minh trên Network.- Copy
watchsearch 1.2.7 vào đây và gọi là chuyện timeout. Triệu chứng: hai ví dụ giống nhau. Nguyên nhân: topic chồng. Sửa: topic này là timeout + ghép + đừng-abort-Pay.
Nhà tuyển dụng hay hỏi gì
User gõ nhanh hoặc rời trang. Bạn vẫn để những request đó chạy xong? Họ muốn abort lúc search, timeout mọi thứ, và không abort mù Pay lúc unmount.
Trả lời khi phỏng vấn
Mình abort search và filter khi query đổi hoặc rời route. Mình timeout mọi thứ bằng AbortSignal.timeout để TCP treo không thành spinner vô hạn, rồi ghép timer đó với signal route qua AbortSignal.any. Mình nuốt AbortError và TimeoutError lúc đọc; 500 thật vẫn throw. Mình không abort Pay lúc unmount: giữ idempotency key và reconcile khi view trở lại. Timeout WebSocket là heartbeat, không phải AbortController. Race typeahead với cleanup watch là JavaScript 1.2.7; mình chỉ sang đó, không dán lại. Promise.race không abort chỉ là timeout giả. Axios phải chuyển signal, không thì abort là dối; mình chứng minh một lần trên Network.
Nhớ nhanh
- Abort GET đã hết liên quan. Timeout mọi request.
- Ghép signal route với AbortSignal.timeout qua AbortSignal.any.
- Đừng abort Pay lúc unmount. Giữ idempotency key.
- Race typeahead nằm ở JavaScript 1.2.7; topic này không copy nó.
Câu hỏi nối tiếp thường gặp
- Abort tương tác optimistic UI thế nào? Nếu đã đoán rồi abort, hãy rollback, không thì là dối. Pay không nên bị đoán.
- Timeout WebSocket thế nào? Heartbeat / ping (13.1). AbortController không hủy socket.
- Vì sao Promise.race là timeout kém? Fetch vẫn chạy và 200 muộn vẫn ghi, trừ khi bạn abort signal.
- Thứ gì abort lúc rời route, thứ gì giữ? Abort search và GET list. Giữ Pay/save và chạy xong với cùng idempotency key.