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.
Most frontend engineers can localStorage.setItem('theme', 'dark') on day one {Hầu hết frontend engineer có thể localStorage.setItem('theme', 'dark') ngay ngày đầu}. Few can explain why that same API will freeze a checkout flow at 50k rows, why the browser evicted your user’s draft essay, or how to reconcile offline edits when two tabs write the same record {Í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}.
This post is the storage layer companion to HTTP caching and cookies {Bài này là lớp storage đi kèm HTTP caching và cookies} — we assume you already know Cache-Control and Set-Cookie {giả định bạn đã biết Cache-Control và Set-Cookie}, and focus on programmable, origin-scoped client storage and offline-first architecture {tập trung vào client storage lập trình được, theo origin và kiến trúc offline-first}.
The storage landscape — pick the right primitive {Bức tranh storage — chọn primitive đúng}
Browsers expose several overlapping APIs {Browser expose nhiều API chồng lấn}. They differ on capacity, sync vs async, structured vs opaque data, and who can read them {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 |
Rule of thumb {Quy tắc ngón tay cái}: cookies carry credentials to the server {cookies mang credential lên server}; Web Storage holds small strings you can lose {Web Storage giữ chuỗi nhỏ có thể mất}; IndexedDB holds your app’s source of truth offline {IndexedDB giữ source of truth offline của app}; Cache API holds HTTP-shaped artifacts {Cache API giữ artifact dạng HTTP}; OPFS holds files you would
read()/write()like a disk {OPFS giữ file bạnread()/write()như ổ đĩa}.
Cookies and HTTP cache are covered elsewhere on this blog {Cookies và HTTP cache đã có bài khác trên blog}; we only reference them when boundaries matter {chỉ nhắc khi ranh giới quan trọng}.
Why localStorage is a trap for app data {Vì sao localStorage là bẫy cho app data}
localStorage looks convenient because the API is trivial {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') ?? '[]');
That simplicity hides four production problems {Sự đơn giản che bốn vấn đề production}:
- Main-thread blocking {Chặn main thread} — every
getItem/setItemruns synchronously {mọigetItem/setItemchạy đồng bộ}. Parse a 2 MB JSON string during scroll and you drop frames {Parse chuỗi JSON 2 MB khi scroll là rớt frame}. - String-only {Chỉ string} — you pay
JSON.stringify/parsetax and cannot storeBlob,File, or binary efficiently {trả thuếJSON.stringify/parse, không lưuBlob,File, binary hiệu quả}. - No transactions {Không transaction} — concurrent tabs can interleave reads/writes and corrupt logical state {tab đồng thời xen kẽ read/write và hỏng state logic}.
- ~5 MB soft ceiling {Trần mềm ~5 MB} — browsers enforce per-origin limits; large payloads fail with
QuotaExceededError{browser giới hạn theo origin; payload lớn failQuotaExceededError}.
Use localStorage for low-stakes, tiny, synchronous reads (theme, sidebar collapsed) {Dùng localStorage cho đọc sync, nhỏ, ít rủi ro (theme, sidebar thu gọn)}. Never for cart, drafts, messages, or anything that must survive refresh and sync {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} — that belongs in IndexedDB with explicit schema and migrations {thuộc IndexedDB với schema và migration rõ ràng}.
IndexedDB deep dive {IndexedDB đào sâu}
IndexedDB is a transactional, versioned, object database in the browser {IndexedDB là CSDL object trong browser có transaction và version}. It is the default choice for structured offline app state {Là lựa chọn mặc định cho app state offline có cấu trúc}.
Mental model: databases, object stores, indexes {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 {Database} — one logical DB per name per origin; version bumps trigger
onupgradeneeded{một DB logic theo tên theo origin; version tăng kích hoạtonupgradeneeded}. - Object store {Object store} — like a table; each record is a structured-cloneable value {như bảng; mỗi record là giá trị structured-cloneable}.
- Key {Key} — inline (
keyPath) or out-of-line (explicit key inadd/put) {inline (keyPath) hoặc out-of-line (key tường minh trongadd/put)}. - Index {Index} — secondary lookup; supports range queries via cursors {tra cứu phụ; hỗ trợ range query qua cursor}.
Transactions: the non-negotiable rule {Transaction: quy tắc không thương lượng}
All reads and writes happen inside a transaction scoped to one or more object stores {Mọi read/write trong transaction gắn một hoặc nhiều object store}. Modes:
| Mode | Reads | Writes |
|---|---|---|
readonly | Yes | No |
readwrite | Yes | Yes |
versionchange | During upgrade only | Schema changes only |
Transactions auto-commit when the synchronous task completes {Transaction auto-commit khi task đồng bộ kết thúc} — you cannot await unrelated work inside a transaction callback and expect it to stay open {không thể await việc không liên quan trong callback transaction và mong nó còn mở}. This is the #1 IndexedDB footgun {Đây là footgun số 1 của IndexedDB}.
Versioning and migrations {Version và migration}
Schema changes only run inside onupgradeneeded {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);
});
}
Use incremental oldVersion checks, never drop stores in production without a migration path {Dùng kiểm tra oldVersion từng bước, không drop store production không có lối migration}.
The verbose API — and why wrappers exist {API dài dòng — và vì sao có wrapper}
The native API is event-based and low-level {API gốc event-based và low-level}. Production code almost always uses idb (Jake Archibald) or Dexie {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 adds chainable queries, hooks, and Observable-style live queries {Dexie thêm query chain, hook, live query kiểu Observable}; idb stays thin over the spec {idb mỏng trên spec}. Pick based on team familiarity and query complexity {Chọn theo quen thuộc team và độ phức tạp query}.
Real pattern: offline note with outbox {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;
}
The UI reads from IndexedDB immediately {UI đọc IndexedDB ngay}; sync drains the outbox when online {sync xả outbox khi online} — covered later {sẽ nói sau}.
Cache API — not HTTP cache, not IndexedDB {Cache API — không phải HTTP cache, không phải IndexedDB}
The Cache API (caches.open, cache.match, cache.put) stores Response objects keyed by Request {Cache API lưu object Response keyed bởi Request}. It lives under the Service Worker global scope and the window {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))
);
}
});
Do not store personalized JSON API payloads in Cache API unless you key by URL and accept cache fragmentation {Đừ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}. User-specific state belongs in IndexedDB; static assets and idempotent GET responses belong in Cache API {State theo user thuộc IndexedDB; asset tĩnh và GET idempotent thuộc Cache API}.
OPFS — file semantics at origin scale {OPFS — semantics file ở quy mộ origin}
The Origin Private File System exposes a sandboxed directory tree per origin via navigator.storage.getDirectory() {Origin Private File System expose cây thư mục sandbox theo origin qua navigator.storage.getDirectory()}. Unlike IndexedDB’s record model, OPFS is byte-oriented {Khác model record IndexedDB, OPFS theo byte} — ideal for large files, streaming writes, and WASM modules that expect POSIX-like I/O {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 Handles in workers {Sync Access Handle trong worker}
In Dedicated Workers, OPFS supports createSyncAccessHandle() — synchronous read/write without async overhead per op {Trong Dedicated Worker, OPFS hỗ trợ createSyncAccessHandle() — read/write đồng bộ không overhead async mỗi op}. This is how wa-sqlite and similar run SQLite compiled to WASM with acceptable performance {Đâ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();
}
Trade-off {Đánh đổi}: OPFS files are not structured-cloneable across tabs the way IDB records are {file OPFS không structured-cloneable giữa tab như record IDB}; you coordinate via locks and worker ownership {điều phối qua lock và ownership worker}. Use OPFS when throughput and file layout matter; use IndexedDB when you need indexed queries on objects {Dùng OPFS khi throughput và layout file quan trọng; IndexedDB khi cần query có index trên object}.
Quotas, persistence, and eviction {Quota, persistence, và eviction}
All client storage shares an origin quota enforced by the browser {Mọi client storage chia quota origin do browser enforce}. There is no fixed “500 MB for everyone” number {Không có con số cố định “500 MB cho mọi người”} — quota depends on available disk, engagement, and storage pressure {quota phụ thuộc dung lượng đĩa, mức engagement, áp lực storage}.
Inspecting usage {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 is approximate; treat it for UX warnings, not billing {usage gần đúng; dùng cảnh báo UX, không billing}.
Requesting persistence {Xin persistence}
By default, storage is “best-effort” — the browser may evict under pressure {Mặc định storage là “best-effort” — browser có thể evict khi áp lực}. navigator.storage.persist() asks for persistent storage (eviction-resistant) {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();
Browsers grant persistence more readily for installed PWAs and sites with meaningful user engagement {Browser cấp persistence dễ hơn cho PWA đã cài và site có engagement thật}. Always design for eviction anyway — persist is a hint, not a contract {Luôn thiết kế vẫn bị evict — persist là gợi ý, không phải hợp đồng}.
Eviction under storage pressure {Eviction khi storage chịu áp lực}
When disk is low, browsers evict origin data in LRU-ish order among non-persistent origins {Khi đĩa thấp, browser evict data origin theo thứ tự gần LRU trong các origin không persistent}. Symptoms: IndexedDB empty after weeks away, Cache API misses, angry support tickets {Triệu chứng: IndexedDB trống sau vài tuần không vào, Cache API miss, ticket support}.
Mitigations {Giảm thiểu}:
- Sync critical data to server when online {Sync data quan trọng lên server khi online}.
- Surface “storage low” UX using
estimate(){Hiện UX “storage thấp” bằngestimate()}. - Call
persist()after install or meaningful action {Gọipersist()sau cài hoặc hành động có ý nghĩa}. - Never assume local-only is durable {Không bao giờ coi chỉ-local là bền vững}.
Service Workers — lifecycle and caching strategies {Service Workers — vòng đời và chiến lược cache}
A Service Worker is a separate thread that intercepts network requests for your origin {Service Worker là thread riêng chặn request mạng cho origin}. It is the integration point for Cache API, background sync, and push {Là điểm tích hợp Cache API, background sync, và push}.
Lifecycle: install → waiting → activate {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())
);
});
Updating safely {Cập nhật an toàn}: bump cache name on each deploy {tăng tên cache mỗi deploy}; delete stale caches in activate {xoá cache cũ trong activate}; notify clients to reload via postMessage when a new SW takes over {báo client reload qua postMessage khi SW mới tiếp quản}. Avoid infinite reload loops by comparing registration.waiting state {Tránh vòng reload vô hạn bằng cách so registration.waiting}.
Caching strategies {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;
}
Precaching vs runtime caching {Precache vs runtime cache}
- Precaching {Precaching} — fixed URL list at build/deploy (
addAllininstall) {danh sách URL cố định lúc build/deploy (addAlltronginstall)}. Guarantees offline boot {Đảm bảo boot offline}. - Runtime caching {Runtime caching} — populate on first fetch (
cache.putinfetchhandler) {điền khi fetch lần đầu (cache.puttrong handlerfetch)}. Grows with usage; needs eviction policy {Tăng theo usage; cần chính sách eviction}.
Workbox (Google) encodes these strategies as recipes and handles cache expiration, routing, and precache manifests from build tools {Workbox mã hóa strategy thành recipe và xử lý hết hạn cache, routing, manifest precache từ build tool}. Principal teams often wrap Workbox rather than hand-writing every fetch branch {Team principal thường bọc Workbox thay vì viết tay mọi nhánh fetch}.
Background Sync and offline mutations {Background Sync và mutation offline}
Background Sync (SyncManager) lets a Service Worker defer work until connectivity returns {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 (where supported, often installed PWA) runs on an interval for low-priority refresh {Periodic Background Sync (nơi hỗ trợ, thường PWA cài) chạy theo interval cho refresh ưu tiên thấp}. Check periodicSync permission and feature detection — it is not universal {Kiểm tra permission periodicSync và feature detection — không phổ biến toàn cầu}.
Outbox / queue pattern {Pattern outbox / hàng đợi}
The robust offline mutation pipeline {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
}
}
Use idempotency keys on the server (mutationId) so retries do not duplicate side effects {Dùng idempotency key trên server (mutationId) để retry không nhân đôi side effect}.
Offline-first architecture {Kiến trúc offline-first}
Offline-first means the local store is authoritative for UX; the network is an synchronization transport, not the primary read path {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}.
Optimistic UI {UI lạc quan}
Apply mutations locally immediately {Áp mutation local ngay}; show pending/synced/error states per record {hiện trạng thái pending/synced/error theo record}; reconcile when server ACK arrives {reconcile khi server ACK}. Users perceive zero latency; you absorb complexity {User cảm nhận latency zero; bạn gánh complexity}.
Conflict resolution {Giải quyết xung đột}
When two writers touch the same entity {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 is easy and wrong for collaborative editors {LWW dễ và sai cho editor cộng tác}. CRDTs (Yjs, Automerge) add bundle and learning cost but eliminate “your edit disappeared” {CRDT (Yjs, Automerge) thêm cost bundle và học nhưng loại “chỉnh sửa biến mất”}. Most product teams: LWW + server validation + user-visible conflict banner for the 95% case {Hầu hết team: LWW + validate server + banner xung đột cho 95% case}.
Sync engines {Engine đồng bộ}
At scale, hand-rolled outbox loops give way to sync engines — ElectricSQL, PowerSync, Replicache, RxDB replication, Firebase offline persistence {Ở scale, vòng outbox thủ công nhường sync engine — ElectricSQL, PowerSync, Replicache, RxDB replication, Firebase offline persistence}. They provide cursor-based replication, auth, and conflict hooks {Cung cấp replication cursor-based, auth, hook xung đột}. Evaluate: vendor lock-in, WASM size, and server requirements {Đá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 essentials — manifest, installability, fit {PWA cơ bản — manifest, cài đặt, khi nào hợp}
A Progressive Web App bundles HTTPS + Service Worker + Web App Manifest {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" }
]
}
Installability criteria (Chromium) include manifest with icons, SW with fetch handler, and engagement heuristics {Tiêu chí cài (Chromium) gồm manifest có icon, SW có fetch handler, heuristic engagement}. Safari and Firefox differ — test on real devices {Safari và Firefox khác — test thiết bị thật}.
When a PWA is the right call {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 |
PWAs excel at reachable offline resilience {PWA giỏi khả năng chịu offline dễ tiếp cận}; they do not replace native when OS APIs or store discovery dominate {không thay native khi API OS hoặc discovery store chi phối}.
Decision guide for principal engineers {Hướng dẫn quyết định cho principal engineer}
Use this at architecture review {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-patterns to veto in PR review {Anti-pattern cần veto trong PR review}:
- Storing JWTs in
localStorage(XSS exfiltration) {Lưu JWT tronglocalStorage(XSS exfiltration)} — prefer HttpOnly cookies for session tokens {ưu tiên HttpOnly cookie cho session token}. - Caching authenticated API responses in Cache API without Vary/credentials awareness {Cache response API đã auth trong Cache API không hiểu Vary/credentials}.
JSON.parse(localStorage.getItem('state'))on every render {JSON.parse(localStorage.getItem('state'))mỗi lần render}.- Service Worker that caches
POSTresponses by default {SW cache responsePOSTmặc định}. - Assuming
persist()means data is backed up {Coipersist()là đã backup} — it is not; sync to server is backup {không phải; sync server mới là backup}.
Closing mental model {Mô hình tư duy kết}
Think in layers, not APIs {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) │
└──────────────────────────────────────────────┘
The browser gives you multiple storage primitives because no single one fits all access patterns {Browser cho nhiều storage primitive vì không có cái nào fit mọi access pattern}. Senior work is choosing the right primitive, designing for eviction, and making offline writes safe to retry {Việc senior là chọn primitive đúng, thiết kế chịu eviction, và làm ghi offline an toàn khi retry}. Everything else — Workbox, Dexie, wa-sqlite — is ergonomics on top of that discipline {Phần còn lại — Workbox, Dexie, wa-sqlite — là ergonomics trên kỷ luật đó}.
For HTTP-layer caching and cookie security, see the dedicated deep dives on this blog {Về caching HTTP và bảo mật cookie, xem các bài deep dive riêng trên blog}. For your next offline feature: start with the outbox, measure quota early, and ship a SW that fails open to network when cache misses {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}.