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

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.

< 1msĐộ trễ đọc dữ liệu local
3MBKích thước PGlite (gzipped)
50+Extensions PostgreSQL hỗ trợ
100%Hoạt động offline

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-OnlyLocal-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)
OfflineKhông hoạt động khi mất mạngHoạt động đầy đủ, đồng bộ sau
Bảo mật dữ liệuTập trung một chỗ — mục tiêu tấn công lớnPhân tán trên thiết bị — giảm rủi ro
Chi phí serverTỷ lệ thuận với số requestChỉ sync delta — giảm đáng kể bandwidth
Quyền sở hữu dữ liệuDữ liệu nằm trên cloud của nhà cung cấpDữ 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 CRDTMô tảUse Case
G-CounterCounter chỉ tăng, mỗi node có counter riêngView count, like count
LWW-RegisterLast-Writer-Wins — dùng timestamp phân xửProfile fields, settings
OR-SetObserved-Remove Set — add/remove không conflictTag list, todo list
RGAReplicated Growable Array — ordered listCollaborative text editing
Fractional IndexIndex 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 EngineStorageCơ chế syncĐiểm mạnh
ElectricSQLPGlite / SQLiteWAL-based replication streamTích hợp sâu với PostgreSQL, shape-based sync
Zero (Rocicorp)Custom storeIncremental view maintenanceRealtime multi-user, Figma-style collab
AutomergeBinary formatOperation-based CRDTCollaborative documents, rich merge
YjsShared typesDifferential syncRich text editing performance tốt nhất
PowerSyncSQLiteBucket-based sync rulesReact 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()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:

BackendTốc độ ghiDung lượngBrowser SupportKhi nào dùng
In-memoryNhanh nhấtGiới hạn RAMTất cảTesting, prototype, ephemeral data
IndexedDBTrung bìnhHàng trăm MBTất cảDefault choice, tương thích rộng
OPFSNhanh (file I/O gốc)Hàng GBChrome, Edge, FirefoxỨng dụng data-intensive
File SystemNhanh nhấtDisk limitNode/Bun/DenoServer-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ácCloud-Only (REST API)Local-First (PGlite)Cải thiện
Đọc danh sách 100 items180-400ms0.5-2ms~200x
Tạo record mới200-600ms1-3ms~150x
Full-text search300-800ms5-15ms~40x
Aggregation query500ms-2s10-50ms~30x
Offline supportKhông hoạt động100% 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

2019
Ink & Switch công bố paper "Local-First Software: You Own Your Data", đặt nền tảng lý thuyết cho phong trào Local-First.
2022
ElectricSQL ra mắt — sync engine đầu tiên kết nối PostgreSQL với local SQLite trên client.
2024
PGlite v0.1 được phát hành — lần đầu tiên chạy full PostgreSQL trên WebAssembly trong browser, chỉ 3MB.
2025
PGlite + ElectricSQL sync hoàn thiện, hỗ trợ shape-based sync, live queries, và pgvector extension. Zero (Rocicorp) ra mắt bản production.
2026
Local-First trở thành mainstream — PGlite hỗ trợ 50+ extensions, OPFS backend, TanStack DB integration. Các framework lớn (Next.js, Nuxt) bắt đầu có official local-first plugins.

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