Tại Sao Offline-First Không Còn Là "Nice-To-Have" Nữa?
Nếu bạn đã theo dõi series của mình — từ Expo SDK 55, tối ưu hiệu năng, quản lý state, debug, testing, navigation cho đến xác thực sinh trắc học — thì bạn đã có một bộ kỹ năng khá toàn diện để xây dựng app React Native chuyên nghiệp rồi. Nhưng có một câu hỏi mà mình nhận được nhiều nhất: "App chạy ngon trên WiFi, nhưng mất mạng là trắng xóa — làm sao để app hoạt động offline?"
Thật ra, chuyện này xảy ra thường xuyên hơn bạn nghĩ. Theo thống kê năm 2026, người dùng di động trung bình mất kết nối internet khoảng 30-45 phút mỗi ngày — đi tàu điện ngầm, vào thang máy, đi qua vùng sóng yếu, hay đơn giản là mạng chập chờn. Mình từng demo app cho khách hàng ngay trong tòa nhà có WiFi yếu, và ứng dụng cứ loading mãi — khá xấu hổ nói thật.
Với các ứng dụng doanh nghiệp — quản lý kho, y tế, bán hàng tại hiện trường — offline không phải tùy chọn mà là yêu cầu bắt buộc.
Bài viết này mình sẽ hướng dẫn bạn xây dựng một ứng dụng offline-first hoàn chỉnh với Expo SDK 55, expo-sqlite, MMKV, Legend State v3, và Outbox Pattern. Từ kiến trúc đến code chạy được, kèm benchmark và chiến lược xử lý conflict. Nào, bắt đầu thôi.
Kiến Trúc Offline-First: Nguyên Tắc Cốt Lõi
Trước khi nhảy vào code, hãy nắm vững ba nguyên tắc nền tảng đã:
- Local-first: UI luôn đọc từ database local. Server sync chạy ở background — người dùng không bao giờ phải chờ network request để thấy dữ liệu.
- Optimistic UI: Khi người dùng thao tác (thêm, sửa, xóa), thay đổi được áp dụng ngay lên giao diện và lưu vào local database. Trạng thái "đang đồng bộ" được hiển thị rõ ràng.
- Atomic Outbox: Mỗi mutation ghi vào bảng dữ liệu chính VÀ bảng outbox trong cùng một transaction. Điều này đảm bảo không bao giờ mất thao tác của người dùng — và đây là phần quan trọng nhất.
Flow tổng thể trông sẽ như thế này:
User Action
→ Ghi vào SQLite (data + outbox) trong 1 transaction
→ UI cập nhật ngay từ local data
→ Background: Kiểm tra network → Flush outbox → Gửi lên server
→ Server phản hồi → Đánh dấu outbox "done"
→ Nếu offline → Retry khi có mạng (exponential backoff)
So Sánh Giải Pháp Lưu Trữ Local: Chọn Đúng Tool Cho Đúng Việc
Không phải mọi dữ liệu đều nên nhét vào cùng một chỗ. Dưới đây là benchmark thực tế năm 2026 trên iPhone 15 với các thư viện phổ biến nhất — con số nói lên tất cả:
Benchmark: 1.000 thao tác đọc
| Thư viện | Tốc độ tương đối | Ghi chú |
|---|---|---|
| MMKV v4.3 | Nhanh nhất (baseline) | Đọc đồng bộ, ~0.52ms/thao tác |
| expo-sqlite v55 | ~3-4x chậm hơn MMKV | Hỗ trợ query phức tạp |
| WatermelonDB v0.27 | ~4x chậm hơn MMKV | Thread riêng, reactive |
| AsyncStorage | ~20x chậm hơn MMKV | Không nên dùng cho offline-first |
Chênh lệch 20x giữa AsyncStorage và MMKV là khá kinh khủng. Nếu bạn đang dùng AsyncStorage cho offline data, đã đến lúc migrate rồi đấy.
Khi nào dùng gì?
| Loại dữ liệu | Giải pháp | Lý do |
|---|---|---|
| Settings, tokens, flags | MMKV | Key-value đồng bộ, cực nhanh, mã hóa AES-256 |
| Dữ liệu có quan hệ (tasks, orders, users) | expo-sqlite | SQL queries, transactions, FTS5, changeset sync |
| Cache API responses | MMKV + Legend State | Tự động persist + retry sync |
| Dữ liệu lớn, reactive | WatermelonDB | Lazy loading, separate thread (nhưng không cập nhật từ 2023) |
Khuyến nghị năm 2026: Combo expo-sqlite + MMKV phủ được hầu hết use case. WatermelonDB vẫn mạnh về mặt kỹ thuật, nhưng thật lòng mà nói — thư viện không có bản cập nhật từ tháng 10/2023 khiến mình hơi ngại khi recommend cho dự án mới.
Thiết Lập Dự Án Expo Offline-First
OK, giờ vào phần thực hành. Mình sẽ xây một ứng dụng Task Manager offline-first — người dùng có thể tạo, sửa, xóa task khi không có mạng, và tất cả tự động đồng bộ khi có kết nối trở lại.
Cài đặt dependencies
npx create-expo-app@latest OfflineTaskManager --template blank-typescript
cd OfflineTaskManager
# Database & Storage
npx expo install expo-sqlite
npm install [email protected] react-native-nitro-modules
# State Management & Sync
npm install @legendapp/[email protected]
# Network Detection
npx expo install @react-native-community/netinfo
# Crypto for UUIDs
npx expo install expo-crypto
Lưu ý: MMKV v4 yêu cầu react-native >= 0.75.0 và react-native-nitro-modules >= 0.35.0. Nếu bạn đang dùng Expo SDK 55 thì khỏi lo — tất cả đều tương thích sẵn.
Cấu trúc thư mục
src/
├── db/
│ ├── schema.ts # SQLite schema & migrations
│ ├── database.ts # Database singleton
│ └── outbox.ts # Outbox sync engine
├── hooks/
│ ├── useNetworkStatus.ts
│ └── useTasks.ts
├── stores/
│ └── taskStore.ts # Legend State observable
├── services/
│ └── syncService.ts # Background sync coordinator
├── components/
│ ├── TaskList.tsx
│ ├── TaskItem.tsx
│ └── SyncStatusBar.tsx
└── app/
└── index.tsx
Cấu trúc khá rõ ràng — tách biệt data layer, state management, và UI. Mình thích cách tổ chức này vì khi debug sync issues (và tin mình đi, bạn sẽ debug sync issues), bạn biết chính xác nên nhìn vào đâu.
Xây Dựng Data Layer Với expo-sqlite
Schema và Migrations
// src/db/schema.ts
import * as SQLite from 'expo-sqlite';
export async function initDatabase(db: SQLite.SQLiteDatabase) {
await db.execAsync(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
is_completed INTEGER NOT NULL DEFAULT 0,
priority TEXT NOT NULL DEFAULT 'medium',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'pending'
);
CREATE TABLE IF NOT EXISTS outbox (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
payload TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS outbox_idem_idx
ON outbox(idempotency_key);
CREATE INDEX IF NOT EXISTS outbox_status_next_idx
ON outbox(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS tasks_sync_idx
ON tasks(sync_status);
`);
}
Mấy cái index ở cuối quan trọng lắm đấy — đặc biệt là outbox_status_next_idx. Không có nó, mỗi lần flush outbox sẽ scan toàn bộ bảng, và khi outbox có vài trăm entries thì bạn sẽ thấy lag rõ rệt.
Database Singleton
// src/db/database.ts
import * as SQLite from 'expo-sqlite';
import { initDatabase } from './schema';
let db: SQLite.SQLiteDatabase | null = null;
export async function getDatabase(): Promise<SQLite.SQLiteDatabase> {
if (!db) {
db = await SQLite.openDatabaseAsync('tasks.db');
await initDatabase(db);
}
return db;
}
Outbox Pattern: Không Bao Giờ Mất Dữ Liệu Người Dùng
Đây là trái tim của toàn bộ kiến trúc offline-first. Ý tưởng cốt lõi đơn giản thôi: mỗi khi người dùng thao tác, chúng ta ghi đồng thời vào bảng dữ liệu chính và bảng outbox trong một transaction duy nhất. Nếu transaction thành công, cả hai đều có. Nếu thất bại, cả hai đều không có. Sạch sẽ.
Outbox Engine
// src/db/outbox.ts
import * as SQLite from 'expo-sqlite';
import * as Crypto from 'expo-crypto';
import { getDatabase } from './database';
interface OutboxEntry {
id: string;
type: string;
payload: string;
idempotency_key: string;
status: string;
attempt_count: number;
next_attempt_at: number;
created_at: number;
}
// Ghi task mới + outbox entry trong 1 transaction
export async function createTaskOffline(task: {
title: string;
description: string;
priority: string;
}) {
const db = await getDatabase();
const id = Crypto.randomUUID();
const now = Date.now();
await db.withTransactionAsync(async () => {
// 1. Ghi vào bảng tasks
await db.runAsync(
`INSERT INTO tasks (id, title, description, priority, created_at, updated_at, sync_status)
VALUES (?, ?, ?, ?, ?, ?, 'pending')`,
[id, task.title, task.description, task.priority, now, now]
);
// 2. Ghi vào outbox — cùng transaction!
await db.runAsync(
`INSERT OR IGNORE INTO outbox (id, type, payload, idempotency_key, status, attempt_count, next_attempt_at, created_at)
VALUES (?, 'create_task', ?, ?, 'pending', 0, 0, ?)`,
[
Crypto.randomUUID(),
JSON.stringify({ id, ...task, created_at: now }),
`task:create:${id}`,
now,
]
);
});
return id;
}
// Cập nhật task + outbox
export async function updateTaskOffline(
taskId: string,
updates: Partial<{ title: string; description: string; is_completed: number; priority: string }>
) {
const db = await getDatabase();
const now = Date.now();
const setClauses: string[] = ['updated_at = ?', "sync_status = 'pending'"];
const values: any[] = [now];
for (const [key, value] of Object.entries(updates)) {
setClauses.push(`${key} = ?`);
values.push(value);
}
values.push(taskId);
await db.withTransactionAsync(async () => {
await db.runAsync(
`UPDATE tasks SET ${setClauses.join(', ')} WHERE id = ?`,
values
);
await db.runAsync(
`INSERT OR IGNORE INTO outbox (id, type, payload, idempotency_key, status, attempt_count, next_attempt_at, created_at)
VALUES (?, 'update_task', ?, ?, 'pending', 0, 0, ?)`,
[
Crypto.randomUUID(),
JSON.stringify({ id: taskId, ...updates, updated_at: now }),
`task:update:${taskId}:${now}`,
now,
]
);
});
}
// Xóa task + outbox
export async function deleteTaskOffline(taskId: string) {
const db = await getDatabase();
const now = Date.now();
await db.withTransactionAsync(async () => {
await db.runAsync(`DELETE FROM tasks WHERE id = ?`, [taskId]);
await db.runAsync(
`INSERT OR IGNORE INTO outbox (id, type, payload, idempotency_key, status, attempt_count, next_attempt_at, created_at)
VALUES (?, 'delete_task', ?, ?, 'pending', 0, 0, ?)`,
[Crypto.randomUUID(), JSON.stringify({ id: taskId }), `task:delete:${taskId}`, now]
);
});
}
Để ý cái INSERT OR IGNORE — idempotency key đảm bảo dù có gọi hàm này nhiều lần (ví dụ do React re-render), outbox entry chỉ tạo một lần duy nhất. Chi tiết nhỏ nhưng tránh được headache lớn.
Sync Service: Flush Outbox Với Exponential Backoff
Sync service chịu trách nhiệm đọc outbox, gửi lên server, và xử lý kết quả. Điểm mình muốn nhấn mạnh ở đây: exponential backoff với jitter. Tại sao lại cần jitter? Hãy tưởng tượng 500 devices cùng mất mạng trong tàu điện ngầm, rồi cùng reconnect khi ra ngoài — không có jitter, server sẽ nhận 500 requests cùng lúc. Không vui đâu.
// src/services/syncService.ts
import { getDatabase } from '../db/database';
import NetInfo from '@react-native-community/netinfo';
const MAX_BACKOFF_MS = 15 * 60 * 1000; // 15 phút
const BATCH_SIZE = 10;
const API_BASE = 'https://api.example.com';
function calculateBackoff(attemptCount: number): number {
const base = Math.min(1000 * Math.pow(2, attemptCount), MAX_BACKOFF_MS);
const jitter = Math.random() * base * 0.3; // 30% jitter
return base + jitter;
}
export async function flushOutbox(): Promise<number> {
const db = await getDatabase();
const now = Date.now();
// Lấy batch entries đã sẵn sàng retry
const entries = await db.getAllAsync<{
id: string;
type: string;
payload: string;
attempt_count: number;
}>(
`SELECT id, type, payload, attempt_count FROM outbox
WHERE status = 'pending' AND next_attempt_at <= ?
ORDER BY created_at ASC LIMIT ?`,
[now, BATCH_SIZE]
);
if (entries.length === 0) return 0;
let syncedCount = 0;
for (const entry of entries) {
try {
const payload = JSON.parse(entry.payload);
// Map outbox type sang API endpoint
const endpoint = getEndpoint(entry.type, payload);
const method = getMethod(entry.type);
const response = await fetch(`${API_BASE}${endpoint}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: method !== 'DELETE' ? JSON.stringify(payload) : undefined,
});
if (response.ok) {
// Thành công — đánh dấu done, cập nhật sync_status của task
await db.withTransactionAsync(async () => {
await db.runAsync(
`UPDATE outbox SET status = 'done' WHERE id = ?`,
[entry.id]
);
if (entry.type !== 'delete_task') {
await db.runAsync(
`UPDATE tasks SET sync_status = 'synced' WHERE id = ?`,
[payload.id]
);
}
});
syncedCount++;
} else if (response.status === 409) {
// Conflict — server trả về version mới hơn
await handleConflict(db, entry, await response.json());
} else {
// Lỗi khác — backoff
await scheduleRetry(db, entry);
}
} catch (error) {
// Network error — backoff
await scheduleRetry(db, entry);
}
}
return syncedCount;
}
async function scheduleRetry(
db: any,
entry: { id: string; attempt_count: number }
) {
const nextAttempt = Date.now() + calculateBackoff(entry.attempt_count);
await db.runAsync(
`UPDATE outbox SET attempt_count = attempt_count + 1, next_attempt_at = ? WHERE id = ?`,
[nextAttempt, entry.id]
);
}
async function handleConflict(db: any, entry: any, serverData: any) {
// Last-Write-Wins: server version thắng
const payload = JSON.parse(entry.payload);
await db.withTransactionAsync(async () => {
await db.runAsync(
`UPDATE tasks SET title = ?, description = ?, is_completed = ?,
priority = ?, updated_at = ?, sync_status = 'synced' WHERE id = ?`,
[
serverData.title, serverData.description,
serverData.is_completed ? 1 : 0, serverData.priority,
serverData.updated_at, payload.id,
]
);
await db.runAsync(`UPDATE outbox SET status = 'conflict_resolved' WHERE id = ?`, [entry.id]);
});
}
function getEndpoint(type: string, payload: any): string {
switch (type) {
case 'create_task': return '/tasks';
case 'update_task': return `/tasks/${payload.id}`;
case 'delete_task': return `/tasks/${payload.id}`;
default: return '/tasks';
}
}
function getMethod(type: string): string {
switch (type) {
case 'create_task': return 'POST';
case 'update_task': return 'PATCH';
case 'delete_task': return 'DELETE';
default: return 'POST';
}
}
Phát Hiện Kết Nối Mạng Và Tự Động Sync
Phần này khá straightforward. Dùng @react-native-community/netinfo v12 để phát hiện thay đổi kết nối và tự động trigger sync khi mạng khôi phục.
// src/hooks/useNetworkStatus.ts
import { useEffect, useRef, useCallback } from 'react';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { flushOutbox } from '../services/syncService';
// Cấu hình NetInfo một lần
NetInfo.configure({
reachabilityUrl: 'https://clients3.google.com/generate_204',
reachabilityTest: async (response) => response.status === 204,
reachabilityLongTimeout: 60_000,
reachabilityShortTimeout: 5_000,
reachabilityRequestTimeout: 15_000,
});
export function useNetworkSync() {
const wasOffline = useRef(false);
const isSyncing = useRef(false);
const triggerSync = useCallback(async () => {
if (isSyncing.current) return;
isSyncing.current = true;
try {
let synced = 0;
do {
synced = await flushOutbox();
} while (synced > 0); // Tiếp tục flush cho đến khi hết
} finally {
isSyncing.current = false;
}
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
const isOnline = state.isConnected && state.isInternetReachable;
if (isOnline && wasOffline.current) {
// Vừa reconnect — flush outbox
triggerSync();
}
wasOffline.current = !isOnline;
});
return () => unsubscribe();
}, [triggerSync]);
return { triggerSync };
}
Cái do...while loop ở trên là quan trọng — nó đảm bảo flush hết tất cả pending entries, không chỉ batch đầu tiên. Mình đã từng quên cái này và thắc mắc tại sao chỉ 10 entries đầu được sync. Bài học nhớ đời.
State Management Với Legend State v3: Persist Và Sync Tự Động
Legend State v3 (3.0.0-beta.42) theo mình là giải pháp state management phù hợp nhất cho offline-first hiện tại — chỉ ~4KB, hỗ trợ persist vào MMKV và retry sync tự động. Nó nhẹ hơn Redux Toolkit rất nhiều mà lại built-in sẵn những thứ bạn cần cho offline.
Cấu hình MMKV Storage
// src/stores/storage.ts
import { createMMKV } from 'react-native-mmkv';
export const appStorage = createMMKV({
id: 'app-offline-storage',
encryptionKey: 'your-encryption-key-here',
});
// Adapter cho Legend State
export const mmkvAdapter = {
getItem: (key: string) => appStorage.getString(key) ?? null,
setItem: (key: string, value: string) => appStorage.set(key, value),
removeItem: (key: string) => appStorage.remove(key),
};
Task Store với Offline Sync
// src/stores/taskStore.ts
import { observable } from '@legendapp/state';
import { synced, syncState, configureSynced } from '@legendapp/state/sync';
import { ObservablePersistMMKV } from '@legendapp/state/persist-plugins/mmkv';
import { getDatabase } from '../db/database';
// Cấu hình mặc định cho tất cả synced observables
const offlineSynced = configureSynced({
persist: {
plugin: ObservablePersistMMKV,
retrySync: true,
},
retry: {
infinite: true,
backoff: 'exponential',
maxDelay: 30000,
},
});
// Observable cho danh sách tasks
export const tasks$ = observable(
offlineSynced({
get: async () => {
const db = await getDatabase();
return db.getAllAsync('SELECT * FROM tasks ORDER BY created_at DESC');
},
persist: { name: 'tasks-cache' },
initial: [],
})
);
// Theo dõi trạng thái sync
export const tasksSyncState$ = syncState(tasks$);
// tasksSyncState$.isLoaded.get()
// tasksSyncState$.error.get()
// tasksSyncState$.lastSync.get()
// Observable cho số lượng pending trong outbox
export const pendingCount$ = observable(
offlineSynced({
get: async () => {
const db = await getDatabase();
const result = await db.getFirstAsync<{ count: number }>(
`SELECT COUNT(*) as count FROM outbox WHERE status = 'pending'`
);
return result?.count ?? 0;
},
persist: { name: 'pending-count' },
initial: 0,
})
);
Giao Diện Offline-Aware: Người Dùng Luôn Biết Chuyện Gì Đang Xảy Ra
Một nguyên tắc mình luôn tuân thủ: đừng bao giờ để người dùng đoán app đang online hay offline, dữ liệu đã sync hay chưa. Nếu họ phải tự hỏi "liệu thay đổi của mình đã được lưu chưa?", bạn đã thất bại về UX rồi.
Sync Status Bar
// src/components/SyncStatusBar.tsx
import React from 'react';
import { View, Text, StyleSheet, Animated } from 'react-native';
import { useNetInfo } from '@react-native-community/netinfo';
import { observer } from '@legendapp/state/react';
import { pendingCount$ } from '../stores/taskStore';
export const SyncStatusBar = observer(function SyncStatusBar() {
const { isConnected, isInternetReachable } = useNetInfo();
const pendingCount = pendingCount$.get();
const isOnline = isConnected && isInternetReachable;
if (isOnline && pendingCount === 0) return null;
return (
<View style={[
styles.container,
isOnline ? styles.syncing : styles.offline
]}>
<Text style={styles.text}>
{!isOnline
? '📡 Offline — thay đổi sẽ tự đồng bộ khi có mạng'
: `⏳ Đang đồng bộ ${pendingCount} thay đổi...`}
</Text>
</View>
);
});
const styles = StyleSheet.create({
container: {
paddingVertical: 6,
paddingHorizontal: 16,
alignItems: 'center',
},
offline: { backgroundColor: '#FFA726' },
syncing: { backgroundColor: '#42A5F5' },
text: { color: '#fff', fontSize: 13, fontWeight: '600' },
});
Status bar chỉ hiện khi cần — offline hoặc đang sync. Khi mọi thứ bình thường thì ẩn đi, không chiếm không gian. UX tốt là UX vô hình khi mọi thứ hoạt động đúng.
Task Item với trạng thái sync
// src/components/TaskItem.tsx
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
interface TaskItemProps {
task: {
id: string;
title: string;
description: string;
is_completed: number;
sync_status: string;
};
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}
export function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
return (
<View style={styles.container}>
<TouchableOpacity
style={styles.content}
onPress={() => onToggle(task.id)}
>
<Text style={[
styles.title,
task.is_completed && styles.completed
]}>
{task.title}
</Text>
<View style={styles.meta}>
{task.sync_status === 'pending' && (
<Text style={styles.pendingBadge}>Chưa đồng bộ</Text>
)}
{task.sync_status === 'synced' && (
<Text style={styles.syncedBadge}>✓ Đã đồng bộ</Text>
)}
</View>
</TouchableOpacity>
<TouchableOpacity onPress={() => onDelete(task.id)}>
<Text style={styles.deleteBtn}>Xóa</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
content: { flex: 1 },
title: { fontSize: 16, color: '#212121' },
completed: { textDecorationLine: 'line-through', color: '#9E9E9E' },
meta: { flexDirection: 'row', marginTop: 4 },
pendingBadge: {
fontSize: 11, color: '#F57C00',
backgroundColor: '#FFF3E0', paddingHorizontal: 6,
paddingVertical: 2, borderRadius: 4,
},
syncedBadge: {
fontSize: 11, color: '#388E3C',
},
deleteBtn: { color: '#E53935', fontSize: 14, padding: 8 },
});
Chiến Lược Xử Lý Conflict: Từ Đơn Giản Đến Nâng Cao
Conflict xảy ra khi cùng một record bị sửa ở cả client và server (hoặc từ device khác). Đây là phần mà nhiều developer hay overthink — nên mình sẽ trình bày ba chiến lược phổ biến, xếp theo độ phức tạp tăng dần. Hãy bắt đầu từ cái đơn giản nhất.
1. Last-Write-Wins (LWW) — Bắt đầu từ đây
Đơn giản nhất: timestamp lớn hơn thắng. Phù hợp cho hầu hết ứng dụng CRUD, và thật lòng, cho 90% apps ngoài kia thì LWW là đủ rồi.
// Server-side (ví dụ Node.js)
async function resolveConflict(clientData, serverData) {
if (clientData.updated_at > serverData.updated_at) {
// Client thắng — áp dụng thay đổi từ client
await db.update('tasks', clientData);
return { resolved: 'client_wins', data: clientData };
}
// Server thắng — trả về data server cho client áp dụng
return { resolved: 'server_wins', data: serverData };
}
2. Field-Level Merge — Chính xác hơn
Thay vì so sánh cả record, so sánh từng field. Nếu client đổi title và server đổi priority, cả hai đều được giữ lại. Cách này thông minh hơn LWW nhưng cần lưu base version để so sánh.
function fieldLevelMerge(clientData, serverData, baseData) {
const merged = { ...serverData };
for (const key of Object.keys(clientData)) {
if (key === 'id' || key === 'updated_at') continue;
const clientChanged = clientData[key] !== baseData[key];
const serverChanged = serverData[key] !== baseData[key];
if (clientChanged && !serverChanged) {
// Chỉ client thay đổi — dùng giá trị client
merged[key] = clientData[key];
} else if (clientChanged && serverChanged) {
// Cả hai thay đổi cùng field — LWW cho field đó
merged[key] = clientData.updated_at > serverData.updated_at
? clientData[key]
: serverData[key];
}
// Nếu chỉ server thay đổi — giữ nguyên (đã có trong merged)
}
return merged;
}
3. CRDTs Với CR-SQLite — Tự động, không conflict
CR-SQLite (v0.16.3) biến bảng SQLite thành Conflict-free Replicated Data Types. Mỗi cột trở thành một LWW Register với Lamport timestamp — merge hoàn toàn tự động, không cần logic xử lý conflict nào cả.
// Kích hoạt CRDT cho bảng tasks
await db.execAsync(`SELECT crsql_as_crr('tasks')`);
// Lấy changes từ lần sync cuối
const changes = await db.getAllAsync(
`SELECT "table", "pk", "cid", "val", "col_version", "db_version", "site_id"
FROM crsql_changes
WHERE db_version > ? AND site_id IS NULL`,
[lastSyncVersion]
);
// Áp dụng changes từ server (tự động merge, không conflict)
for (const change of serverChanges) {
await db.runAsync(
`INSERT INTO crsql_changes ("table", "pk", "cid", "val", "col_version", "db_version", "site_id")
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[change.table, change.pk, change.cid, change.val,
change.col_version, change.db_version, change.site_id]
);
}
Lưu ý: CR-SQLite với expo-sqlite vẫn đang ở giai đoạn thử nghiệm. Cho production, mình khuyên bắt đầu với LWW hoặc field-level merge trước, rồi chuyển sang CRDTs khi ứng dụng thực sự cần multi-device sync phức tạp. Đừng overengineer sớm.
Testing Offline Scenarios
Test offline là phần hay bị bỏ qua nhất — mình hiểu, vì nó không "hào nhoáng" và setup hơi mất công. Nhưng nếu bạn skip phần này, bug sẽ đến từ production chứ không phải từ CI. Dưới đây là cách test hiệu quả với Jest và Detox.
Unit Test: Outbox Pattern
// __tests__/outbox.test.ts
import * as SQLite from 'expo-sqlite';
import { createTaskOffline, updateTaskOffline } from '../src/db/outbox';
describe('Outbox Pattern', () => {
let db: SQLite.SQLiteDatabase;
beforeEach(async () => {
db = await SQLite.openDatabaseAsync(':memory:');
await initDatabase(db);
});
it('tạo task và outbox entry trong cùng transaction', async () => {
const id = await createTaskOffline({
title: 'Test task',
description: 'Mô tả',
priority: 'high',
});
const tasks = await db.getAllAsync('SELECT * FROM tasks');
const outbox = await db.getAllAsync('SELECT * FROM outbox');
expect(tasks).toHaveLength(1);
expect(outbox).toHaveLength(1);
expect(tasks[0].sync_status).toBe('pending');
expect(outbox[0].type).toBe('create_task');
});
it('không tạo duplicate outbox với idempotency key', async () => {
// INSERT OR IGNORE đảm bảo không duplicate
await createTaskOffline({ title: 'Task 1', description: '', priority: 'low' });
const outbox = await db.getAllAsync('SELECT * FROM outbox');
expect(outbox).toHaveLength(1);
});
});
E2E Test Với Detox: Giả Lập Offline
// e2e/offline.test.ts
describe('Offline Flow', () => {
it('tạo task khi offline và sync khi online', async () => {
// 1. Tắt WiFi
await device.setURLBlacklist(['.*api.example.com.*']);
// 2. Tạo task
await element(by.id('add-task-btn')).tap();
await element(by.id('task-title-input')).typeText('Task offline');
await element(by.id('save-btn')).tap();
// 3. Verify task xuất hiện với badge "Chưa đồng bộ"
await expect(element(by.text('Task offline'))).toBeVisible();
await expect(element(by.text('Chưa đồng bộ'))).toBeVisible();
// 4. Bật lại mạng
await device.setURLBlacklist([]);
// 5. Verify sync hoàn tất
await waitFor(element(by.text('✓ Đã đồng bộ')))
.toBeVisible()
.withTimeout(10000);
});
});
Checklist Triển Khai Production
Trước khi ship ứng dụng offline-first ra production, chạy qua danh sách này. Mình đã học được hầu hết từ những lần deploy đau thương:
- WAL mode đã bật —
PRAGMA journal_mode = WALcho phép đọc/ghi đồng thời. Thiếu cái này thì app sẽ lock database khi sync. - Outbox có TTL — Xóa entries cũ hơn 30 ngày để database không phình to vô hạn.
- Retry có giới hạn — Sau N lần thất bại (ví dụ 20 lần), chuyển status sang
'failed'và notify người dùng. Đừng retry mãi mãi. - Database migration — Dùng version number trong
PRAGMA user_versionđể quản lý schema changes. - Encryption — Bật
useSQLCipher: truetrong expo-sqlite config plugin nếu lưu dữ liệu nhạy cảm. - Storage monitoring — Theo dõi kích thước database và outbox queue depth. Alert nếu vượt ngưỡng.
- Conflict analytics — Log tỷ lệ conflict để phát hiện vấn đề thiết kế sớm. Nếu conflict rate cao hơn 5%, có thể bạn cần xem lại data model.
- Graceful degradation — Nếu database bị corrupted, có fallback để tạo lại từ server. Chuyện này hiếm nhưng không phải không xảy ra.
Câu Hỏi Thường Gặp (FAQ)
Nên dùng AsyncStorage hay MMKV cho offline-first?
MMKV, luôn luôn MMKV. MMKV v4.3 nhanh hơn AsyncStorage 20-30 lần, hỗ trợ đọc/ghi đồng bộ (không cần await), và có mã hóa AES-256 tích hợp. AsyncStorage chỉ nên dùng nếu bạn cần tương thích với Expo Go mà không dùng development build — ngoài ra thì không có lý do gì chọn nó.
Offline-first có cần backend riêng không? Dùng được với Firebase/Supabase không?
Hoàn toàn dùng được. Firebase Firestore đã có offline persistence tích hợp sẵn. Supabase có thể kết hợp với Legend State qua plugin @legendapp/state/sync-plugins/supabase. Outbox pattern trong bài viết này là cho trường hợp bạn có REST/GraphQL API riêng và cần kiểm soát hoàn toàn logic sync — cái mà Firebase hay Supabase không cho bạn.
Làm sao xử lý khi người dùng xóa app rồi cài lại?
Database local sẽ bị xóa sạch. Khi app khởi động lần đầu sau khi cài lại, bạn cần: (1) Xác thực người dùng, (2) Pull toàn bộ dữ liệu từ server về local database, (3) Thiết lập lại outbox trống. Đây chính là lý do server luôn phải là source of truth cuối cùng — local database chỉ là "cache thông minh".
WatermelonDB hay expo-sqlite — cái nào tốt hơn cho offline-first năm 2026?
Với dự án mới năm 2026, mình khuyên expo-sqlite. Lý do: (1) expo-sqlite được Expo team maintain tích cực với bản mới nhất v55.0.11, (2) Hỗ trợ tagged template literals, KV Store, và Session API cho changeset sync, (3) Tích hợp tự nhiên với Expo ecosystem. WatermelonDB vẫn mạnh về lazy loading và reactive queries, nhưng bản cập nhật cuối là tháng 10/2023 — và trong thế giới React Native, 2+ năm không cập nhật là rủi ro thực sự.
Outbox pattern có làm chậm app không?
Gần như không. Mỗi INSERT vào outbox thêm khoảng ~0.5-1ms vào transaction — người dùng hoàn toàn không thể nhận ra. Điều quan trọng nhất là ghi UI data và outbox entry trong cùng một transaction (withTransactionAsync) để đảm bảo tính nhất quán. Background sync chạy hoàn toàn độc lập với UI thread, nên performance không bị ảnh hưởng.