Có một loại bug có thể gọi là “bug ma”, UI hiển thị data sai nhưng không có lỗi nào trong console, không có exception nào trên server, mọi API đều trả 200. User thấy số dư cũ sau khi vừa thanh toán, hoặc danh sách order hiện đơn đã xóa, hoặc form submit xong nhưng UI không cập nhật. Refresh trang thì đúng lại.

Những bug này không phải do một dòng setState sai, chúng do mất đồng bộ giữa các nguồn sự thật. Server đã đổi nhưng cache client còn cũ. Hai tab cùng sửa một resource. Optimistic update nhưng server reject mà UI không rollback. Race condition giữa hai request, response cũ đến sau response mới.

Bài này không gắn vào framework cụ thể (React, Vue, Angular đều gặp), mà tập trung vào mô hình tư duy: server truth vs client projection, khi nào cache, khi nào invalidate, và làm sao tránh race condition.


Server truth vs client projection

Mọi bug đồng bộ UI bắt nguồn từ một câu hỏi: nguồn sự thật cuối cùng là ở đâu?

Server truth là dữ liệu authoritative sau khi commit, order status, số dư tài khoản, inventory count. Đây là nguồn đúng nhất.

Client projection là bản sao phục vụ UI, cache, normalized store, component state. Bản sao này có thể stale, partial, hoặc inconsistent với server bất kỳ lúc nào.

Quy tắc quan trọng: mọi màn hình “quan trọng” (liên quan tiền, quyền, data nhạy cảm) phải trả lời được: “khi client và server khác nhau, tin ai?” Nếu không trả lời rõ ràng, bạn sẽ vá bug từng case mà không có nguyên tắc nhất quán.

Với hầu hết trường hợp, server truth thắng, client projection chỉ là optimization cho UX (hiển thị nhanh hơn, giảm loading state). Nhưng có trường hợp client truth quan trọng: form draft chưa submit, undo/redo local, offline-first app. Biết rõ đâu là truth cho từng loại data giúp design state management đúng từ đầu.


Ba lớp state

Frontend state có thể chia theo ba lớp:

URL + server render (nếu có SSR): truth theo mỗi request. URL chứa state navigation (page, filter, sort), server render HTML initial. Đây là lớp đáng tin nhất, refresh trang luôn cho đúng.

Global client store (Redux, Zustand, React Query cache, Pinia): projection có TTL. Data fetch từ server, cache local, có thể stale. Đây là nơi hầu hết bug “ma” sống, cache stale, invalidation thiếu, race condition.

Local component state (form draft, modal open/close, hover state): mất khi unmount. Quyết định có “draft recovery” không, persist form draft vào localStorage hay chấp nhận mất khi đóng tab.

Khi bug xảy ra, hỏi: “state đang ở lớp nào, và ai merge vào lớp trên?” Bug thường là do lớp 2 (cache) không sync đúng với lớp 1 (server truth).


Stale read: data cũ hiển thị sai

Stale read là loại bug phổ biến nhất, UI hiển thị data đã bị thay đổi trên server (bởi chính user ở tab khác, hoặc bởi hệ thống, hoặc bởi user khác).

Ví dụ: user A mở danh sách order lúc 10:00, order #123 status “pending”. Lúc 10:05 hệ thống xử lý xong, status đổi thành “completed”. User A vẫn thấy “pending” vì cache client không refresh.

Các chiến lược giảm stale read phổ biến:

Refetch on focus: khi user quay lại tab (visibilitychange), refetch data. React Query, SWR có built-in option này. Đơn giản và hiệu quả cho hầu hết case.

Invalidation sau mutation: sau khi submit form (mutation), invalidate cache của data liên quan. React Query queryClient.invalidateQueries rất tiện. Nhưng cần biết rõ query nào cần invalidate, miss một query là stale read.

ETag/version: server trả ETag hoặc version number. Client gửi If-None-Match, nếu data không đổi, server trả 304 (không cần parse JSON lớn). Nếu đổi, trả data mới. Tiết kiệm bandwidth cho resource ít đổi.


Optimistic UI: nhanh nhưng rủi ro

Optimistic UI cập nhật giao diện ngay trước khi server xác nhận, user bấm “Like” thì counter tăng ngay, không chờ API trả về. UX mượt hơn, nhưng có rủi ro: server reject (validation fail, permission denied) mà UI đã show thành công.

Quy tắc nên theo: optimistic UI cho action low-risk (like, bookmark, toggle setting). Non-optimistic cho action high-risk (payment, delete, permission change). Khi dùng optimistic, luôn implement rollback rõ ràng, nếu server reject, revert UI state VÀ show error message nhất quán.

Một bug thường gặp: optimistic delete item khỏi list → server reject (item đang bị lock) → UI đã xóa rồi nhưng không add lại → user nghĩ đã xóa thành công → data inconsistent. Rollback phải atomic, revert state + notify user.


Conflict: hai người cùng sửa

Hai user (hoặc hai tab cùng user) sửa cùng resource. Ai thắng?

Last-write-wins (LWW): đơn giản nhất, server chấp nhận write mới nhất. Nhưng write trước bị ghi đè âm thầm, mất data tinh vi. Hợp cho data ít giá trị (draft note, preference).

Version-based conflict detection: server check version, nếu client gửi version cũ hơn current, reject với conflict error. Client phải fetch mới và retry hoặc hiển thị cho user chọn. Hợp cho data quan trọng (document, order).

Field-level merge: merge thay đổi theo field, user A sửa name, user B sửa email, cả hai apply. Phức tạp hơn nhưng tránh conflict false positive khi sửa field khác nhau. CRDTs cho case extreme (collaborative editing).

Chọn chiến lược theo giá trị data: LWW cho config/preference, version-based cho business data, field-level merge cho collaborative feature.


Chiến lược invalidation theo loại dữ liệu

Không phải mọi data cần invalidation strategy giống nhau. Phân loại:

Danh mục ít đổi (product category, country list): TTL dài (giờ hoặc ngày) + manual invalidate khi admin thay đổi. Cache hit rate cao, gần như không stale.

Feed cá nhân (notification, activity log): invalidate theo cursor hoặc mutation key. Mỗi action tạo entry mới → invalidate query với key liên quan.

Dữ liệu nhạy (giá, tồn kho, số dư): TTL rất ngắn hoặc không cache client. Server authoritative, client refetch mỗi lần hiển thị. Stale data ở đây gây hậu quả business (user thấy giá cũ, đặt hàng giá mới → phàn nàn).


Ma trận quyết định

Khi design cache cho một loại data, đánh giá theo ba trục: độ nhạy (sai thì hậu quả gì), tần suất thay đổi, và nhu cầu realtime.

Độ nhạy thấp + tần suất thấp (banner copy, about page): cache + TTL dài. Đơn giản nhất.

Độ nhạy thấp + tần suất cao (feed, listing): SSR + CDN hoặc short TTL client cache + stale-while-revalidate.

Độ nhạy cao + tần suất thấp (số dư, quota): ít cache client, refetch có kiểm soát (on focus, after mutation).

Độ nhạy cao + tần suất cao (stock price, auction): WebSocket/SSE hoặc polling ngắn có backoff. Nhưng realtime không tự động bắt buộc WebSocket, polling 2-5 giây + version check đôi khi đủ và rẻ vận hành hơn nhiều.


Race condition: request A chậm hơn request B

User đổi filter nhanh, hoặc bấm “Lưu” hai lần liên tiếp. Request A bay trước, request B bay sau. Response B về trước (server xử lý nhanh hơn), UI cập nhật đúng. Rồi response A về sau, UI “nhảy ngược” về data cũ.

Giải pháp: sequence token trên mỗi request. Client giữ counter, mỗi request gán seq number. Khi response về, chỉ áp dụng nếu seq khớp mới nhất, response cũ bỏ qua. AbortController bổ sung, abort request cũ trước khi gửi mới, tiết kiệm bandwidth và giảm race.

React Query, SWR xử lý phần lớn race condition nếu dùng đúng query key. Nhưng custom fetch trong event handler vẫn cần tự handle.


WebSocket: không miễn phí

WebSocket cho latency thấp nhất, server push data ngay khi có thay đổi. Nhưng thêm complexity đáng kể.

Kết nối dài: load balancer cần sticky session hoặc layer 7 routing. Deploy mới → reconnect storm, tất cả client reconnect cùng lúc, thundering herd.

Backpressure: client chậm (tab background, mobile weak network) làm buffer server đầy. Cần message queue hoặc drop strategy.

Reconnection logic: client phải handle disconnect, exponential backoff, resubscribe topic, fetch missed events. Code reconnection thường phức tạp hơn business logic.

Đôi khi SSE (Server-Sent Events) đủ, một chiều server → client, đơn giản hơn WebSocket, tốt cho notification “đơn đã đổi trạng thái”. Nhiều CDN/proxy hỗ trợ SSE tốt hơn WebSocket.


SSR + hydration mismatch

Khi HTML từ SSR khác với khi client hydrate (do random value, Date.now(), locale setting, feature flag đọc khác môi trường), React/Vue cảnh báo hydration mismatch. Đây là dạng mất đồng bộ giữa server render truth và client truth.

Giảm bằng: deterministic seed cho random, đồng bộ feature flag evaluate giữa server và client, tránh đọc Date.now() trong render phase (defer sang useEffect/onMounted).


Anti-pattern: cache mọi thứ

Cache không có khóa phiên bản: deploy API mới đổi response schema → client cache vẫn dùng format cũ → parse error hoặc data sai. Cache key nên include API version.

Cache toàn cục không partition theo user: user A thấy data user B vì cache key không include user identifier. Đây không chỉ là bug, là lỗi bảo mật nghiêm trọng.

Hai thư viện cache chồng nhau (React Query + custom Redux cache) không có invalidation thống nhất: invalidate một thì cái kia vẫn stale. Chọn một source of truth cho cache.


Form phức tạp: dirty state

User đóng tab khi form chưa lưu, có cảnh báo beforeunload không? Nếu có auto-save draft lên server, cần xử lý conflict khi hai thiết bị cùng sửa draft. Form state là intersection giữa UX design và state management, cần quyết định rõ: local-only draft hay server-synced draft.


Pagination vô hạn vs cursor

Infinite scroll giữ toàn bộ page trong memory → memory creep trên client. Chiến lược: virtual list (chỉ render visible items), windowing (chỉ giữ N page gần nhất trong memory), hoặc cursor-based pagination (ổn định hơn offset khi data thêm mới xen kẽ).


Tóm tắt

Mọi bug đồng bộ UI bắt nguồn từ không rõ “tin ai khi conflict”, xác định server truth vs client projection cho từng loại data là bước đầu tiên.

Stale read giảm bằng refetch on focus, invalidation sau mutation, ETag/version. Optimistic UI chỉ cho action low-risk và phải có rollback rõ ràng. Conflict resolution chọn theo giá trị data, LWW cho ít giá trị, version-based cho business critical.

Cache phân loại theo độ nhạy × tần suất × realtime need. Không cache mọi thứ, data nhạy (tiền, quyền) ít cache hoặc không cache client. WebSocket không miễn phí, polling + version check đôi khi đủ và rẻ hơn.

Race condition giải bằng sequence token + AbortController. Dùng React Query/SWR đúng cách xử lý phần lớn case. Custom fetch trong event handler vẫn cần tự handle race.

“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?”, trả lời được câu này cho mỗi màn hình đã giảm một nửa bug sync.


Tham khảo