Kiến trúc local-first: Giữ dữ liệu không lưu trên cloud
Cách mình thiết kế ứng dụng local-first với dữ liệu trên thiết bị, Automerge, đồng bộ tùy chọn và backup để ứng dụng vẫn hữu ích mà không phải đưa dữ liệu làm việc lên cloud.
Chia sẻ

Mình thích những ứng dụng vẫn có cảm giác “thuộc về mình” khi biểu tượng Wi-Fi biến mất. Ghi chú mở ngay, chỉnh sửa không phải chờ spinner và muốn xuất dữ liệu cũng không cần xin phép một dịch vụ nào.
Đó là lời hứa thực tế của kiến trúc local-first: bản trên thiết bị là bản làm việc chính. Server có thể hỗ trợ đồng bộ hoặc backup, nhưng không đứng giữa mọi thao tác đọc và ghi.
Để viết bài này, mình follow Automerge, thư viện CRDT open source, giấy phép MIT, có lõi Rust và binding JavaScript/WASM. Cách tổ chức của dự án vẫn đáng học nếu sau này bạn chọn SQLite hoặc một cơ sở dữ liệu khác: lưu tại chỗ trước, mô tả thay đổi rõ ràng rồi mới đồng bộ các bản sao.
Local-first không phải là cache lớn hơn
Trong ứng dụng cloud thông thường, server giữ bản chuẩn, còn client chỉ có cache có thể bỏ đi. Khi phiên đăng nhập hết hạn hoặc API ngừng hoạt động, cache đó đôi khi chẳng còn dùng được.
Mình thường kiểm tra local-first bằng bốn câu hỏi:
- Tạo, đọc, sửa, tìm kiếm và xóa có chạy khi mất mạng không?
- Đóng rồi mở lại ứng dụng có giữ những thay đổi chưa đồng bộ không?
- Người dùng có thể xuất được dữ liệu ở định dạng đọc được hoặc có tài liệu mô tả không?
- Đồng bộ lỗi chỉ đổi trạng thái, hay làm cả ứng dụng ngừng làm việc?
Nếu ứng dụng chỉ xếp hàng một form khi offline, mình sẽ gọi nó là “có hỗ trợ offline”, chưa phải local-first.

Local-first bắt đầu bằng một lời hứa rất đời thường: bản dữ liệu hữu ích nằm trên thiết bị và vẫn mở được khi mạng không hoạt động.
Đặt kho dữ liệu cục bộ trên luồng chính
Thiết kế gọn nhất khá đơn giản:
- Giao diện gửi lệnh xuống domain layer.
- Domain layer ghi vào IndexedDB, SQLite hoặc một tài liệu CRDT cục bộ.
- Giao diện render ngay trạng thái đã commit trên máy.
- Worker chạy nền trao đổi thay đổi với thiết bị khác hoặc relay sau đó.
Mạng không nằm giữa cú nhấp và lần lưu thành công. Cách này giảm khá nhiều logic rollback của optimistic update, nhưng đổi lại migration, xử lý xung đột, giới hạn dung lượng và khôi phục dữ liệu đều trở thành tính năng thật của sản phẩm.
Luồng đọc và ghi luôn đi qua dữ liệu cục bộ. Đồng bộ chạy nền, không phải điều kiện để dùng ứng dụng.
Ví dụ nhỏ theo cách Automerge tổ chức dữ liệu
Automerge lưu một tài liệu gần giống JSON và ghi lại thay đổi để hai bản được sửa độc lập có thể merge. Một tài liệu ghi chú tối thiểu có thể viết như sau:
import * as Automerge from "@automerge/automerge";
type Note = { id: string; text: string; updatedAt: number };
type NotesDoc = { notes: Note[] };
let doc = Automerge.from<NotesDoc>({ notes: [] });
doc = Automerge.change(doc, "add note", (draft) => {
draft.notes.push({
id: crypto.randomUUID(),
text: "Nội dung này được lưu mà không cần gọi mạng.",
updatedAt: Date.now(),
});
});
const bytes = Automerge.save(doc);
await localDocumentStore.put("notes", bytes); // adapter IndexedDB hoặc file
Khi khởi động, hãy load số byte này trước khi mở kết nối đồng bộ. Khi hai bản sao gặp lại nhau, chúng trao đổi thay đổi rồi lưu tài liệu đã merge xuống máy lần nữa.
CRDT không làm mọi quyết định sản phẩm tự biến mất. Hai người cùng đổi tên một board vẫn cần kết quả dễ hiểu. File đính kèm, quyền truy cập, xóa vĩnh viễn và migration schema cũng cần quy tắc riêng.
Đồng bộ có thể tùy chọn nhưng phải nói rõ
Mình sẽ chọn và công bố rõ một trong ba chế độ:
| Chế độ | Dữ liệu di chuyển thế nào | Phù hợp với |
|---|---|---|
| Một thiết bị | Không tự đồng bộ; xuất file hoặc backup có mã hóa | Nhật ký, dụng cụ xưởng, log cảm biến cá nhân |
| Mạng nội bộ | Peer-to-peer hoặc relay nhỏ tự host | Nhà, phòng lab, nhóm nhỏ trong LAN tin cậy |
| Relay riêng | Thay đổi đã mã hóa đi qua relay Internet | Nhiều thiết bị dùng ở ngoài nhà |
Với relay riêng, chỉ mã hóa đường truyền là chưa đủ nếu lời hứa là “server không đọc được dữ liệu”. Bạn cần mã hóa thay đổi hoặc snapshot ngay trên thiết bị, không đưa khóa lên relay, xác thực từng thiết bị và thiết kế cách khôi phục khóa trước khi quảng cáo end-to-end encryption. Automerge giải quyết merge; nó không tự giải quyết danh tính, phân quyền hay quản lý bí mật.

Ứng dụng vẫn lưu tại chỗ trước; mạng gia đình chỉ cần khi bạn bật đồng bộ chạy nền.
Những phần chán mới quyết định độ tin cậy
Trước khi gọi một ứng dụng là local-first, mình kiểm tra các tình huống này:
- Storage bị dọn: trình duyệt có thể xóa dữ liệu, nên hãy xin persistent storage khi nền tảng hỗ trợ và luôn có chức năng export.
- Ghi bị ngắt giữa chừng: dùng transaction hoặc thay file kiểu atomic; đừng ghi đè trực tiếp lên snapshot tốt duy nhất.
- Migration lỗi: giữ dữ liệu cũ đến khi schema mới mở thành công.
- Mất thiết bị: mã hóa dữ liệu nhạy cảm và nói rõ màn hình khóa không bảo vệ được điều gì.
- Xóa dữ liệu: xác định xóa có xung đột không, tombstone được giữ bao lâu và khi nào mọi bản sao cùng quên một mục.
- Khôi phục backup: phải thử thật. Nút backup mà chưa từng restore chỉ là đồ trang trí.
Bản đầu tiên của mình sẽ thật nhỏ
Mình sẽ bắt đầu với một loại tài liệu, một local adapter và chức năng xuất file mã hóa thủ công. Sau đó, thử chế độ máy bay, buộc tắt ứng dụng giữa lúc ghi, nâng schema và khôi phục trên một thiết bị mới.
Khi phần đó chạy chắc, mình mới thêm peer sync. Local-first không chủ yếu là cài thêm một dependency CRDT; cốt lõi là làm cho bản dữ liệu trên thiết bị đầy đủ, bền và tự nó đã hữu ích. Cloud khi ấy chỉ là người chuyển thư tùy chọn, không còn là chủ sở hữu của mọi cú nhấp.
Nguồn tham khảo và nguồn ảnh
- GitHub - Automerge
- Ink & Switch - Local-first software
- GitHub - Yjs
- Ảnh cover: Lau Clrd trên Unsplash, đã custom cho bài viết.
- Ảnh lưu trữ cục bộ: Samsung Memory US trên Unsplash, đã custom cho bài viết.
- Ảnh đồng bộ: TRIANGLEMZ trên Unsplash đã custom cho bài viết.
Chia sẻ
Tiếp tục khám phá
Đọc tiếp
Bài viết liên quan
Chạy Linux trên ESP32-S31: MMU thay đổi điều gì?
ESP32-S31 đã có Linux BSP chính thức ở dạng Developer Preview. Mình cùng xem MMU, Buildroot, U-Boot, Linux 6.18 và những giới hạn thực tế trước khi chọn board để thử.
TinyML trên ESP32: Chạy AI ngay tại thiết bị
Cách mình chạy một model nhận diện người ngay trên ESP32 bằng TensorFlow Lite Micro và ESP-NN, từ giới hạn bộ nhớ đến cách thử ví dụ person_detection.
Thiết kế hệ thống trạng thái cảm xúc cho robot Mochi
Xây dựng state machine cảm xúc cho Robot Mochi: idle, listening, talking, happy, sad, thinking, error và animation dễ thay thế.