jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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-ControlSet-Cookie, tập trung vào client storage lập trình được, theo originkiế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.

APITypical capacitySync / asyncStructured dataPrimary use case
Cookies~4 KB per cookie, ~180 cookies/domain (browser-dependent)Sync on every HTTP requestString (name=value)Session/auth transport to server
localStorage~5 MB per origin (implementation-dependent)Synchronous on main threadString keys/values onlySmall prefs, feature flags, non-critical UI state
sessionStorageSame quota as localStorage, tab-scopedSynchronousString onlyEphemeral tab state (wizard step, form scratch)
IndexedDBLarge (often hundreds of MB to GB under pressure policy)AsyncObjects, blobs, files, indexesApp data, offline records, binary assets
Cache APIShares origin quota with other storageAsyncRequest/Response pairsOffline assets, API response snapshots
OPFS (Origin Private File System)Shares origin quotaAsync; sync handles in workersByte files, directoriesHigh-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:

  1. Chặn main thread — mọi getItem/setItem chạy đồng bộ. Parse chuỗi JSON 2 MB khi scroll là rớt frame.
  2. Chỉ string — trả thuế JSON.stringify/parse, không lưu Blob, File, binary hiệu quả.
  3. Không transaction — tab đồng thời xen kẽ read/write và hỏng state logic.
  4. 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 trong add/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.

ModeReadsWrites
readonlyYesNo
readwriteYesYes
versionchangeDuring upgrade onlySchema 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.

ConcernHTTP cache (browser)Cache APIIndexedDB
Who controls itServer headers + browser heuristicsYour JavaScriptYour JavaScript
Key shapeURL + method + vary headersRequest object you defineYour keyPath / key
StoresFull HTTP responsesResponse (body stream)Arbitrary structured data
Typical useAutomatic revalidationOffline shell, runtime API cacheApp 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 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àisite 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

StrategyFlowBest for
Cache-firstCache → network on missApp shell, fonts, versioned static assets
Network-firstNetwork → cache fallbackFresh API data, auth-sensitive GETs
Stale-while-revalidateReturn cache immediately, update cache in backgroundSemi-static JSON, avatars, config
Network-onlyAlways networkMutations, analytics, WebSockets
Cache-onlyCache or failOffline 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 (addAll trong install). Đảm bảo boot offline.
  • Runtime caching — điền khi fetch lần đầu (cache.put trong handler fetch). 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:

ApproachBehaviorWhen it fits
Last-write-wins (LWW)Highest timestamp winsLow-collaboration CRUD, notes, settings
Server authorityServer merges or rejectsCheckout, inventory, payments
CRDTs / OTMathematically merge concurrent editsReal-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 fitPoor fit
Field apps, logistics, intermittent connectivitySEO-critical marketing site only
Repeat-visit tools (dashboards, editors)One-shot landing pages
Need install icon without app storeNeed deep OS integrations (BLE, background GPS)
Cross-platform with one codebaseHeavy 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 POST mặ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.