The essentials

Quick reference

One focused task per row. Jump to the related section for complete, working examples.

UseSyntaxExamples
Save a persistent stringlocalStorage.setItem('theme', 'dark')View examples
Read a persistent stringconst theme = localStorage.getItem('theme')View examples
Remove one stored valuelocalStorage.removeItem('theme')View examples
Save tab-scoped statesessionStorage.setItem('draft-step', '2')View examples
Serialize a small objectlocalStorage.setItem('prefs', JSON.stringify(preferences))View examples
Observe another documentaddEventListener('storage', event => syncPreference(event))View examples
Open a versioned databaseconst request = indexedDB.open('notes', 3)View examples
Create an object storedb.createObjectStore('notes', { keyPath: 'id', autoIncrement: true })View examples
Create a non-unique indexstore.createIndex('by-updated', 'updatedAt')View examples
Release an old connectiondb.onversionchange = () => db.close()View examples
Start an atomic writeconst tx = db.transaction('notes', 'readwrite')View examples
Insert a new recordstore.add({ title: 'Plan', updatedAt: Date.now() })View examples
Insert or replace a recordstore.put({ id: 7, title: 'Revised', updatedAt: Date.now() })View examples
Read by primary keyconst request = store.get(7)View examples
Query an index rangeconst request = index.getAll(IDBKeyRange.lowerBound(cutoff))View examples
Iterate in reverse orderconst request = index.openCursor(null, 'prev')View examples
Estimate usage and quotaconst { usage, quota } = await navigator.storage.estimate()View examples
Request persistent storageconst persistent = await navigator.storage.persist()View examples
Delete a databaseconst request = indexedDB.deleteDatabase('notes')View examples

Browser storage is local application state, not a source of truth. Use Web Storage only for small string settings that must be read synchronously, and use IndexedDB for structured records, indexes, and atomic updates. Both are scoped by the browser's storage model, can be unavailable or cleared, and must never hold secrets that page scripts should not read. Design a server-backed recovery path for irreplaceable data, version IndexedDB schemas deliberately, and surface storage failures to the user.

Step by step

Detailed examples

01

Use Web Storage for small, non-sensitive string state

localStorage is shared by same-origin documents and normally survives browser restarts; sessionStorage is separated by origin and top-level browsing context, survives reloads, and ends with that page session. Browser privacy features can partition or deny storage in third-party contexts, so do not assume an embedded origin sees the same state it has when opened directly. The Storage interface is synchronous and stores UTF-16 strings. Keep payloads small, serialize intentionally, distinguish a missing key from a stored string, and catch SecurityError or QuotaExceededError. Never store session tokens or confidential material: any script running in the origin, including an injected script, can read it.

Load and save a small preference defensively
const defaults = { theme: 'system', compact: false };

function loadPreferences() {
  try {
    const raw = localStorage.getItem('preferences:v1');
    if (raw === null) return defaults;
    const parsed = JSON.parse(raw);
    return {
      theme: ['system', 'light', 'dark'].includes(parsed.theme) ? parsed.theme : defaults.theme,
      compact: parsed.compact === true
    };
  } catch {
    return defaults;
  }
}

function savePreferences(preferences) {
  try {
    localStorage.setItem('preferences:v1', JSON.stringify(preferences));
    return true;
  } catch (error) {
    console.warn('Preferences were not saved', error);
    return false;
  }
}

Note: Parsing is validation, not trust. Check the shape and allowed values after JSON.parse.

Back to quick reference ↑
02

Treat the storage event as notification, not locking

A storage event is sent to other relevant documents when a script changes localStorage or sessionStorage; it is not fired on the Window that made the change. For localStorage, same-origin documents can receive it; for sessionStorage, recipients must also share the same top-level browsing context. The event carries key, oldValue, newValue, url, and storageArea. It does not make read-modify-write sequences atomic, and the HTML Standard explicitly warns authors not to assume a storage mutex. Use IndexedDB transactions, a server, or another coordination design when concurrent updates must not overwrite one another.

Reflect a preference changed in another tab
addEventListener('storage', event => {
  if (event.storageArea !== localStorage || event.key !== 'theme') return;
  const theme = event.newValue ?? 'system';
  document.documentElement.dataset.theme = theme;
});

function setTheme(theme) {
  document.documentElement.dataset.theme = theme;
  localStorage.setItem('theme', theme);
}

Note: Apply the local change directly because the writing document does not receive its own storage event.

Back to quick reference ↑
03

Evolve IndexedDB schemas in the upgrade transaction

indexedDB.open returns an IDBOpenDBRequest. The success event exposes a reusable database connection, while upgradeneeded provides the exclusive versionchange transaction used to create, remove, or alter object stores and indexes. Perform deterministic migrations based on event.oldVersion; never fetch remote data during an upgrade. Existing connections can block a newer version, so close your connection on versionchange and show useful UI for the open request's blocked event. A failed or thrown upgrade aborts atomically and leaves the prior schema in place.

Open a database and migrate two schema versions
function openNotesDatabase() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('notes', 2);

    request.onupgradeneeded = event => {
      const db = request.result;
      if (event.oldVersion < 1) {
        db.createObjectStore('notes', { keyPath: 'id', autoIncrement: true });
        db.createObjectStore('audit', { keyPath: 'id', autoIncrement: true });
      }
      if (event.oldVersion < 2) {
        const store = request.transaction.objectStore('notes');
        store.createIndex('by-updated', 'updatedAt');
      }
    };

    request.onblocked = () => console.warn('Close other tabs to finish the upgrade.');
    request.onerror = () => reject(request.error);
    request.onsuccess = () => {
      const db = request.result;
      db.onversionchange = () => db.close();
      resolve(db);
    };
  });
}
Back to quick reference ↑
04

Keep IndexedDB transactions short and await completion

Every IndexedDB read or write occurs in a transaction with a fixed object-store scope and mode. Requests made by one transaction execute in order; a successful readwrite transaction commits all of its changes atomically, while an abort rolls them back. A request's success only means that request worked, not that the transaction committed. Wait for the transaction's complete event before reporting a durable application-level success. Transactions become inactive after control returns to the event loop unless a request callback is being dispatched, so schedule database requests immediately and do not await unrelated network or timer work midway through a transaction.

Write two related records and resolve after commit
function saveNoteAndAudit(db, note) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(['notes', 'audit'], 'readwrite');
    tx.objectStore('notes').put(note);
    tx.objectStore('audit').add({ noteId: note.id, at: Date.now() });

    tx.oncomplete = () => resolve();
    tx.onabort = () => reject(tx.error ?? new DOMException('Transaction aborted', 'AbortError'));
    tx.onerror = () => console.error('A request failed', tx.error);
  });
}

Note: Let the abort handler reject once; transaction error events otherwise bubble and can produce duplicate logging.

Back to quick reference ↑
05

Choose primary keys, indexes, and bounded reads deliberately

Object stores retrieve records by primary key. An index maintains another ordered key path and may be unique or multiEntry, trading write cost and disk space for efficient lookup. get returns one value, getAll returns an array and should usually have a range or count limit, and cursors stream records in key order. Results are structured clones rather than live references. Handle request errors, and remember that a missing get result is undefined. Model an index only for query patterns the application actually uses.

Read the 20 most recently updated notes
function recentNotes(db, limit = 20) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes');
    const index = tx.objectStore('notes').index('by-updated');
    const request = index.openCursor(null, 'prev');
    const notes = [];

    request.onsuccess = () => {
      const cursor = request.result;
      if (!cursor || notes.length === limit) {
        resolve(notes);
        return;
      }
      notes.push(cursor.value);
      cursor.continue();
    };
    request.onerror = () => reject(request.error);
  });
}
Read one record with a small request adapter
function requestAsPromise(request) {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

const tx = db.transaction('notes', 'readonly');
const note = await requestAsPromise(tx.objectStore('notes').get(7));
Back to quick reference ↑
06

Plan for implementation-defined quota and best-effort eviction

Storage limits are not a portable constant. navigator.storage.estimate returns deliberately imprecise usage and quota estimates for the current storage shelf, not a reservation or guarantee. Storage is best-effort by default and can be evicted under storage pressure; persist requests persistent mode, but the user agent decides whether to grant it. Persistent mode protects against automatic eviction, not explicit user deletion, application bugs, device loss, or quota errors. Ask only when data has clear user value, catch QuotaExceededError on every write path, remove regenerable data first, and synchronize irreplaceable content elsewhere.

Report an estimate and request persistence in context
async function prepareOfflineStorage() {
  const estimate = await navigator.storage.estimate();
  const usedMiB = Math.round((estimate.usage ?? 0) / 1024 / 1024);
  const quotaMiB = Math.round((estimate.quota ?? 0) / 1024 / 1024);

  let persistent = await navigator.storage.persisted();
  if (!persistent) persistent = await navigator.storage.persist();

  return { usedMiB, quotaMiB, persistent };
}

Note: Call this after explaining the offline benefit; a denied persistence request is a normal outcome.

Back to quick reference ↑
07

Make recovery, deletion, and privacy controls explicit

Storage access may fail in opaque or policy-restricted contexts, writes may exceed quota, structured cloning may reject unsupported values, transactions may abort, and users may clear site data at any time. Classify DOMException names only to improve recovery or messaging; do not hide unexpected failures. Close database connections before deletion, handle blocked when another context remains open, and delete only after an explicit user action. Logging out should remove sensitive cached application state, but local cleanup is not a substitute for invalidating server credentials.

Delete an IndexedDB database and expose blocking
function deleteNotesDatabase(openConnection) {
  openConnection?.close();
  return new Promise((resolve, reject) => {
    const request = indexedDB.deleteDatabase('notes');
    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
    request.onblocked = () => reject(new Error('Close other tabs before deleting local data.'));
  });
}
Handle a quota failure without discarding user work
try {
  await saveOfflineDraft(draft);
  showStatus('Saved on this device.');
} catch (error) {
  if (error?.name === 'QuotaExceededError') {
    keepDraftInMemory(draft);
    showStatus('Device storage is full. Copy or sync this draft before leaving.');
  } else {
    throw error;
  }
}
Back to quick reference ↑

Sources and further reading

References

Authoritative documentation used to verify and expand this cheat sheet.

  1. WHATWGWeb storagehtml.spec.whatwg.org
  2. World Wide Web ConsortiumIndexed Database API 3.0w3c.github.io
  3. WHATWGStorage Standardstorage.spec.whatwg.org
  4. MDN Web DocsWeb Storage APIdeveloper.mozilla.org
  5. MDN Web DocsStorage quotas and eviction criteriadeveloper.mozilla.org

Help us improve

Found a typo or missing example?

Tell us what would make this cheat sheet clearer, more complete, or more useful.

Share feedback