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.

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-ControlSet-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 originkiế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}.

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

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ạn read()/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}:

  1. Main-thread blocking {Chặn main thread} — every getItem/setItem runs synchronously {mọi getItem/setItem chạ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}.
  2. String-only {Chỉ string} — you pay JSON.stringify/parse tax and cannot store Blob, File, or binary efficiently {trả thuế JSON.stringify/parse, không lưu Blob, File, binary hiệu quả}.
  3. 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}.
  4. ~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 fail QuotaExceededError}.

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ạt onupgradeneeded}.
  • 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 in add/put) {inline (keyPath) hoặc out-of-line (key tường minh trong add/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:

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

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))
    );
  }
});

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 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àisite 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ằng estimate()}.
  • Call persist() after install or meaningful action {Gọi persist() 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}

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;
}

Precaching vs runtime caching {Precache vs runtime cache}

  • Precaching {Precaching} — fixed URL list at build/deploy (addAll in install) {danh sách URL cố định lúc build/deploy (addAll trong install)}. Guarantees offline boot {Đảm bảo boot offline}.
  • Runtime caching {Runtime caching} — populate on first fetch (cache.put in fetch handler) {điền khi fetch lần đầu (cache.put trong handler fetch)}. 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}:

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 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 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

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 trong localStorage (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 POST responses by default {SW cache response POST mặc định}.
  • Assuming persist() means data is backed up {Coi persist() 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}.