The essentials
Quick reference
One focused task per row. Jump to the related section for complete, working examples.
| Use | Syntax | Examples |
|---|---|---|
| Save a persistent string | localStorage.setItem('theme', 'dark') | View examples |
| Read a persistent string | const theme = localStorage.getItem('theme') | View examples |
| Remove one stored value | localStorage.removeItem('theme') | View examples |
| Save tab-scoped state | sessionStorage.setItem('draft-step', '2') | View examples |
| Serialize a small object | localStorage.setItem('prefs', JSON.stringify(preferences)) | View examples |
| Observe another document | addEventListener('storage', event => syncPreference(event)) | View examples |
| Open a versioned database | const request = indexedDB.open('notes', 3) | View examples |
| Create an object store | db.createObjectStore('notes', { keyPath: 'id', autoIncrement: true }) | View examples |
| Create a non-unique index | store.createIndex('by-updated', 'updatedAt') | View examples |
| Release an old connection | db.onversionchange = () => db.close() | View examples |
| Start an atomic write | const tx = db.transaction('notes', 'readwrite') | View examples |
| Insert a new record | store.add({ title: 'Plan', updatedAt: Date.now() }) | View examples |
| Insert or replace a record | store.put({ id: 7, title: 'Revised', updatedAt: Date.now() }) | View examples |
| Read by primary key | const request = store.get(7) | View examples |
| Query an index range | const request = index.getAll(IDBKeyRange.lowerBound(cutoff)) | View examples |
| Iterate in reverse order | const request = index.openCursor(null, 'prev') | View examples |
| Estimate usage and quota | const { usage, quota } = await navigator.storage.estimate() | View examples |
| Request persistent storage | const persistent = await navigator.storage.persist() | View examples |
| Delete a database | const 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
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.
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.
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.
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.
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.
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);
};
});
} 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.
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.
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.
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);
});
} 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)); 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.
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.
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.
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.'));
});
} 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;
}
} Sources and further reading
References
Authoritative documentation used to verify and expand this cheat sheet.
Help us improve
Found a typo or missing example?
Tell us what would make this cheat sheet clearer, more complete, or more useful.



