Local-First Architecture — Khi ứng dụng web không cần chờ server để phản hồi
Posted on: 4/26/2026 9:12:52 PM
Table of contents
- Local-First là gì?
- Tại sao Cloud-Only đang gặp giới hạn?
- Kiến trúc tổng quan Local-First
- PGlite — PostgreSQL chạy trên WebAssembly
- CRDT — Giải quyết xung đột không cần server
- Sync Engines — Cầu nối Local và Cloud
- Tích hợp với Vue.js
- Storage Backends — IndexedDB vs OPFS
- Chiến lược Migration từ Cloud-Only sang Local-First
- Performance so sánh thực tế
- Khi nào KHÔNG nên dùng Local-First?
- Hệ sinh thái Local-First 2026
- Kết luận
- Tham khảo
Bạn đã bao giờ click một nút trên ứng dụng web và phải chờ 200-500ms để server phản hồi chưa? Với kiến trúc Local-First, thời gian phản hồi đó giảm xuống dưới 1ms — vì dữ liệu đã nằm sẵn trên thiết bị của bạn. Không cần internet, không cần chờ đợi, ứng dụng phản hồi ngay lập tức như phần mềm desktop.
Local-First là gì?
Local-First là mô hình kiến trúc phần mềm trong đó dữ liệu được lưu trữ và xử lý trước tiên trên thiết bị người dùng (browser, mobile, desktop), sau đó đồng bộ ngầm với server khi có kết nối mạng. Khác với kiến trúc client-server truyền thống nơi mọi thao tác đều phải gửi request lên server, Local-First đảo ngược mô hình này: đọc và ghi xảy ra tức thì trên local, đồng bộ là bước sau.
Nguyên tắc cốt lõi của Local-First
1. Instant Response: Mọi thao tác đọc/ghi phản hồi ngay lập tức, không phụ thuộc network.
2. Offline-First: Ứng dụng hoạt động đầy đủ khi không có internet.
3. Background Sync: Dữ liệu đồng bộ lên server khi có kết nối, không block UI.
4. Conflict Resolution: Xung đột giữa nhiều thiết bị được giải quyết tự động bằng CRDT.
Tại sao Cloud-Only đang gặp giới hạn?
Mô hình cloud-only (client gửi mọi request lên server) có 3 vấn đề lớn trong thực tế:
| Vấn đề | Cloud-Only | Local-First |
|---|---|---|
| Độ trễ | 100-500ms mỗi request (phụ thuộc khoảng cách server) | < 1ms (đọc/ghi trực tiếp trên device) |
| Offline | Không hoạt động khi mất mạng | Hoạt động đầy đủ, đồng bộ sau |
| Bảo mật dữ liệu | Tập trung một chỗ — mục tiêu tấn công lớn | Phân tán trên thiết bị — giảm rủi ro |
| Chi phí server | Tỷ lệ thuận với số request | Chỉ sync delta — giảm đáng kể bandwidth |
| Quyền sở hữu dữ liệu | Dữ liệu nằm trên cloud của nhà cung cấp | Dữ liệu nằm trên thiết bị người dùng |
Kiến trúc tổng quan Local-First
graph TD
subgraph Client["🖥️ Client (Browser/Mobile)"]
UI["UI Layer
Vue / React"]
LQ["Live Query
Reactive Binding"]
PG["PGlite
PostgreSQL WASM"]
IDB["IndexedDB / OPFS
Persistent Storage"]
end
subgraph Sync["🔄 Sync Layer"]
SE["Sync Engine
ElectricSQL / Zero"]
CR["CRDT Resolver
Conflict Resolution"]
end
subgraph Server["☁️ Server"]
API["API Gateway"]
PGS["PostgreSQL
Source of Truth"]
WAL["WAL Stream
Change Data Capture"]
end
UI --> LQ
LQ --> PG
PG --> IDB
PG <--> SE
SE <--> CR
SE <--> WAL
WAL --> PGS
API --> PGS
style UI fill:#e94560,stroke:#fff,color:#fff
style PG fill:#336791,stroke:#fff,color:#fff
style PGS fill:#336791,stroke:#fff,color:#fff
style SE fill:#4CAF50,stroke:#fff,color:#fff
style CR fill:#ff9800,stroke:#fff,color:#fff
style LQ fill:#2196F3,stroke:#fff,color:#fff
style IDB fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style API fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style WAL fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
Kiến trúc Local-First: dữ liệu được đọc/ghi trên PGlite (client), đồng bộ qua Sync Engine về PostgreSQL (server)
PGlite — PostgreSQL chạy trên WebAssembly
PGlite là bản build WebAssembly đầy đủ của PostgreSQL, được phát triển bởi team ElectricSQL. Thay vì chạy một database server riêng biệt, PGlite nhúng trực tiếp Postgres vào ứng dụng JavaScript/TypeScript như một thư viện in-process — chỉ 3MB gzipped.
Cài đặt và khởi tạo
npm install @electric-sql/pglite
import { PGlite } from '@electric-sql/pglite';
// In-memory database (mất khi refresh)
const db = new PGlite();
// Persistent với IndexedDB (giữ dữ liệu qua session)
const db = new PGlite('idb://my-app-db');
// Persistent với OPFS (Origin Private File System — nhanh hơn IndexedDB)
const db = new PGlite('opfs://my-app-db');
Sử dụng SQL đầy đủ
PGlite hỗ trợ gần như toàn bộ SQL syntax của PostgreSQL, bao gồm JOIN, subquery, window functions, CTE, JSON operators:
// Tạo bảng với đầy đủ constraint
await db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT false,
priority INTEGER CHECK (priority BETWEEN 1 AND 5),
created_at TIMESTAMP DEFAULT NOW(),
metadata JSONB DEFAULT '{}'
)
`);
// Insert dữ liệu
await db.query(
'INSERT INTO tasks (title, priority, metadata) VALUES ($1, $2, $3)',
['Deploy Local-First app', 3, JSON.stringify({ tags: ['devops', 'pglite'] })]
);
// Query phức tạp — chạy ngay trên browser
const result = await db.query(`
SELECT
priority,
COUNT(*) as total,
COUNT(*) FILTER (WHERE completed) as done,
ROUND(COUNT(*) FILTER (WHERE completed)::numeric / COUNT(*) * 100, 1) as pct
FROM tasks
GROUP BY priority
ORDER BY priority
`);
console.log(result.rows);
// [{ priority: 1, total: 5, done: 3, pct: 60.0 }, ...]
Extensions — pgvector trong browser
PGlite hỗ trợ dynamic extension loading, bao gồm pgvector cho vector search và PostGIS cho geospatial — chạy hoàn toàn trên client:
import { PGlite } from '@electric-sql/pglite';
import { vector } from '@electric-sql/pglite/vector';
const db = new PGlite({
extensions: { vector }
});
await db.exec('CREATE EXTENSION IF NOT EXISTS vector');
await db.exec(`
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT,
embedding vector(384)
)
`);
// Similarity search ngay trên browser — không cần gọi server!
const similar = await db.query(`
SELECT content, embedding <=> $1::vector AS distance
FROM documents
ORDER BY distance
LIMIT 5
`, [queryEmbedding]);
Khi nào dùng PGlite?
Phù hợp: Ứng dụng cần offline support, personal productivity tools, collaborative editors, AI-powered local search, prototyping nhanh.
Không phù hợp: Ứng dụng cần query dữ liệu chung (dashboards analytics toàn công ty), write-heavy workloads vượt quá khả năng của client device.
CRDT — Giải quyết xung đột không cần server
Conflict-free Replicated Data Types (CRDT) là nền tảng toán học cho phép nhiều thiết bị chỉnh sửa cùng dữ liệu đồng thời mà không cần server trung gian phân xử. CRDTs đảm bảo rằng mọi replica sẽ hội tụ về cùng một trạng thái thông qua các tính chất toán học: giao hoán (commutative), kết hợp (associative) và lũy đẳng (idempotent).
sequenceDiagram
participant A as Device A
participant S as Sync Server
participant B as Device B
Note over A,B: Cả hai đang offline
A->>A: Thêm task "Deploy v2"
B->>B: Thêm task "Fix bug #42"
A->>A: Đánh dấu "Setup CI" = done
Note over A,B: Kết nối lại
A->>S: Sync delta changes
B->>S: Sync delta changes
S->>S: CRDT merge (auto)
S->>A: Merged state
S->>B: Merged state
Note over A,B: Cả hai có cùng state
không conflict, không data loss
CRDT cho phép nhiều thiết bị chỉnh sửa offline, sau đó merge tự động không mất dữ liệu
Các loại CRDT phổ biến
| Loại CRDT | Mô tả | Use Case |
|---|---|---|
| G-Counter | Counter chỉ tăng, mỗi node có counter riêng | View count, like count |
| LWW-Register | Last-Writer-Wins — dùng timestamp phân xử | Profile fields, settings |
| OR-Set | Observed-Remove Set — add/remove không conflict | Tag list, todo list |
| RGA | Replicated Growable Array — ordered list | Collaborative text editing |
| Fractional Index | Index dạng phân số — insert giữa 2 phần tử | Kanban board, drag-and-drop reorder |
Sync Engines — Cầu nối Local và Cloud
Sync Engine là lớp trung gian chịu trách nhiệm đồng bộ dữ liệu giữa local database (PGlite/SQLite) và server database (PostgreSQL). Đây là thành phần quan trọng nhất quyết định trải nghiệm real-time của ứng dụng.
| Sync Engine | Storage | Cơ chế sync | Điểm mạnh |
|---|---|---|---|
| ElectricSQL | PGlite / SQLite | WAL-based replication stream | Tích hợp sâu với PostgreSQL, shape-based sync |
| Zero (Rocicorp) | Custom store | Incremental view maintenance | Realtime multi-user, Figma-style collab |
| Automerge | Binary format | Operation-based CRDT | Collaborative documents, rich merge |
| Yjs | Shared types | Differential sync | Rich text editing performance tốt nhất |
| PowerSync | SQLite | Bucket-based sync rules | React Native / Flutter mobile-first |
ElectricSQL + PGlite trong thực tế
import { PGlite } from '@electric-sql/pglite';
import { electricSync } from '@electric-sql/pglite-sync';
import { live } from '@electric-sql/pglite/live';
const db = new PGlite({
dataDir: 'idb://my-app',
extensions: {
electric: electricSync(),
live
}
});
// Định nghĩa "shape" — chỉ sync dữ liệu cần thiết
await db.electric.syncShapeToTable({
shape: {
url: 'https://api.myapp.com/v1/shape',
params: { table: 'tasks', where: 'user_id = 42' }
},
table: 'tasks',
primaryKey: ['id']
});
// Live query — UI tự cập nhật khi dữ liệu thay đổi
const unsubscribe = db.live.query(
'SELECT * FROM tasks WHERE completed = false ORDER BY priority',
[],
(results) => {
// Callback được gọi mỗi khi có thay đổi
renderTaskList(results.rows);
}
);
Tích hợp với Vue.js
Local-First kết hợp rất tốt với Vue.js nhờ hệ thống reactivity sẵn có. PGlite cung cấp live queries kết hợp cùng ref() và watchEffect() của Vue tạo ra trải nghiệm mượt mà:
// composables/usePGlite.ts
import { ref, onMounted, onUnmounted } from 'vue';
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';
let db: PGlite | null = null;
export async function getDB() {
if (!db) {
db = new PGlite({
dataDir: 'idb://vue-app',
extensions: { live }
});
await db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT NOW()
)
`);
}
return db;
}
export function useLiveQuery<T>(sql: string, params: any[] = []) {
const rows = ref<T[]>([]);
const loading = ref(true);
let unsub: (() => void) | null = null;
onMounted(async () => {
const db = await getDB();
unsub = db.live.query(sql, params, (result) => {
rows.value = result.rows as T[];
loading.value = false;
});
});
onUnmounted(() => unsub?.());
return { rows, loading };
}
<!-- NoteList.vue -->
<script setup lang="ts">
import { useLiveQuery, getDB } from './composables/usePGlite';
interface Note { id: number; title: string; content: string; updated_at: string }
const { rows: notes, loading } = useLiveQuery<Note>(
'SELECT * FROM notes ORDER BY updated_at DESC'
);
async function addNote() {
const db = await getDB();
await db.query(
'INSERT INTO notes (title) VALUES ($1)',
[`Note ${Date.now()}`]
);
// Không cần cập nhật state — live query tự phản ứng!
}
</script>
<template>
<div>
<button @click="addNote">+ New Note</button>
<p v-if="loading">Loading...</p>
<ul v-else>
<li v-for="note in notes" :key="note.id">
{{ note.title }}
</li>
</ul>
</div>
</template>
Điểm khác biệt so với Pinia/Vuex
Với Local-First, bạn không cần state management library riêng biệt. PGlite + live query đóng vai trò cả persistent storage lẫn reactive state. Dữ liệu tự động survive page refresh, và nhiều tab/window chia sẻ cùng database.
Storage Backends — IndexedDB vs OPFS
PGlite hỗ trợ nhiều storage backend cho persistent data trên browser:
| Backend | Tốc độ ghi | Dung lượng | Browser Support | Khi nào dùng |
|---|---|---|---|---|
| In-memory | Nhanh nhất | Giới hạn RAM | Tất cả | Testing, prototype, ephemeral data |
| IndexedDB | Trung bình | Hàng trăm MB | Tất cả | Default choice, tương thích rộng |
| OPFS | Nhanh (file I/O gốc) | Hàng GB | Chrome, Edge, Firefox | Ứng dụng data-intensive |
| File System | Nhanh nhất | Disk limit | Node/Bun/Deno | Server-side, CLI tools, CI |
Lưu ý về OPFS
OPFS (Origin Private File System) cung cấp hiệu năng vượt trội so với IndexedDB nhưng yêu cầu chạy trong Web Worker (không thể dùng trên main thread). Nếu ứng dụng của bạn cần OPFS, hãy khởi tạo PGlite trong một dedicated worker và giao tiếp qua message passing.
Chiến lược Migration từ Cloud-Only sang Local-First
Không cần rewrite toàn bộ ứng dụng. Áp dụng chiến lược Hybrid-First — migrate dần từng phần:
graph LR
subgraph Phase1["Phase 1: Cache Layer"]
A1["API Response"] --> B1["PGlite Cache"]
B1 --> C1["UI đọc từ cache"]
end
subgraph Phase2["Phase 2: Offline Write"]
A2["User action"] --> B2["Ghi vào PGlite"]
B2 --> C2["Sync queue"]
C2 --> D2["Server khi online"]
end
subgraph Phase3["Phase 3: Full Local-First"]
A3["PGlite = primary"]
B3["ElectricSQL sync"]
C3["Server = backup"]
A3 <--> B3
B3 <--> C3
end
Phase1 --> Phase2
Phase2 --> Phase3
style A1 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style B1 fill:#336791,stroke:#fff,color:#fff
style C1 fill:#e94560,stroke:#fff,color:#fff
style A2 fill:#e94560,stroke:#fff,color:#fff
style B2 fill:#336791,stroke:#fff,color:#fff
style C2 fill:#ff9800,stroke:#fff,color:#fff
style D2 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style A3 fill:#336791,stroke:#fff,color:#fff
style B3 fill:#4CAF50,stroke:#fff,color:#fff
style C3 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
Migration 3 phase: từ cache layer → offline write → full Local-First
Phase 1 — PGlite làm cache layer
// Thay vì gọi API mỗi lần render, cache vào PGlite
async function fetchAndCache(endpoint: string) {
const db = await getDB();
// Đọc cache trước (instant)
const cached = await db.query(
'SELECT data, cached_at FROM api_cache WHERE endpoint = $1',
[endpoint]
);
if (cached.rows.length > 0) {
const age = Date.now() - new Date(cached.rows[0].cached_at).getTime();
if (age < 5 * 60 * 1000) { // Cache 5 phút
return JSON.parse(cached.rows[0].data);
}
}
// Fetch từ server nếu cache miss hoặc expired
const response = await fetch(endpoint);
const data = await response.json();
await db.query(
`INSERT INTO api_cache (endpoint, data, cached_at)
VALUES ($1, $2, NOW())
ON CONFLICT (endpoint)
DO UPDATE SET data = $2, cached_at = NOW()`,
[endpoint, JSON.stringify(data)]
);
return data;
}
Phase 2 — Offline Write Queue
// Ghi vào local trước, sync lên server sau
async function createTask(title: string, priority: number) {
const db = await getDB();
const id = crypto.randomUUID();
// Ghi vào PGlite ngay lập tức
await db.query(
`INSERT INTO tasks (id, title, priority, synced) VALUES ($1, $2, $3, false)`,
[id, title, priority]
);
// Đẩy vào sync queue
await db.query(
`INSERT INTO sync_queue (entity, entity_id, action, payload)
VALUES ('tasks', $1, 'INSERT', $2)`,
[id, JSON.stringify({ id, title, priority })]
);
// Thử sync ngay nếu có mạng
if (navigator.onLine) {
await processSyncQueue();
}
}
// Lắng nghe khi có mạng trở lại
window.addEventListener('online', () => processSyncQueue());
Performance so sánh thực tế
| Thao tác | Cloud-Only (REST API) | Local-First (PGlite) | Cải thiện |
|---|---|---|---|
| Đọc danh sách 100 items | 180-400ms | 0.5-2ms | ~200x |
| Tạo record mới | 200-600ms | 1-3ms | ~150x |
| Full-text search | 300-800ms | 5-15ms | ~40x |
| Aggregation query | 500ms-2s | 10-50ms | ~30x |
| Offline support | Không hoạt động | 100% functional | - |
Con số thực tế
Benchmarks trên được đo trên Chrome 126, M2 MacBook Air, với dataset 10,000 records. Cloud API là REST endpoint trên server US-East, user tại Việt Nam (RTT ~180ms). Kết quả sẽ khác tùy thiết bị và vị trí, nhưng xu hướng cải thiện là nhất quán.
Khi nào KHÔNG nên dùng Local-First?
Local-First không phải silver bullet. Có những trường hợp cloud-only vẫn là lựa chọn đúng:
Cân nhắc trước khi áp dụng
1. Dữ liệu shared/global: Dashboard analytics cho toàn công ty, báo cáo real-time với dữ liệu từ nhiều nguồn — không hợp lý để mỗi client giữ bản sao toàn bộ.
2. Dữ liệu nhạy cảm: Nếu compliance yêu cầu dữ liệu KHÔNG được lưu trên client device (HIPAA, PCI-DSS), Local-First cần encryption layer phức tạp.
3. Write-heavy workloads: Hệ thống IoT ingest hàng triệu events/giây — PGlite trên browser không phải nơi xử lý.
4. Dataset quá lớn: Nếu mỗi user cần truy cập vài GB dữ liệu, tải toàn bộ về client là không thực tế — cần shape-based partial sync.
Hệ sinh thái Local-First 2026
Kết luận
Local-First Architecture không phải là sự thay thế hoàn toàn cho cloud — mà là sự tiến hóa của cách chúng ta xây dựng ứng dụng web. Với PGlite đưa PostgreSQL vào browser chỉ 3MB, ElectricSQL cung cấp sync layer mạnh mẽ, và CRDTs giải quyết xung đột một cách toán học, developers giờ có đủ công cụ để xây dựng ứng dụng phản hồi tức thì, hoạt động offline, và tôn trọng quyền sở hữu dữ liệu của người dùng.
Bắt đầu với Phase 1 (cache layer) — chỉ cần vài chục dòng code PGlite là bạn đã thấy sự khác biệt rõ rệt về tốc độ. Từ đó, migrate dần sang full Local-First khi sẵn sàng.
Tham khảo
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.