Mở đầu — vì sao bài này quan trọng
Hãy hình dung một buổi sáng thứ Hai ở Hà Nội. Một nhân viên giao hàng của một app logistics đang ngồi trên xe máy, len lỏi qua hầm chui Kim Liên — nơi sóng 4G chập chờn rồi mất hẳn trong vài giây. Anh ấy vừa bấm "Đã giao thành công" cho một đơn hàng. Nếu app của bạn xử lý hành động đó bằng cách gọi thẳng API và chờ phản hồi, thì ngay lúc đó màn hình sẽ quay vòng vòng, hiện lỗi "Không có kết nối", và anh shipper phải bấm lại — có khi ba bốn lần. Tệ hơn, nếu mỗi lần bấm lại tạo ra một request, server có thể ghi nhận đơn hàng được giao... hai lần.
Đây chính là lý do Offline Queue & Sync tồn tại. Tại Việt Nam và Đông Nam Á nói chung, connectivity (kết nối mạng) mang tính intermittent — chập chờn, gián đoạn liên tục: trong thang máy, dưới hầm gửi xe, trên tàu Metro, ở vùng nông thôn sóng yếu, hay đơn giản là khi data 3G/4G "rớt" vài giây. Người dùng ngày nay mặc định kỳ vọng rằng các thao tác "nhẹ" như ghi chú, lưu nháp, thêm vào giỏ hàng, like, comment, đánh dấu hoàn thành... phải hoạt động ngay cả khi không có mạng, rồi tự đồng bộ lên server khi mạng trở lại.
Với vai trò là PM, bạn không phải người code phần đồng bộ này. Nhưng nếu spec của bạn không nói rõ "hành động nào được phép offline", "queue lưu ở đâu", "khi nào sync", "xử lý conflict thế nào", thì engineer sẽ tự quyết định — và họ thường chọn phương án dễ code nhất, không phải phương án tốt nhất cho người dùng. Bài này dạy bạn cách viết một Offline Queue & Sync Spec đủ chặt để team build ra trải nghiệm offline mượt mà, không mất dữ liệu, không nhân đôi hành động.
Khái niệm cốt lõi
Offline-first là một quyết định sản phẩm, không phải tính năng kỹ thuật
"Offline queue" nghĩa là: khi người dùng thực hiện một hành động mà mạng không sẵn sàng, app không báo lỗi ngay mà xếp hành động đó vào một hàng đợi (queue) lưu cục bộ trên thiết bị, hiển thị cho người dùng như thể nó đã thành công (optimistic UI), rồi gửi lên server (sync) khi có mạng trở lại.
Điểm mấu chốt bạn cần spec rõ: không phải hành động nào cũng nên offline. Một số hành động bắt buộc cần server phản hồi thật trước khi xác nhận. Vì vậy việc đầu tiên là phân loại hành động thành 3 nhóm:
- Queue được (queueable): Hành động mà kết quả gần như chắc chắn thành công và không cần xác nhận tức thì từ server. Ví dụ: lưu nháp bài viết, thêm sản phẩm vào giỏ, đánh dấu đã đọc, ghi chú, like.
- Không queue được (online-only): Hành động cần xác thực real-time hoặc có tính tài chính/pháp lý. Ví dụ: thanh toán, đặt vé khi số ghế giới hạn, xác thực OTP, kiểm tra tồn kho tức thời trước khi cam kết.
- Tùy ngữ cảnh (conditional): Có thể queue nhưng cần cảnh báo. Ví dụ: gửi tin nhắn (queue được nhưng phải hiển thị trạng thái "đang gửi"), đặt đơn hàng giá trị nhỏ.
Bốn thành phần phải có trong spec
Một Offline Queue & Sync Spec đầy đủ cần trả lời được bốn câu hỏi:
- Lưu trữ (Storage): Queue được lưu ở đâu trên thiết bị? (IndexedDB / SQLite / Core Data / Room...). Bao nhiêu item tối đa? Tồn tại bao lâu? Có mã hóa không?
- Kích hoạt sync (Trigger): Khi nào queue được "đẩy" lên server? Khi mạng trở lại? Khi app mở lại? Theo lịch định kỳ? Khi người dùng kéo refresh?
- Thứ tự & idempotency: Các item được gửi theo thứ tự nào? Làm sao đảm bảo gửi lại không tạo bản ghi trùng?
- Giải quyết xung đột (Conflict resolution): Nếu cùng một dữ liệu bị sửa ở cả offline lẫn trên server (do thiết bị khác), thì bên nào thắng?
Idempotency — khái niệm sống còn
Đây là từ khóa quan trọng nhất của bài này. Idempotency nghĩa là: gửi cùng một request nhiều lần cũng chỉ tạo ra một kết quả. Vì queue có thể retry (gửi lại) khi mạng chập chờn — request đã tới server thành công nhưng phản hồi về bị mất, app tưởng thất bại nên gửi lại — nên mỗi hành động trong queue cần một idempotency key (một mã định danh duy nhất do client tạo ra). Server thấy key trùng thì biết "à, đơn này xử lý rồi" và trả về kết quả cũ thay vì tạo mới. Không có idempotency key, đơn giao hàng của anh shipper ở đầu bài sẽ bị ghi nhận hai lần.
Optimistic UI và việc rollback
Optimistic UI là hiển thị kết quả "như thể đã thành công" ngay lập tức, trước khi server xác nhận. Điều này làm app cảm giác nhanh và hoạt động được offline. Nhưng spec phải nói rõ: nếu sync thất bại vĩnh viễn thì sao? Người dùng cần được thông báo, và UI cần rollback (hoàn tác) — ví dụ comment offline bị server từ chối vì vi phạm chính sách thì phải biến mất kèm thông báo, chứ không lặng lẽ ở lại làm người dùng tưởng đã đăng thành công.
Tình huống thực tế
Ví dụ 1: Notion và bài học "draft không bao giờ mất"
Notion — app ghi chú/tài liệu được rất nhiều startup Việt dùng — xây dựng theo triết lý offline-first cho phần soạn thảo. Khi bạn gõ trên một trang, mọi thay đổi được ghi vào local database (trên web là IndexedDB) trước, rồi mới đồng bộ lên server theo từng "operation" nhỏ. Nhờ vậy, kể cả khi bạn đang viết giữa chừng mà mất mạng — đang ở quán cà phê wifi yếu chẳng hạn — chữ vẫn hiện ra mượt, và khi mạng quay lại, mọi thứ tự sync lên.
Bài học cho PM: Notion phân loại "soạn thảo nội dung" là queueable và dùng cơ chế operational transform để merge các thay đổi nhỏ thay vì ghi đè toàn bộ trang. Trong spec của bạn, khi nội dung là dạng "tích lũy từng phần" (gõ chữ, thêm dòng), đừng spec kiểu "lưu cả document mỗi lần" — vì hai thiết bị sửa cùng lúc sẽ ghi đè nhau. Hãy spec ở cấp độ thao tác nhỏ nhất có thể.
Ví dụ 2: Một app giao hàng nội thành — bài học idempotency
Giả định một startup logistics tại TP.HCM, gọi là "GiaoNhanh", có 2.000 shipper hoạt động mỗi ngày. Ban đầu, nút "Hoàn thành đơn" gọi thẳng API. Đội vận hành phát hiện mỗi ngày có khoảng 30–40 đơn bị ghi nhận trạng thái "giao thành công" hai lần, gây sai lệch số liệu đối soát tiền với cửa hàng. Nguyên nhân: shipper bấm trong vùng sóng yếu (hầm, cao ốc), request gửi đi nhưng phản hồi rớt, app hiện lỗi, shipper bấm lại.
PM viết lại spec với 3 điều khoản:
- Mỗi lần shipper bấm "Hoàn thành", client sinh một
idempotency_keydạngorder_{id}_complete_{uuid}và lưu vào queue. - Hành động được đưa vào offline queue, UI hiện ngay "Đã hoàn thành" (optimistic), kèm icon đồng hồ nhỏ báo "đang đồng bộ".
- Server kiểm tra idempotency_key: nếu đã xử lý, trả về 200 với kết quả cũ, không tạo bản ghi mới.
Ví dụ 3: Giỏ hàng của một sàn TMĐT — conflict resolution
Một sàn thương mại điện tử (tương tự Shopee/Tiki) cho phép thêm sản phẩm vào giỏ khi offline — người dùng lướt sản phẩm trên tàu Metro Bến Thành – Suối Tiên, mạng chập chờn, vẫn "thêm vào giỏ" được. Vấn đề phát sinh: người dùng thêm 3 món khi offline trên điện thoại, nhưng trước đó họ cũng đã thêm 2 món khác từ máy tính ở nhà (đã sync lên server). Khi điện thoại có mạng lại, sync thế nào?
PM spec quy tắc conflict resolution kiểu merge (hợp nhất) thay vì overwrite (ghi đè): giỏ hàng cuối cùng = union của giỏ offline + giỏ server, với quy tắc nếu trùng sản phẩm thì lấy số lượng lớn hơn. Đồng thời, nếu một sản phẩm trong giỏ offline đã hết hàng lúc sync, hệ thống không xóa lặng lẽ mà giữ lại với nhãn "Hết hàng" để người dùng tự quyết.
Bài học: với giỏ hàng, "last write wins" (bản ghi cuối cùng thắng) là quy tắc sai vì nó làm mất món hàng người dùng đã thêm. Spec phải nêu rõ chiến lược merge theo từng loại dữ liệu — không có quy tắc chung cho mọi trường hợp.
Hướng dẫn từng bước
Đây là khung 7 bước để viết một section "Offline Queue & Sync" trong PRD/Spec của bạn.
Bước 1 — Liệt kê và phân loại hành động. Lập bảng mọi hành động người dùng có thể làm trong tính năng, gắn nhãn mỗi hành động: Queueable / Online-only / Conditional. Đây là phần quan trọng nhất; làm sai ở đây thì cả spec sai.
Bước 2 — Định nghĩa Storage. Với mỗi hành động queueable, ghi rõ: lưu ở đâu (local DB nào), giới hạn dung lượng/số item (ví dụ "tối đa 200 item, hoặc 50MB"), thời hạn (ví dụ "item quá 7 ngày chưa sync thì hủy và báo người dùng"), có cần mã hóa không (dữ liệu nhạy cảm thì có).
Bước 3 — Định nghĩa Trigger sync. Liệt kê tất cả sự kiện kích hoạt đồng bộ: (a) khi network state chuyển từ offline → online; (b) khi app vào foreground; (c) định kỳ background (ví dụ mỗi 15 phút nếu OS cho phép); (d) khi người dùng pull-to-refresh. Nêu rõ chiến lược retry với exponential backoff (giãn dần thời gian thử lại: 2s, 4s, 8s, 16s...) để không "dội bom" server.
Bước 4 — Spec idempotency. Ghi rõ format của idempotency key, ai sinh ra (client), server xử lý key trùng ra sao, và TTL của key trên server (ví dụ giữ 24h).
Bước 5 — Spec thứ tự xử lý. Queue gửi theo FIFO (vào trước ra trước) hay theo độ ưu tiên? Có item nào phụ thuộc item khác không (ví dụ "phải tạo folder xong mới tạo file trong folder")? Nếu có dependency, nêu rõ.
Bước 6 — Spec conflict resolution theo từng loại dữ liệu. Với mỗi loại, chọn chiến lược: Last-write-wins, Merge, Server-wins, hoặc Manual (để người dùng chọn). Giải thích lý do.
Bước 7 — Spec trạng thái UI. Mỗi item trong queue cần một visual state rõ ràng: Pending (chờ sync) / Syncing (đang sync) / Synced (xong) / Failed (thất bại, cần hành động). Mô tả người dùng nhìn thấy gì và làm gì được ở mỗi trạng thái.
Lỗi thường gặp & mẹo
Lỗi 1 — Coi mọi hành động đều queue được. Nhiều PM hào hứng với offline-first rồi spec cả... thanh toán cũng offline. Sai. Hành động tài chính, hành động cần kiểm tra tồn kho/số ghế giới hạn, hành động bảo mật (OTP) phải online-only. Mẹo: với mỗi hành động, tự hỏi "nếu hành động này thất bại sau khi mình đã báo người dùng thành công, hậu quả có nghiêm trọng không?". Nếu có, đừng queue.
Lỗi 2 — Quên idempotency. Đây là lỗi gây hậu quả tài chính thật như ví dụ GiaoNhanh. Mọi hành động queueable có ghi vào server đều bắt buộc có idempotency key. Coi đây là dòng không-được-thiếu trong checklist.
Lỗi 3 — Optimistic UI nhưng không có rollback. Hiển thị "thành công" rồi nhưng sync fail vĩnh viễn, app im lặng — người dùng mất dữ liệu mà không biết. Luôn spec đường đi cho trạng thái Failed: thông báo gì, có nút "Thử lại" không, dữ liệu rollback hay giữ lại để sửa.
Lỗi 4 — Để queue phình vô hạn. Người dùng offline lâu ngày, queue tích hàng nghìn item, app chậm và tốn bộ nhớ. Luôn đặt giới hạn dung lượng và thời hạn (TTL) cho item.
Lỗi 5 — Conflict resolution kiểu "last write wins" cho mọi thứ. Như ví dụ giỏ hàng, đây là mặc định nguy hiểm. Spec chiến lược riêng cho từng loại dữ liệu.
Mẹo vàng: Đừng viết spec offline kiểu trừu tượng. Hãy viết theo kịch bản: "Người dùng làm X khi offline → thấy Y → mạng về sau Z giây → kết quả W". Engineer đọc kịch bản dễ hiểu và dễ test hơn nhiều so với mô tả lý thuyết. Và luôn nhớ phối hợp với QA để liệt kê test case mạng chập chờn (không chỉ "offline hoàn toàn" mà cả "mạng rớt giữa chừng request").
Bài tập thực hành
Hãy viết một section "Offline Queue & Sync" cho tính năng sau:
> Bối cảnh: Một app học tiếng Anh (giả định tên "EngGo") muốn cho phép người dùng làm bài tập trắc nghiệm khi offline — vì nhiều học viên học trên xe buýt, tàu Metro, nơi sóng yếu. Mỗi bài có 10 câu. Sau khi làm xong, điểm và tiến độ phải được lưu lên server để đồng bộ giữa điện thoại và máy tính.
Yêu cầu của bạn:
- Lập bảng phân loại hành động (làm câu hỏi, nộp bài, xem giải thích, mua gói premium...) thành Queueable / Online-only / Conditional.
- Định nghĩa Storage: lưu đáp án ở đâu, giới hạn bao nhiêu, TTL bao lâu.
- Spec idempotency key cho hành động "nộp bài".
- Spec conflict resolution: nếu người dùng làm cùng một bài ở cả điện thoại (offline) lẫn máy tính (online), điểm nào được giữ? Giải thích lý do bạn chọn.
- Mô tả 4 trạng thái UI (Pending/Syncing/Synced/Failed) mà học viên nhìn thấy.
quiz_{id}_attempt_{uuid}; và conflict resolution nên là "giữ điểm cao hơn" hoặc "giữ lần nộp gần nhất theo timestamp" — miễn là bạn giải thích được lý do, không có đáp án duy nhất đúng.Tóm tắt
Offline Queue & Sync không phải tính năng "có thì tốt" mà là yêu cầu thiết yếu cho người dùng Việt Nam và Đông Nam Á, nơi kết nối mạng luôn chập chờn. Là PM, bạn không code phần này nhưng bạn quyết định nó qua spec. Hãy nhớ:
- Phân loại hành động trước tiên: Queueable / Online-only / Conditional. Đừng queue thứ không nên queue (thanh toán, OTP, tồn kho giới hạn).
- Bốn thành phần bắt buộc: Storage (lưu ở đâu, giới hạn, TTL), Trigger (khi nào sync, retry với backoff), Idempotency (key duy nhất chống nhân đôi), Conflict resolution (merge/last-write/server-wins theo từng loại dữ liệu).
- Idempotency key là dòng không-được-thiếu với mọi hành động ghi lên server — nó cứu bạn khỏi đơn trùng, điểm trùng, tiền sai.
- Optimistic UI phải đi kèm rollback và trạng thái Failed rõ ràng — đừng để người dùng tưởng thành công rồi mất dữ liệu trong im lặng.
- Viết spec theo kịch bản, không theo lý thuyết: "làm X offline → thấy Y → mạng về → kết quả W".