Browser Storage & Offline-First — IndexedDB, Cache API, OPFS, and Service Workers
A principal-level guide to client-side storage APIs, quota/eviction, Service Worker caching strategies, background sync, and offline-first architecture patterns.
Hầu hết frontend engineer có thể localStorage.setItem('theme', 'dark') ngay ngày đầu. Ít người giải thích được vì sao cùng API đó sẽ đơ luồng checkout ở 50k dòng, vì sao browser evict bản nháp của user, hay cách reconcile chỉnh sửa offline khi hai tab ghi cùng record.
Bài này là lớp storage đi kèm HTTP caching và cookies — giả định bạn đã biết Cache-Control và Set-Cookie, tập trung vào client storage lập trình được, theo origin và kiến trúc offline-first.
Bức tranh storage — chọn primitive đúng
Browser expose nhiều API chồng lấn. Chúng khác nhau về dung lượng, sync vs async, dữ liệu có cấu trúc vs opaque, và ai đọc được.
| API | Typical capacity | Sync / async | Structured data | Primary use case |
|---|---|---|---|---|
| Cookies | ~4 KB per cookie, ~180 cookies/domain (browser-dependent) | Sync on every HTTP request | String (name=value) | Session/auth transport to server |
localStorage | ~5 MB per origin (implementation-dependent) | Synchronous on main thread | String keys/values only | Small prefs, feature flags, non-critical UI state |
sessionStorage | Same quota as localStorage, tab-scoped | Synchronous | String only | Ephemeral tab state (wizard step, form scratch) |
| IndexedDB | Large (often hundreds of MB to GB under pressure policy) | Async | Objects, blobs, files, indexes | App data, offline records, binary assets |
| Cache API | Shares origin quota with other storage | Async | Request/Response pairs | Offline assets, API response snapshots |
| OPFS (Origin Private File System) | Shares origin quota | Async; sync handles in workers | Byte files, directories | High-throughput files, WASM DB engines |
Quy tắc ngón tay cái: cookies mang credential lên server; Web Storage giữ chuỗi nhỏ có thể mất; IndexedDB giữ source of truth offline của app; Cache API giữ artifact dạng HTTP; OPFS giữ file bạn
read()/write()như ổ đĩa.
Cookies và HTTP cache đã có bài khác trên blog; chỉ nhắc khi ranh giới quan trọng.
Vì sao localStorage là bẫy cho app data
localStorage trông tiện vì API cực đơn giản:
localStorage.setItem('cart', JSON.stringify(items));
const cart = JSON.parse(localStorage.getItem('cart') ?? '[]');
Sự đơn giản che bốn vấn đề production:
- Chặn main thread — mọi
getItem/setItemchạy đồng bộ. Parse chuỗi JSON 2 MB khi scroll là rớt frame. - Chỉ string — trả thuế
JSON.stringify/parse, không lưuBlob,File, binary hiệu quả. - Không transaction — tab đồng thời xen kẽ read/write và hỏng state logic.
- Trần mềm ~5 MB — browser giới hạn theo origin; payload lớn fail
QuotaExceededError.
Dùng localStorage cho đọc sync, nhỏ, ít rủi ro (theme, sidebar thu gọn). Không bao giờ cho giỏ hàng, nháp, tin nhắn, hay bất kỳ thứ phải sống qua refresh và sync — thuộc IndexedDB với schema và migration rõ ràng.
IndexedDB đào sâu
IndexedDB là CSDL object trong browser có transaction và version. Là lựa chọn mặc định cho app state offline có cấu trúc.
Mô hình tư duy: database, object store, index
Origin
└── Database "app-db" (version 3)
├── Object store "notes" keyPath: "id"
│ ├── Index "byUpdatedAt" on updatedAt
│ └── Index "byFolder" on [folderId, updatedAt]
└── Object store "outbox" keyPath: "mutationId"
- Database — một DB logic theo tên theo origin; version tăng kích hoạt
onupgradeneeded. - Object store — như bảng; mỗi record là giá trị structured-cloneable.
- Key — inline (
keyPath) hoặc out-of-line (key tường minh trongadd/put). - Index — tra cứu phụ; hỗ trợ range query qua cursor.
Transaction: quy tắc không thương lượng
Mọi read/write trong transaction gắn một hoặc nhiều object store.
| Mode | Reads | Writes |
|---|---|---|
readonly | Yes | No |
readwrite | Yes | Yes |
versionchange | During upgrade only | Schema changes only |
Transaction auto-commit khi task đồng bộ kết thúc — không thể await việc không liên quan trong callback transaction và mong nó còn mở. Đây là footgun số 1 của IndexedDB.
Version và migration
Thay schema chỉ chạy trong onupgradeneeded:
const DB_NAME = 'app-db';
const DB_VERSION = 3;
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const tx = event.target.transaction;
if (event.oldVersion < 1) {
db.createObjectStore('notes', { keyPath: 'id' });
}
if (event.oldVersion < 2) {
const store = tx.objectStore('notes');
store.createIndex('byUpdatedAt', 'updatedAt');
}
if (event.oldVersion < 3) {
db.createObjectStore('outbox', { keyPath: 'mutationId' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
Dùng kiểm tra oldVersion từng bước, không drop store production không có lối migration.
API dài dòng — và vì sao có wrapper
API gốc event-based và low-level. Code production gần như luôn dùng idb hoặc Dexie:
import { openDB } from 'idb';
const db = await openDB('app-db', 3, {
upgrade(db, oldVersion) {
if (oldVersion < 1) db.createObjectStore('notes', { keyPath: 'id' });
if (oldVersion < 2) {
db.createObjectStore('notes').createIndex('byUpdatedAt', 'updatedAt');
}
if (oldVersion < 3) {
db.createObjectStore('outbox', { keyPath: 'mutationId' });
}
},
});
export async function upsertNote(note) {
await db.put('notes', note);
}
export async function notesUpdatedSince(since) {
const idx = db.transaction('notes').store.index('byUpdatedAt');
return idx.getAll(IDBKeyRange.lowerBound(since));
}
Dexie thêm query chain, hook, live query kiểu Observable; idb mỏng trên spec. Chọn theo quen thuộc team và độ phức tạp query.
Pattern thực: ghi chú offline với outbox
/** @typedef {{ id: string; body: string; updatedAt: number; syncStatus: 'pending' | 'synced' }} Note */
export async function saveNoteLocal(note) {
const tx = db.transaction(['notes', 'outbox'], 'readwrite');
await tx.objectStore('notes').put({
...note,
syncStatus: 'pending',
updatedAt: Date.now(),
});
await tx.objectStore('outbox').put({
mutationId: crypto.randomUUID(),
type: 'NOTE_UPSERT',
payload: note,
createdAt: Date.now(),
});
await tx.done;
}
UI đọc IndexedDB ngay; sync xả outbox khi online — sẽ nói sau.
Cache API — không phải HTTP cache, không phải IndexedDB
Cache API lưu object Response keyed bởi Request. Sống trong global scope Service Worker và window.
| Concern | HTTP cache (browser) | Cache API | IndexedDB |
|---|---|---|---|
| Who controls it | Server headers + browser heuristics | Your JavaScript | Your JavaScript |
| Key shape | URL + method + vary headers | Request object you define | Your keyPath / key |
| Stores | Full HTTP responses | Response (body stream) | Arbitrary structured data |
| Typical use | Automatic revalidation | Offline shell, runtime API cache | App records, user content |
// In a Service Worker
const CACHE = 'app-shell-v2';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) =>
cache.addAll(['/index.html', '/app.js', '/app.css'])
)
);
});
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
caches.match(event.request).then((cached) => cached ?? fetch(event.request))
);
}
});
Đừng lưu JSON API cá nhân hóa trong Cache API trừ khi key theo URL và chấp nhận phân mảnh cache. State theo user thuộc IndexedDB; asset tĩnh và GET idempotent thuộc Cache API.
OPFS — semantics file ở quy mộ origin
Origin Private File System expose cây thư mục sandbox theo origin qua navigator.storage.getDirectory(). Khác model record IndexedDB, OPFS theo byte — lý tưởng cho file lớn, ghi stream, module WASM cần I/O kiểu POSIX.
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('export.bin', { create: true });
const writable = await fileHandle.createWritable();
await writable.write(largeUint8Array);
await writable.close();
Sync Access Handle trong worker
Trong Dedicated Worker, OPFS hỗ trợ createSyncAccessHandle() — read/write đồng bộ không overhead async mỗi op. Đây là cách wa-sqlite và tương tự chạy SQLite compile WASM với hiệu năng chấp nhận được:
// Dedicated worker — not available on main thread
const handle = await fileHandle.createSyncAccessHandle();
try {
handle.write(buffer, { at: offset });
handle.flush();
} finally {
handle.close();
}
Đánh đổi: file OPFS không structured-cloneable giữa tab như record IDB; điều phối qua lock và ownership worker. Dùng OPFS khi throughput và layout file quan trọng; IndexedDB khi cần query có index trên object.
Quota, persistence, và eviction
Mọi client storage chia quota origin do browser enforce. Không có con số cố định “500 MB cho mọi người” — quota phụ thuộc dung lượng đĩa, mức engagement, áp lực storage.
Kiểm tra dung lượng
if (navigator.storage?.estimate) {
const { usage, quota } = await navigator.storage.estimate();
console.log(`Using ${usage} of ${quota} bytes`);
}
usage gần đúng; dùng cảnh báo UX, không billing.
Xin persistence
Mặc định storage là “best-effort” — browser có thể evict khi áp lực. navigator.storage.persist() xin storage persistent (kháng eviction):
const persisted = await navigator.storage.persist();
// or check without prompting:
const isPersisted = await navigator.storage.persisted();
Browser cấp persistence dễ hơn cho PWA đã cài và site có engagement thật. Luôn thiết kế vẫn bị evict — persist là gợi ý, không phải hợp đồng.
Eviction khi storage chịu áp lực
Khi đĩa thấp, browser evict data origin theo thứ tự gần LRU trong các origin không persistent. Triệu chứng: IndexedDB trống sau vài tuần không vào, Cache API miss, ticket support.
Giảm thiểu:
- Sync data quan trọng lên server khi online.
- Hiện UX “storage thấp” bằng
estimate(). - Gọi
persist()sau cài hoặc hành động có ý nghĩa. - Không bao giờ coi chỉ-local là bền vững.
Service Workers — vòng đời và chiến lược cache
Service Worker là thread riêng chặn request mạng cho origin. Là điểm tích hợp Cache API, background sync, và push.
Vòng đời: install → waiting → activate
Register sw.js
│
▼
install event ──► precache assets, call skipWaiting() optionally
│
▼
waiting ──► new SW blocked until all tabs close (unless skipWaiting)
│
▼
activate event ──► delete old caches, clients.claim()
│
▼
fetch events ──► serve from cache and/or network
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v2').then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== 'v2').map((k) => caches.delete(k))
)
).then(() => self.clients.claim())
);
});
Cập nhật an toàn: tăng tên cache mỗi deploy; xoá cache cũ trong activate; báo client reload qua postMessage khi SW mới tiếp quản. Tránh vòng reload vô hạn bằng cách so registration.waiting.
Chiến lược cache
| Strategy | Flow | Best for |
|---|---|---|
| Cache-first | Cache → network on miss | App shell, fonts, versioned static assets |
| Network-first | Network → cache fallback | Fresh API data, auth-sensitive GETs |
| Stale-while-revalidate | Return cache immediately, update cache in background | Semi-static JSON, avatars, config |
| Network-only | Always network | Mutations, analytics, WebSockets |
| Cache-only | Cache or fail | Offline fallbacks you fully control |
async function staleWhileRevalidate(request) {
const cache = await caches.open('runtime-v1');
const cached = await cache.match(request);
const networkPromise = fetch(request).then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
});
return cached ?? networkPromise;
}
Precache vs runtime cache
- Precaching — danh sách URL cố định lúc build/deploy (
addAlltronginstall). Đảm bảo boot offline. - Runtime caching — điền khi fetch lần đầu (
cache.puttrong handlerfetch). Tăng theo usage; cần chính sách eviction.
Workbox mã hóa strategy thành recipe và xử lý hết hạn cache, routing, manifest precache từ build tool. Team principal thường bọc Workbox thay vì viết tay mọi nhánh fetch.
Background Sync và mutation offline
Background Sync (SyncManager) cho SW hoãn việc đến khi có mạng:
// Page — user saves while offline
await saveNoteLocal(note);
await registration.sync.register('flush-outbox');
// sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'flush-outbox') {
event.waitUntil(flushOutbox());
}
});
Periodic Background Sync (nơi hỗ trợ, thường PWA cài) chạy theo interval cho refresh ưu tiên thấp. Kiểm tra permission periodicSync và feature detection — không phổ biến toàn cầu.
Pattern outbox / hàng đợi
Pipeline mutation offline vững:
User action
│
▼
Optimistic UI update (in-memory / IDB)
│
▼
Append mutation to outbox (IndexedDB)
│
├── Online? ──► POST /sync ──► remove from outbox on 2xx
│
└── Offline? ──► Background Sync tag registered
│
▼
SW flushOutbox() retries with backoff
async function flushOutbox() {
const pending = await db.getAll('outbox');
for (const item of pending) {
const res = await fetch('/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
if (res.ok) await db.delete('outbox', item.mutationId);
else throw new Error('Retry later'); // SyncManager will retry
}
}
Dùng idempotency key trên server (mutationId) để retry không nhân đôi side effect.
Kiến trúc offline-first
Offline-first nghĩa store local là authoritative cho UX; mạng là kênh đồng bộ, không phải đường đọc chính.
UI lạc quan
Áp mutation local ngay; hiện trạng thái pending/synced/error theo record; reconcile khi server ACK. User cảm nhận latency zero; bạn gánh complexity.
Giải quyết xung đột
Khi hai writer chạm cùng entity:
| Approach | Behavior | When it fits |
|---|---|---|
| Last-write-wins (LWW) | Highest timestamp wins | Low-collaboration CRUD, notes, settings |
| Server authority | Server merges or rejects | Checkout, inventory, payments |
| CRDTs / OT | Mathematically merge concurrent edits | Real-time docs, whiteboards |
LWW dễ và sai cho editor cộng tác. CRDT (Yjs, Automerge) thêm cost bundle và học nhưng loại “chỉnh sửa biến mất”. Hầu hết team: LWW + validate server + banner xung đột cho 95% case.
Engine đồng bộ
Ở scale, vòng outbox thủ công nhường sync engine — ElectricSQL, PowerSync, Replicache, RxDB replication, Firebase offline persistence. Cung cấp replication cursor-based, auth, hook xung đột. Đánh giá: vendor lock-in, kích thước WASM, yêu cầu server.
┌─────────────┐ replication ┌─────────────┐
│ Client IDB │ ◄────────────────► │ Server │
│ + outbox │ (WebSocket/SSE) │ Postgres │
└─────────────┘ └─────────────┘
▲
│ read/write
Optimistic UI
PWA cơ bản — manifest, cài đặt, khi nào hợp
PWA gồm HTTPS + Service Worker + Web App Manifest:
{
"name": "Field Notes",
"short_name": "Notes",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#c8ff00",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
Tiêu chí cài (Chromium) gồm manifest có icon, SW có fetch handler, heuristic engagement. Safari và Firefox khác — test thiết bị thật.
Khi nào PWA là lựa chọn đúng
| Good fit | Poor fit |
|---|---|
| Field apps, logistics, intermittent connectivity | SEO-critical marketing site only |
| Repeat-visit tools (dashboards, editors) | One-shot landing pages |
| Need install icon without app store | Need deep OS integrations (BLE, background GPS) |
| Cross-platform with one codebase | Heavy 3D/games needing native perf |
PWA giỏi khả năng chịu offline dễ tiếp cận; không thay native khi API OS hoặc discovery store chi phối.
Hướng dẫn quyết định cho principal engineer
Dùng khi review kiến trúc:
1. Is the data credential/session? → HttpOnly cookie (server-set)
2. Is it a tiny UI pref (< 1 KB)? → localStorage (accept sync cost)
3. Is it structured app state / offline? → IndexedDB (+ wrapper)
4. Is it HTTP response replay? → Cache API (+ SW strategy)
5. Is it large binary / SQL / WASM file? → OPFS (+ worker)
6. Must mutations survive offline? → Outbox + Background Sync
7. Multi-device real-time collaboration? → Sync engine or CRDT layer
8. Need install + push + offline boot? → PWA (manifest + SW precache)
Anti-pattern cần veto trong PR review:
- Lưu JWT trong
localStorage(XSS exfiltration) — ưu tiên HttpOnly cookie cho session token. - Cache response API đã auth trong Cache API không hiểu Vary/credentials.
JSON.parse(localStorage.getItem('state'))mỗi lần render.- SW cache response
POSTmặc định. - Coi
persist()là đã backup — không phải; sync server mới là backup.
Mô hình tư duy kết
Nghĩ theo lớp, không theo API:
┌──────────────────────────────────────────────┐
│ UI (optimistic, pending states) │
├──────────────────────────────────────────────┤
│ Domain logic + sync (outbox, idempotency) │
├──────────────────────────────────────────────┤
│ IndexedDB / OPFS (durable structured state) │
├──────────────────────────────────────────────┤
│ Service Worker (Cache API, background sync) │
├──────────────────────────────────────────────┤
│ Network (eventual consistency transport) │
└──────────────────────────────────────────────┘
Browser cho nhiều storage primitive vì không có cái nào fit mọi access pattern. Việc senior là chọn primitive đúng, thiết kế chịu eviction, và làm ghi offline an toàn khi retry. Phần còn lại — Workbox, Dexie, wa-sqlite — là ergonomics trên kỷ luật đó.
Về caching HTTP và bảo mật cookie, xem các bài deep dive riêng trên blog. Cho feature offline tiếp theo: bắt đầu bằng outbox, đo quota sớm, và ship SW fail open về network khi cache miss.