Bug “ma” trên UI thường không phải do một dòng setState sai, mà do mất đồng bộ giữa các nguồn sự thật: server đã đổi, cache client còn cũ; hoặc hai tab cùng ghi; hoặc optimistic update không reconcile với response.
Bài này dùng mô hình trạng thái + ma trận quyết định, không gắn cứng một framework.
Phạm vi: SPA/SSR lai; không hướng dẫn từng API của React Query / Relay.
Tham chiếu: AbortController & fetch timeout khi hủy request tránh race.
1. Hai nguồn sự thật: server truth và client projection
- Server truth: dữ liệu authoritative sau commit (order status, số dư).
- Client projection: bản sao phục vụ UI (cache, normalized store, component state).
Quy tắc middle+: mọi màn hình “quan trọng” cần trả lời được: sự thật cuối cùng lấy từ đâu khi conflict? Nếu không trả lời được, bạn sẽ vá bug từng ca.
2. Stale read, optimistic UI, conflict
2.1. Stale read
Hiển thị dữ liệu cũ sau khi user hoặc hệ thống khác đã đổi.
- Giảm: refetch khi focus, invalidation sau mutation, ETag/version.
2.2. Optimistic UI
Cập nhật UI trước khi server ack.
- Rủi ro: server từ chối → cần rollback projection rõ ràng + thông báo lỗi nhất quán.
2.3. Conflict
Hai thao tác cùng sửa một resource.
- Chiến lược: last-write-wins (đơn giản, mất dữ liệu tinh vi), merge theo field, hoặc yêu cầu user chọn (khi giá trị cao).
3. Chiến lược invalidation (chọn theo loại dữ liệu)
| Loại dữ liệu | Gợi ý |
|---|---|
| Danh mục ít đổi | TTL dài + manual invalidate khi admin đổi |
| Feed cá nhân | invalidate theo cursor / mutation key |
| Giá/ tồn kho nhạy | TTL ngắn hoặc không cache client; server authoritative |
4. Ma trận quyết định (độ nhạy × tần suất × realtime)
Gán mức Low / Med / High cho từng trục:
| Tần suất thay đổi thấp | Tần suất cao | |
|---|---|---|
| Độ nhạy thấp (banner copy) | Cache + TTL dài | SSR + CDN |
| Độ nhạy cao (số dư, quota) | Ít cache client; refetch có kiểm soát | WebSocket/SSE hoặc polling ngắn có backoff |
Realtime need cao không tự động bắt buộc WebSocket — đôi khi polling 2–5s + version vẫn đủ và rẻ hơn vận hành.
5. Anti-pattern: “cache mọi thứ”
- Cache không có khóa phiên bản → UI sai sau deploy schema API.
- Cache toàn cục không partition theo user → leak dữ liệu cross-user (lỗi bảo mật nghiêm trọng).
- Hai thư viện cache chồng lên nhau không có chính sách invalidate thống nhất.
Khi không nên normalized client cache lớn
- App nhỏ, ít màn hình:
fetchper view + cache đơn giản có thể đủ. - Khi team chưa có test e2e cho race — cache phức tạp nhân đôi chi phí QA.
6. Ba lớp state trong đầu (mental model)
- URL + server render (nếu SSR): truth theo request.
- Global client store (Redux, Zustand, Query cache…): projection có TTL.
- Local component state (form draft): có thể mất khi unmount — quyết định có “draft recovery” hay không.
Khi bug xảy ra, hãy hỏi: “state đang ở lớp nào, và ai là người merge vào lớp trên?”
7. Race condition cổ điển: request A chậm hơn request B
User bấm “Lưu” hai lần hoặc đổi filter nhanh:
- Response B về sau A nhưng hiển thị trước A → UI “nhảy ngược”.
- Giải pháp: token/stale counter trên mỗi request; chỉ áp dụng response nếu
seqkhớp mới nhất. Tham khảo: AbortController để hủy in-flight.
Giải thích thêm: đây là serialization của hiệu ứng chứ không phải serialization của network.
8. ETag, If-None-Match, và “conditional fetch”
Với resource ít đổi, HTTP cache + ETag giúp client biết “không đổi thì không parse JSON lớn”. Với GraphQL hoặc POST phức tạp, ETag khó hơn — cân nhắc field-level version hoặc server-driven cursor.
9. Realtime: WebSocket không miễn phí
WebSocket mang lại độ trễ thấp nhưng thêm:
- Kết nối dài: load balancer sticky, reconnect storm khi deploy.
- Backpressure: client chậm có thể làm buffer server đầy.
Đôi khi SSE một chiều (server → client) đủ cho thông báo “đơn đã đổi trạng thái”, đơn giản hơn cho một số CDN/proxy.
10. SSR + hydration mismatch
Khi HTML SSR khác với lần hydrate client (do random, clock, locale, hoặc flag đọc khác môi trường), React/Vue cảnh báo hydration. Đây là dạng mất đồng bộ nguồn sự thật giữa server render và bundle client. Giảm bằng: deterministic seed, đồng bộ flag evaluate, tránh đọc Date.now() trong render phase.
11. Bảng: chọn công cụ cache theo use-case
| Use-case | Gợi ý |
|---|---|
| Danh sách có phân trang | Cursor + cache theo key (filter, cursor) |
| Form dài | Local storage debounce + submit idempotent |
| Dashboard realtime | WebSocket/SSE + snapshot initial HTTP |
Bài tập ngắn
Chọn một màn hình trong sản phẩm của bạn: điền ma trận 3 trục; chỉ ra một kịch bản stale và cách reproduce.
Mở rộng: Viết repro tối thiểu với hai tab trình duyệt hoặc throttle mạng (Slow 3G) để buộc race.
12. Form phức tạp: dirty state và “beforeunload”
Người dùng đóng tab khi form chưa lưu — bạn có cảnh báo không? Nếu có optimistic draft sync lên server, cần policy conflict khi hai thiết bị cùng sửa draft. Đây là UX + state merge, không chỉ React state.
13. Pagination vô hạn vs cursor
Infinite scroll dễ gây memory creep trên client nếu giữ toàn bộ page trong store. Chiến lược: windowing (chỉ giữ N page), hoặc virtual list. Server: cursor ổn định hơn offset khi dữ liệu đang được thêm mới xen kẽ.
14. Theme / locale: global state “tĩnh” nhưng vẫn gây bug
Đổi locale có thể làm format số/ngày đổi — cache chứa string đã format sẽ sai sau đổi locale. Invalidate theo locale trong cache key hoặc format tại render cuối.
15. Testing gợi ý cho race UI
- Playwright: hai context song song cùng user (nếu session cho phép) hoặc rapid click.
- Component test: giả lập response chậm với
seqtoken.
Mục tiêu không phải coverage 100%, mà một vài test bắt regression hay gặp.
16. Tóm tắt “một dòng” cho designer/PM
“Màn hình này tin server hay tin cache; nếu khác nhau thì hiển thị gì trong bao lâu?” — nếu trả lời được, đã giảm một nửa bug sync.
Tóm tắt cho người vội
- Luôn xác định server truth vs projection.
- Race = cần token/abort/ordering hiệu ứng.
- Realtime chọn theo chi phí vận hành, không theo hype.
17. Liên hệ idempotency ở lớp UI
Nút “Thanh toán” bị double-tap: nếu không disable + idempotent request ở server, bạn sẽ có race UI và race backend. Xem thêm Idempotency — UI chỉ là lớp đầu tiên, không thay thế hợp đồng API.
Đọc thêm
- AbortController & fetch timeout
- Martin Fowler — CQRS (khi read model khác write model — đọc có chọn lọc)
- Tài liệu cache HTTP (ETag,
Cache-Control) — MDN - Logs, metrics, traces — debug “UI chậm” có khi là backend