Web browser standard storage API web-api

Browser storage không phải là một cái kho duy nhất. Mỗi loại storage có mô hình dữ liệu, vòng đời, mức độ gắn với HTTP và chi phí khác nhau. Câu hỏi đầu tiên không phải là “API nào dễ dùng nhất?”, mà là:

  1. Dữ liệu có cần server đọc trực tiếp không?
  2. Dữ liệu là vài giá trị nhỏ hay một tập dữ liệu lớn có cấu trúc?
  3. Dữ liệu là response HTTP, file riêng của app, hay file người dùng nhìn thấy trên máy?
  4. Nếu trình duyệt xóa dữ liệu để giải phóng dung lượng thì app có khôi phục được không?

Bức tranh tổng quan

APIMô hình dữ liệuPersistent?Sync/AsyncDùng khi
CookiesTên/giá trị nhỏ, có thuộc tính HTTPSession hoặc persistentdocument.cookie là sync; request gửi cookie tự độngServer cần biết session/trạng thái trong mỗi request
Web Storage: localStorageKey-value, chỉ lưu stringThường giữ qua lần mở browserSyncPreference hoặc config nhỏ
Web Storage: sessionStorageKey-value, chỉ lưu stringTheo tab/sessionSyncState tạm của một tab
IndexedDBObject store, key, index, transactionTheo origin, chịu quota/evictionAsyncDữ liệu có cấu trúc, offline data, Blob/File
Cache StorageCặp Request/ResponseTheo origin, chịu quota/evictionAsyncPWA và cache HTTP cho Service Worker
OPFSFile/directory private của originTheo origin, chịu quota/evictionAsync; sync handle trong WorkerFile lớn, WASM, editor, xử lý media
File System Access APIFile/directory thật do user chọnHandle có thể lưu lại nếu được phépAsyncApp mở/lưu file local như editor
Storage API / StorageManagerKhông lưu data trực tiếpAsyncĐo quota, kiểm tra/request persistence
Storage BucketsNhóm các storage endpointĐang ở mức draft/experimentalAsyncChỉ cân nhắc khi browser support rõ ràng

IndexedDB, Cache Storage, OPFS và Web Storage thường nằm trong hệ thống storage theo origin của browser. Browser quản lý quota chung cho nhiều loại dữ liệu và có thể eviction dữ liệu khi thiếu dung lượng. Vì vậy “đã ghi thành công” không đồng nghĩa với “được backup vĩnh viễn”.

Origin, partition và vòng đời

Origin là ranh giới chính

Origin gồm scheme + host + port. Ví dụ, ba URL sau là ba origin khác nhau:

https://example.com
http://example.com
https://example.com:8443

localStorage, sessionStorage, IndexedDB, Cache Storage và OPFS về cơ bản bị giới hạn bởi same-origin policy. JavaScript ở https://app.example.com không tự đọc được IndexedDB của https://api.example.com.

Cookie có mô hình scope khác: Domain, Path và các thuộc tính SameSite quyết định cookie được gửi trong request nào. Đây là lý do cookie là HTTP state mechanism chứ không chỉ là một key-value store cho JavaScript.

Session, persistence và eviction

  • Session: dữ liệu mất khi session tương ứng kết thúc, ví dụ sessionStorage khi tab đóng.
  • Persistent: dữ liệu được browser giữ qua lần mở lại, nhưng user vẫn có thể xóa site data.
  • Eviction: browser chủ động xóa dữ liệu không persistent khi cần giải phóng dung lượng.
  • Clear site data: thao tác của user có thể xóa toàn bộ dữ liệu của origin, bất kể app dùng API nào.

Không nên xem browser storage là nơi duy nhất chứa dữ liệu không thể mất. Dữ liệu quan trọng vẫn cần server sync hoặc cơ chế export/backup.

Nhóm 1: Cookies

Cookie là cơ chế lưu trạng thái giữa browser và server. Cookie có thể được browser tự động gửi trong HTTP request qua header Cookie, và server tạo/cập nhật cookie qua header Set-Cookie.

Dùng cookie khi server cần biết trạng thái trong request, ví dụ:

  • session identifier;
  • refresh token hoặc một credential có chiến lược bảo mật phù hợp;
  • preference nhỏ mà server cần đọc;
  • một số trường hợp tracking/attribution tuân thủ privacy policy.

Không dùng cookie làm database phía client. Cookie được gửi cùng request phù hợp, nên dữ liệu thừa sẽ làm request lớn hơn.

Đọc và ghi bằng document.cookie

// Đọc: trả về một chuỗi, các cookie ngăn cách bằng dấu chấm phẩy.
console.log(document.cookie);

// Ghi một cookie. Mỗi lần gán chỉ tạo/cập nhật một cookie.
document.cookie = [
  `theme=${encodeURIComponent("dark")}`,
  "Max-Age=2592000", // 30 ngày
  "Path=/",
  "SameSite=Lax",
  "Secure",
].join("; ");

// Xóa: ghi lại cùng cookie với Max-Age=0.
document.cookie = "theme=; Max-Age=0; Path=/; SameSite=Lax; Secure";

document.cookie là synchronous và API chuỗi này khá dễ viết sai khi parse. Một helper tối thiểu:

function getCookie(name) {
  const prefix = `${encodeURIComponent(name)}=`;
  const item = document.cookie
    .split("; ")
    .find((cookie) => cookie.startsWith(prefix));

  return item ? decodeURIComponent(item.slice(prefix.length)) : null;
}

console.log(getCookie("theme"));

Các thuộc tính cần nhớ

Thuộc tínhÝ nghĩa
Max-Age / ExpiresQuyết định cookie persistent; bỏ cả hai thì thường là session cookie
PathGiới hạn URL path được gửi cookie
DomainCho phép cookie áp dụng cho domain/subdomain phù hợp; không nên mở rộng nếu không cần
SecureChỉ gửi qua HTTPS; production nên luôn dùng
HttpOnlyJavaScript không đọc được cookie; chỉ server đặt được
SameSite=Lax/Strict/NoneKiểm soát việc gửi trong request cross-site; None cần Secure
PartitionedTách cookie theo top-level site trong các trường hợp third-party phù hợp

Cookie có HttpOnly vẫn có thể bị gửi kèm request, chỉ là script không đọc được. HttpOnly không tự giải quyết CSRF; vẫn phải thiết kế SameSite, CSRF token và kiểm tra origin/referer khi phù hợp.

Request same-origin mặc định gửi cookie. Request cross-origin cần cấu hình credentials và server phải cho phép theo CORS:

const response = await fetch("/api/me", {
  credentials: "same-origin",
});

const crossOriginResponse = await fetch("https://api.example.test/me", {
  credentials: "include",
});

credentials: "include" không bỏ qua cookie policy, SameSite hay CORS. Nó chỉ nói với Fetch rằng request được phép tham gia credential flow nếu các policy khác cho phép.

Nhóm 2: Web Storage API

Web Storage có hai storage area:

  • localStorage: chia sẻ giữa các document cùng origin và tồn tại qua lần mở lại browser.
  • sessionStorage: gắn với origin và browser tab; đóng tab thì dữ liệu thường bị hủy.

Cả hai đều synchronous và chỉ lưu string. Đây là điểm quan trọng: một object không được lưu trực tiếp như object; phải serialize bằng JSON hoặc một encoding khác.

Các method chung của Storage

const storage = window.localStorage;

storage.setItem("theme", "dark");
const theme = storage.getItem("theme"); // string | null

storage.removeItem("theme");
console.log(storage.length);
console.log(storage.key(0)); // key tại index 0 hoặc null

// Cẩn thận: clear toàn bộ storage area của origin.
// storage.clear();

Lưu object an toàn hơn

const SETTINGS_KEY = "app:settings:v1";

const settings = {
  theme: "dark",
  compactMode: true,
};

localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));

function readSettings() {
  try {
    const raw = localStorage.getItem(SETTINGS_KEY);
    return raw ? JSON.parse(raw) : { theme: "light", compactMode: false };
  } catch {
    // JSON hỏng, storage bị chặn, hoặc browser đang ở private mode.
    return { theme: "light", compactMode: false };
  }
}

Khi thay đổi schema, dùng version trong key hoặc migrate rõ ràng. Đừng giả định JSON.parse luôn thành công: user, extension hoặc phiên bản app cũ có thể để lại dữ liệu không đúng format.

Đồng bộ state giữa các tab

Event storage chạy ở document khác đang dùng cùng storage area, không chạy lại trên window vừa gọi setItem:

window.addEventListener("storage", (event) => {
  if (event.storageArea !== localStorage) return;
  if (event.key !== SETTINGS_KEY) return;

  const nextSettings = event.newValue
    ? JSON.parse(event.newValue)
    : { theme: "light", compactMode: false };

  applySettings(nextSettings);
});

Nếu cần giao tiếp hai chiều có cấu trúc và không muốn lạm dụng storage event, cân nhắc BroadcastChannel. Nó là communication API, không phải storage API.

Khi nào không dùng Web Storage?

Không dùng localStorage/sessionStorage cho:

  • danh sách lớn hoặc dữ liệu cần query/index;
  • dữ liệu nhị phân lớn;
  • thao tác thường xuyên trên main thread;
  • access token, mật khẩu hoặc thông tin nhạy cảm;
  • dữ liệu cần transaction nhiều bước.

Vì thao tác sync có thể block main thread và quota nhỏ, Web Storage phù hợp với preference/config nhỏ hơn là application database.

Nhóm 3: IndexedDB

IndexedDB là database async phía client. Nó lưu được structured data theo structured clone algorithm, có object store, key, index và transaction. Đây thường là lựa chọn mặc định cho dữ liệu app offline có cấu trúc.

Mô hình cần nhớ

Database
└── Object store (ví dụ: notes)
    ├── keyPath: id
    ├── record: { id, title, body, updatedAt }
    └── index: byUpdatedAt

Schema được nâng version bằng indexedDB.open(name, version). Khi version tăng, onupgradeneeded chạy để tạo hoặc migrate object store/index.

Mở database và migrate schema

Native IndexedDB dùng event callback, nên có thể bọc IDBRequest thành Promise:

function requestToPromise(request) {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

function openNotesDb() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("notes-app", 1);

    request.onupgradeneeded = () => {
      const db = request.result;
      const notes = db.createObjectStore("notes", { keyPath: "id" });
      notes.createIndex("byUpdatedAt", "updatedAt");
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);

    // Một tab khác đang giữ connection cũ.
    request.onblocked = () => {
      console.warn("Đóng tab cũ để hoàn tất database migration");
    };
  });
}

Khi tăng version, connection cũ nên đóng ở mọi tab. Có thể gắn handler ngay sau khi mở database:

const db = await openNotesDb();

db.onversionchange = () => {
  db.close();
  console.log("Database đã thay đổi version; cần reload tab này");
};

Ghi, đọc và xóa record

IDBTransaction có lifecycle riêng, nên helper cho transaction cần lắng nghe complete, errorabort:

function transactionToPromise(transaction) {
  return new Promise((resolve, reject) => {
    transaction.oncomplete = () => resolve();
    transaction.onerror = () => reject(transaction.error);
    transaction.onabort = () => {
      reject(transaction.error ?? new Error("Transaction aborted"));
    };
  });
}

async function saveNote(note) {
  const db = await openNotesDb();
  const transaction = db.transaction("notes", "readwrite");
  const done = transactionToPromise(transaction);

  transaction.objectStore("notes").put(note);
  await done;
}

async function readNote(id) {
  const db = await openNotesDb();
  const transaction = db.transaction("notes", "readonly");
  const request = transaction.objectStore("notes").get(id);
  return requestToPromise(request);
}

await saveNote({
  id: "note-1",
  title: "Storage notes",
  body: "IndexedDB is async",
  updatedAt: Date.now(),
});

console.log(await readNote("note-1"));

Query bằng index

async function readNotesByUpdatedAt() {
  const db = await openNotesDb();
  const transaction = db.transaction("notes", "readonly");
  const index = transaction.objectStore("notes").index("byUpdatedAt");
  return requestToPromise(index.getAll());
}

Khi cần dữ liệu theo range, dùng IDBKeyRange, ví dụ IDBKeyRange.lowerBound(timestamp). Transaction nên ngắn; không chờ network trong một transaction rồi mới ghi tiếp, vì transaction có thể trở nên inactive.

IndexedDB checklist

  • Schema migration chỉ đặt trong onupgradeneeded.
  • Đặt keyPath và index ngay từ đầu nếu biết cách query.
  • Đóng connection khi nhận versionchange.
  • Bắt lỗi QuotaExceededError, AbortError, VersionErrorNotFoundError khi phù hợp.
  • Không lưu secret chỉ vì IndexedDB “khó mở hơn localStorage”; XSS vẫn có thể đọc dữ liệu bằng JavaScript của origin.
  • Nếu code nhiều, dùng một wrapper đã được kiểm chứng để giảm callback/event boilerplate, nhưng vẫn phải hiểu transaction model của IndexedDB.

Nhóm 4: Cache API / Cache Storage

Cache Storage lưu cặp Request/Response. Nó được thiết kế cho cache HTTP, đặc biệt trong Service Worker; nó không phải database tùy ý và cũng không tự động cache mọi request.

API cơ bản

const cache = await caches.open("app-v1");

await cache.addAll([
  "/",
  "/index.html",
  "/styles.css",
  "/app.js",
]);

const cachedResponse = await cache.match("/app.js");
if (cachedResponse) {
  console.log(await cachedResponse.text());
}

await cache.delete("/old-file.js");

Có thể lưu response tạo thủ công, nhưng phải nhớ response body thường chỉ đọc được một lần:

const networkResponse = await fetch("/api/articles");
const responseForPage = networkResponse.clone();

await cache.put("/api/articles", networkResponse);
const data = await responseForPage.json();

Cache trong Service Worker

const CACHE_NAME = "app-shell-v2";
const APP_SHELL = ["/", "/index.html", "/styles.css", "/app.js"];

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
  );
});

self.addEventListener("fetch", (event) => {
  if (event.request.method !== "GET") return;

  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached ?? fetch(event.request);
    }),
  );
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => key !== CACHE_NAME)
          .map((key) => caches.delete(key)),
      ),
    ),
  );
});

Đây là cache-first tối giản. Production cần quyết định rõ strategy cho từng loại request:

  • app shell: precache/cache-first;
  • API có thể thay đổi: network-first hoặc stale-while-revalidate;
  • response không muốn giữ: không cache;
  • POST/PUT/DELETE: không dùng cache như thể là database.

Version hóa cache và xóa cache cũ trong activate; nếu không, bản build cũ có thể tồn tại mãi cho tới khi user tự xóa site data.

Nhóm 5: Origin Private File System (OPFS)

OPFS là file system private của origin. File không xuất hiện trong Finder/Explorer của user và không cần user chọn file. Đây là sandbox của app, phù hợp với file lớn hoặc thao tác byte-level hiệu năng cao.

OPFS khác với File System Access API:

OPFSFile System Access API
User nhìn thấy file?Không
Cần picker/permission?Không cho storage của chính originCó, user phải chọn/cấp quyền
Mục đíchCache/file nội bộ của appMở/lưu file thật của user
Rootnavigator.storage.getDirectory()showOpenFilePicker()/showSaveFilePicker()

Ghi và đọc file trong OPFS

async function writeOpfsText(fileName, text) {
  const root = await navigator.storage.getDirectory();
  const drafts = await root.getDirectoryHandle("drafts", { create: true });
  const fileHandle = await drafts.getFileHandle(fileName, { create: true });
  const writable = await fileHandle.createWritable();

  await writable.write(text);
  await writable.close();
}

async function readOpfsText(fileName) {
  const root = await navigator.storage.getDirectory();
  const drafts = await root.getDirectoryHandle("drafts");
  const fileHandle = await drafts.getFileHandle(fileName);
  const file = await fileHandle.getFile();
  return file.text();
}

await writeOpfsText("note.txt", "Nội dung offline");
console.log(await readOpfsText("note.txt"));

Để tạo thư mục:

const root = await navigator.storage.getDirectory();
const drafts = await root.getDirectoryHandle("drafts", { create: true });
const file = await drafts.getFileHandle("note.txt", { create: true });

OPFS chịu quota của origin. Một số API sync như FileSystemSyncAccessHandle chỉ dùng được trong dedicated Web Worker, để không block main thread. Đây là hướng phù hợp cho SQLite/WASM, editor hoặc xử lý media nhiều byte; không nên đưa sync handle lên main thread.

Nhóm 6: File System Access API

File System Access API cho web app tương tác với file/directory thật mà user chọn. Đây là API có permission và browser support không đồng đều, nên luôn feature-detect và có fallback <input type="file"> hoặc download.

Mở file do user chọn

Các picker thường phải được gọi sau một user gesture, ví dụ bên trong click handler:

async function openTextFile() {
  if (!window.showOpenFilePicker) {
    throw new Error("Browser không hỗ trợ File System Access API");
  }

  const [handle] = await window.showOpenFilePicker({
    types: [
      {
        description: "Text files",
        accept: { "text/plain": [".txt", ".md"] },
      },
    ],
    multiple: false,
  });

  const file = await handle.getFile();
  return {
    handle,
    name: file.name,
    text: await file.text(),
  };
}

Lưu file

async function saveTextFile(text) {
  const handle = await window.showSaveFilePicker({
    suggestedName: "note.txt",
    types: [
      {
        description: "Text file",
        accept: { "text/plain": [".txt"] },
      },
    ],
  });

  const writable = await handle.createWritable();
  await writable.write(text);
  await writable.close();
}

Nếu lưu FileSystemFileHandle vào IndexedDB, lần sau app có thể lấy handle lại rồi kiểm tra permission. Tuy nhiên quyền có thể bị browser/user thu hồi:

async function ensureReadPermission(handle) {
  const current = await handle.queryPermission({ mode: "read" });
  if (current === "granted") return true;

  // requestPermission thường cần chạy trong user gesture.
  return (await handle.requestPermission({ mode: "read" })) === "granted";
}

Không nên tự động gọi picker khi page load. Hãy để user chủ động chọn “Open” hoặc “Save”, giải thích app sẽ làm gì với file, và luôn xử lý trường hợp user cancel.

Nhóm 7: Storage API và StorageManager

navigator.storage không phải nơi lưu dữ liệu mới. Nó là entry point để quan sát và điều khiển một phần lifecycle của storage theo origin.

Ước lượng usage và quota

const estimate = await navigator.storage.estimate();

console.log({
  usageBytes: estimate.usage,
  quotaBytes: estimate.quota,
  usageRatio: estimate.usage && estimate.quota
    ? estimate.usage / estimate.quota
    : null,
});

usagequotaước lượng, không nên dùng như số liệu chính xác tuyệt đối. Các browser có cách tính quota khác nhau và một số usage có thể được làm tròn vì privacy.

Persistent storage

const alreadyPersistent = await navigator.storage.persisted();

if (!alreadyPersistent) {
  const granted = await navigator.storage.persist();
  console.log("Persistent storage granted:", granted);
}

persist() là request, không phải mệnh lệnh. Browser có thể tự quyết định false dựa trên engagement, permission, dung lượng và policy. Persistent cũng không ngăn user chủ động xóa site data.

Dùng feature detection

function supportsStorageManager() {
  return typeof navigator !== "undefined" && "storage" in navigator;
}

function supportsOpfs() {
  return supportsStorageManager()
    && typeof navigator.storage.getDirectory === "function";
}

Storage API và OPFS thường yêu cầu secure context (https hoặc môi trường local được browser xem là an toàn). Worker cũng có thể có WorkerNavigator.storage trong các browser hỗ trợ.

Nhóm 8: Storage Buckets

Storage Buckets là hướng thiết kế để một origin chia dữ liệu thành các bucket có policy riêng: quota, expiration, persistence và eviction. Ví dụ app mail có thể tách drafts, attachmentscache để cache bị xóa trước dữ liệu quan trọng.

Tại thời điểm viết bài, đây vẫn là draft/experimental API, không phải nền tảng browser phổ biến để dùng mặc định. Nếu thử nghiệm, phải feature-detect:

if ("storageBuckets" in navigator) {
  const draftsBucket = await navigator.storageBuckets.open("drafts", {
    persisted: true,
  });

  console.log(await draftsBucket.estimate());
}

Không xây thiết kế production chỉ dựa vào API này nếu chưa kiểm tra browser target, compatibility và fallback.

Chọn API nào?

Nhu cầuAPI nên chọnLý do
Server cần session trong requestCookieBrowser/HTTP xử lý tự động
Theme, locale, vài optionlocalStorageAPI đơn giản, dữ liệu nhỏ
Wizard state chỉ trong tabsessionStorageTách theo tab, không cần DB
Nhiều record có index/queryIndexedDBStructured data + transaction + async
Offline app shell/API responseCache StorageĐúng abstraction của Request/Response
File nội bộ lớn, không cần user thấyOPFSSandbox riêng, tối ưu cho file
Mở/lưu file user thấy trên máyFile System Access APIPicker + permission + writable stream
Kiểm tra dung lượng/persistencenavigator.storageestimate, persist, persisted

Một app thực tế thường phối hợp nhiều API:

Cookie          -> session server
localStorage    -> theme/UI preference
IndexedDB       -> records, drafts, sync queue
Cache Storage   -> app shell và HTTP response
OPFS            -> attachment/file lớn nội bộ
StorageManager  -> quota và persistence signal

Đừng lưu cùng một dữ liệu ở nhiều nơi nếu không có lý do. Nếu phải làm vậy, xác định rõ source of truth và quy tắc invalidation; nếu không, app sẽ có các bản copy lệch nhau.

Mẫu wrapper nhỏ cho storage key-value

Khi chỉ cần preference, một wrapper giúp thống nhất JSON, default value và xử lý lỗi:

function createJsonStorage(storage) {
  return {
    read(key, fallback) {
      try {
        const raw = storage.getItem(key);
        return raw === null ? fallback : JSON.parse(raw);
      } catch {
        return fallback;
      }
    },

    write(key, value) {
      try {
        storage.setItem(key, JSON.stringify(value));
        return true;
      } catch (error) {
        if (error?.name === "QuotaExceededError") {
          console.warn("Storage quota exceeded", { key });
        }
        return false;
      }
    },

    remove(key) {
      try {
        storage.removeItem(key);
      } catch {
        // Storage có thể bị browser chặn hoặc unavailable.
      }
    },
  };
}

const preferences = createJsonStorage(window.localStorage);
preferences.write("app:preferences:v1", { theme: "dark" });

Wrapper này chỉ dành cho dữ liệu nhỏ. Không biến nó thành lớp abstraction giả cho IndexedDB; hai API có semantics hoàn toàn khác nhau.

Security và privacy

Đừng lưu secret ở client storage nếu không cần

JavaScript chạy cùng origin có thể đọc localStorage, sessionStorage, IndexedDB, Cache Storage và OPFS. Nếu app có XSS, attacker có thể đọc hoặc sửa dữ liệu trong các kho này.

  • Không lưu password trong browser storage.
  • Cân nhắc cookie HttpOnly; Secure; SameSite=Lax/Strict cho session server-side.
  • Access token trong memory có thể giảm thời gian tồn tại nhưng vẫn có trade-off khi reload.
  • CSP, output encoding, dependency hygiene và XSS prevention quan trọng hơn việc chọn một storage “khó đọc”.

Đừng tin dữ liệu đọc lại từ storage

Dữ liệu trong storage có thể bị user, extension, phiên bản app cũ hoặc chính code khác sửa. Luôn validate shape và range trước khi dùng:

function isSettings(value) {
  return value
    && (value.theme === "light" || value.theme === "dark")
    && typeof value.compactMode === "boolean";
}

Third-party và private browsing

Iframe third-party có thể bị partition hoặc bị chặn storage tùy browser và policy privacy. Private/incognito mode thường có quota/lifecycle khác và có thể làm một số thao tác storage throw error. Hãy test đúng browser target thay vì giả định mọi browser giống nhau.

Checklist khi thiết kế storage

  • Dữ liệu có cần server đọc không? Nếu có, xem xét cookie hoặc gửi explicit trong request.
  • Dữ liệu có nhỏ và ít truy cập không? Nếu có, Web Storage có thể đủ.
  • Dữ liệu có nhiều record, index, transaction hoặc Blob không? Chọn IndexedDB.
  • Dữ liệu là HTTP response cần offline không? Chọn Cache Storage.
  • Dữ liệu là file nội bộ lớn hay file user chọn? Phân biệt OPFS và File System Access.
  • Có fallback khi API không tồn tại, permission bị từ chối hoặc quota hết không?
  • Có schema/version/migration không?
  • Có validate dữ liệu đọc lại không?
  • App có hoạt động đúng nếu toàn bộ site data bị xóa không?
  • Dữ liệu có cần sync server/export để tránh mất không?

Tham khảo