075: Browser Storage: localStorage, sessionStorage, Cookies, and IndexedDB
Outcomes
By the end of this lesson, you can:
- use
localStorage.getItem(),setItem(), andremoveItem(); - serialize structured data with JSON;
- recover from missing, malformed, or wrong-shaped stored data;
- handle storage access and quota failures;
- explain origin scope, synchronous behavior, privacy modes, eviction, and user clearing; and
- persist Todo state without treating storage as trusted or permanent.
Retrieval Warm-Up
- Why is browser-side validation not a security boundary?
- In the Todo architecture, what changes first after a user action?
- Why must a task loaded from storage still be rendered with
textContent?
Terms
- Web Storage: Origin-scoped key/value persistence: localStorage and sessionStorage. — Source: WHATWG HTML: Web storage
- Origin: Scheme+host+port tuple defining the isolation boundary for storage. — Source: WHATWG URL: Origin
- Serialization: Converting structured values to strings via JSON.stringify for storage. — Source: MDN: JSON.stringify
- Deserialization: Rebuilding values from stored strings via JSON.parse with validation. — Source: MDN: JSON.parse
- JSON: Text-based data-interchange format derived from JavaScript literals. — Source: RFC 8259
- Schema validation: Checking that parsed data has the expected structure and types (course term).
- Quota: A browser-managed storage limit (course term).
- Eviction: Browsers may discard origin data under quota pressure; treat storage as best-effort. — Source: MDN: Web Storage API
- Synchronous: localStorage calls block until complete; avoid hot paths. — Source: MDN: Web Storage API
- Serialization (official): "Serialization converts a JavaScript value to a JSON string via JSON.stringify()." — Source: MDN: JSON.stringify()
- Quota (storage): "Storage quota is the maximum amount of data a storage area can hold (typically 5-10 MB for localStorage)." — Source: MDN: Storage — Quota
Mental Model: A Fallible String Cupboard
localStorage is a small cupboard of string key/value pairs associated with an origin. It usually survives browser restarts, but it is not a database guarantee. A user can clear it; private browsing removes it at session end; policy can block it; quota can be exceeded; browser management can evict data; and another script on the same origin can alter it.
Therefore:
load: storage string -> parse -> validate -> state -> render save: state -> stringify -> try storage write
The app must still work in memory if persistence fails. Storage is a convenience, not the source of truth while the page runs and not a suitable home for secrets.
Self-Study Example: Persistent Todo List
Create this complete index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Persistent tasks</title>
<script src="app.js" defer></script>
</head>
<body>
<main>
<h1>My tasks</h1>
<form id="task-form">
<label for="task-title">Task</label>
<input id="task-title" name="title" required maxlength="80">
<button type="submit">Add task</button>
</form>
<p id="status" role="status"></p>
<ul id="task-list"></ul>
<button id="clear-tasks" type="button">Delete all tasks</button>
</main>
</body>
</html>
Add app.js:
const STORAGE_KEY = "todo-course.tasks.v1";
const form = document.querySelector("#task-form");
const titleInput = document.querySelector("#task-title");
const taskList = document.querySelector("#task-list");
const status = document.querySelector("#status");
const clearButton = document.querySelector("#clear-tasks");
function isStoredTask(value) {
return value !== null
&& typeof value === "object"
&& typeof value.id === "string"
&& /^[A-Za-z0-9-]{1,100}$/.test(value.id)
&& typeof value.title === "string"
&& value.title.trim().length >= 1
&& value.title.length <= 80
&& typeof value.completed === "boolean";
}
function loadTasks() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === null) {
return [];
}
const parsed = JSON.parse(stored);
const idsAreUnique = Array.isArray(parsed)
&& new Set(parsed.map((task) => task?.id)).size === parsed.length;
if (!Array.isArray(parsed) || !parsed.every(isStoredTask) || !idsAreUnique) {
console.warn("Ignoring stored tasks with an unexpected shape.");
return [];
}
return parsed;
} catch (error) {
console.warn("Tasks could not be loaded.", error);
return [];
}
}
let tasks = loadTasks();
function saveTasks() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
return true;
} catch (error) {
console.warn("Tasks could not be saved.", error);
return false;
}
}
function createTaskItem(task) {
const item = document.createElement("li");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.id = `task-${task.id}`;
checkbox.checked = task.completed;
const label = document.createElement("label");
label.htmlFor = checkbox.id;
label.textContent = task.title;
checkbox.addEventListener("change", () => {
task.completed = checkbox.checked;
const saved = saveTasks();
status.textContent = saved
? `Updated task: ${task.title}`
: "Task updated for this session, but could not be saved.";
render();
document.querySelector(`#task-${CSS.escape(task.id)}`)?.focus();
});
item.append(checkbox, label);
return item;
}
function render() {
taskList.replaceChildren(...tasks.map(createTaskItem));
clearButton.disabled = tasks.length === 0;
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const title = titleInput.value.trim();
if (title === "") {
return;
}
tasks.push({ id: crypto.randomUUID(), title, completed: false });
const saved = saveTasks();
status.textContent = saved
? `Added and saved task: ${title}`
: "Task added for this session, but could not be saved.";
form.reset();
render();
titleInput.focus();
});
clearButton.addEventListener("click", () => {
tasks = [];
try {
localStorage.removeItem(STORAGE_KEY);
status.textContent = "All tasks deleted.";
} catch (error) {
console.warn("Stored tasks could not be removed.", error);
status.textContent = "Tasks cleared for this session; stored data could not be changed.";
}
render();
titleInput.focus();
});
render();
Test this sequence:
- Add two tasks and reload. They should return.
- Open DevTools Application or Storage, find Local Storage, and inspect the JSON string.
- Change the value to invalid JSON such as
{broken, then reload. The app falls back to[]rather than crashing. - Change it to valid but wrong-shaped JSON such as
{"admin":true}. Schema validation rejects it. - Block site storage if your browser tools permit and confirm the app still works in memory with an honest status.
This version adds listeners while creating items. Because replaceChildren() discards old items and listeners together, it does not accumulate listeners. It also means the checkbox that fired change no longer exists after render(), so the handler finds the replacement by its stable, escaped ID and restores focus to it. After Delete all, the initiating button becomes disabled, so focus moves to the title input instead. 077 will use delegation to keep render purely structural.
Strings and JSON
Storage values are strings. Passing another type causes string conversion:
localStorage.setItem("count", 3);
localStorage.getItem("count"); // "3"
Objects need explicit JSON:
const text = JSON.stringify(tasks);
const value = JSON.parse(text);
JSON does not preserve every JavaScript type. Dates become strings, undefined object properties disappear, functions are not data, and cyclic objects throw during stringification. A Todo schema should use plain objects, arrays, strings, booleans, numbers, and null as needed.
JSON.parse() can throw. Successful parsing proves only valid JSON syntax, not that the result is a task array. That is why Array.isArray() and isStoredTask() both matter.
Storage Scope and Limits
localStorage is separated by origin. https://example.com and http://example.com are different origins; ports also matter. Behavior for pages opened directly from file: URLs is not something applications should rely on, so use a local development server.
The API is synchronous. Large reads, writes, or frequent serialization can block the main thread. It is suitable for a small educational Todo list, not large documents, media, or high-frequency data. IndexedDB is the usual browser database alternative for larger asynchronous storage needs.
The HTML Standard permits setItem() to throw QuotaExceededError when a value cannot be stored, and storage getters/access can throw SecurityError when policy denies persistence or the origin is unsuitable. Catch operations that may fail.
Intermediate Example: Versioned Envelope
A version makes future migrations explicit:
function saveState(tasksToSave) {
const payload = {
version: 1,
tasks: tasksToSave,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}
function parseState(text) {
const payload = JSON.parse(text);
if (payload === null || typeof payload !== "object") return [];
if (payload.version !== 1) return [];
if (!Array.isArray(payload.tasks)) return [];
if (!payload.tasks.every(isStoredTask)) return [];
return payload.tasks;
}
Wrap calls in the same try/catch strategy as the guided example. Do not add migration code until an actual previous shipped version exists; versioning merely creates a clear place for one.
Optional Advanced Example: Synchronize Another Tab
The storage event is delivered to other same-origin documents when storage changes, not normally to the document that made the write:
window.addEventListener("storage", (event) => {
if (event.storageArea !== localStorage || event.key !== STORAGE_KEY) {
return;
}
tasks = loadTasks();
status.textContent = "Tasks changed in another tab.";
render();
});
This is useful but not transactional synchronization. The specification warns authors not to assume locking across tabs. Two tabs can read the same old value and overwrite each other's updates. Multi-user or critical data needs server coordination and appropriate conflict handling.
Mistakes and Debugging
- Parsing
nullcarelessly: explicitly handle a missing key before parsing. - Catching JSON errors but not storage errors:
getItem(),setItem(), and even storage access can fail under policy or quota conditions. - Trusting parsed shape: JSON can be valid and still be an object, number, or maliciously edited data.
- Using
localStorage.tasks: property syntax works in many cases but can collide with built-ins. PrefergetItem()/setItem(). - Calling
localStorage.clear(): it deletes every key for the origin, including unrelated app data. Remove your namespaced key. - Saving on every keystroke: synchronous serialization can cause jank and persist incomplete private drafts.
- Storing secrets: any script running on the origin can access storage. Never store passwords, session tokens, or sensitive personal data there.
- Assuming forever: user clearing, private mode, quota management, and browser policy defeat that assumption.
Debug by inspecting the exact stored string, parsing it manually in a try/catch, checking the schema, and watching the Console for caught failures. Test missing, malformed, wrong-shaped, oversized, and blocked cases.
Accessibility, Security, and Performance
Accessibility: persistence should not surprise users. Provide a clearly named delete-all control and announce save failures without claiming success. Do not trap essential preferences in storage with no reset. Persisted content must still render into semantic lists with labels and keyboard-operable controls.
Security/privacy: local storage is readable by same-origin JavaScript, including code compromised through XSS. It is not encrypted protection, authentication, or authorization. Minimize retained data, explain persistence, and give users control to delete it. Render loaded strings with textContent; stored data is untrusted.
Performance: Web Storage is synchronous. Save only after meaningful state changes, keep payloads small, and avoid repeated JSON work inside loops. For larger or high-frequency data, use an asynchronous API such as IndexedDB. Measure rather than guessing.
Exercises
Core
Persist a theme string under todo-course.theme.v1. Allow only "light" or "dark"; use "light" otherwise.
Practice
Add delete buttons to tasks. On delete, update state, save, render, and announce whether persistence succeeded.
Professional Extension
Convert the guided format to { version: 1, tasks: [...] } and robustly reject all other versions/shapes.
Core
const THEME_KEY = "todo-course.theme.v1";
function loadTheme() {
try {
const theme = localStorage.getItem(THEME_KEY);
return theme === "dark" ? "dark" : "light";
} catch {
return "light";
}
}
function saveTheme(theme) {
if (theme !== "light" && theme !== "dark") return false;
try {
localStorage.setItem(THEME_KEY, theme);
return true;
} catch {
return false;
}
}
Practice
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.textContent = `Delete ${task.title}`;
deleteButton.addEventListener("click", () => {
tasks = tasks.filter((item) => item.id !== task.id);
const saved = saveTasks();
status.textContent = saved
? `Deleted task: ${task.title}`
: "Task deleted for this session, but the change could not be saved.";
render();
});
item.append(" ", deleteButton);
Professional Extension
function loadTasks() {
try {
const text = localStorage.getItem(STORAGE_KEY);
if (text === null) return [];
const payload = JSON.parse(text);
if (payload === null || typeof payload !== "object") return [];
if (payload.version !== 1 || !Array.isArray(payload.tasks)) return [];
return payload.tasks.every(isStoredTask) ? payload.tasks : [];
} catch {
return [];
}
}
function saveTasks() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, tasks }));
return true;
} catch {
return false;
}
}
Recap
- Web Storage stores string key/value pairs by origin.
- JSON serializes structured data, but parse and shape validation are separate steps.
- Missing, malformed, blocked, quota-limited, or cleared storage must not break the app.
- Keep runtime state in memory and persist after meaningful changes.
- Stored data is neither trusted nor guaranteed permanent.
- Web Storage is synchronous and inappropriate for secrets or large datasets.
Official References
- MDN: Web Storage API
- MDN: Using the Web Storage API
- MDN:
Storage - MDN: Storage quotas and eviction
- WHATWG HTML: Web storage
- WHATWG HTML:
Storageinterface - WHATWG HTML: Storage privacy and security
- WCAG 2.2 SC 3.2.2: On Input
Cookies, Web Storage, and IndexedDB
Cookies and Web Storage are not interchangeable. Cookies are small name/value pairs sent to matching HTTP requests according to Domain, Path, SameSite, Secure, and expiry rules; JavaScript can read only cookies without HttpOnly. localStorage and sessionStorage are origin-scoped synchronous strings and are not automatically sent to the server. sessionStorage is generally scoped to a tab's page session, while localStorage survives restarts subject to browser policy.
document.cookie = "theme=dark; Max-Age=86400; Path=/; SameSite=Lax; Secure";
localStorage.setItem("theme", "dark");
sessionStorage.setItem("draft", "temporary");
Do not put passwords or long-lived bearer tokens in JavaScript-readable storage. HttpOnly; Secure; SameSite=Lax cookies reduce script exposure for sessions, but cookie authentication creates CSRF design responsibilities. Neither storage choice replaces server authentication or authorization.
IndexedDB is an asynchronous, transactional, origin-scoped database for larger structured data. It avoids blocking the main thread in the same way synchronous Web Storage can, but its request and transaction lifecycle must still be handled. This runnable smoke test creates a database, writes one record, reads it, and asserts the result:
const request = indexedDB.open("course-demo", 1);
request.onupgradeneeded = () => request.result.createObjectStore("notes", { keyPath: "id" });
request.onerror = () => console.error(request.error);
request.onsuccess = () => {
const db = request.result;
const write = db.transaction("notes", "readwrite").objectStore("notes").put({ id: 1, text: "async storage" });
write.onerror = () => console.error(write.error);
write.onsuccess = () => {
const read = db.transaction("notes", "readonly").objectStore("notes").get(1);
read.onsuccess = () => console.assert(read.result.text === "async storage");
read.onerror = () => console.error(read.error);
};
};
Test blocked/private-policy failures, an upgrade from version 1 to 2, transaction abort, duplicate keys, and closing/deleting the database. Use an IndexedDB wrapper only after understanding the underlying transaction boundaries; do not claim it is a synchronous drop-in replacement.
Interview questions
- Why is
localStoragerisky in a hot input handler? Serialization and storage access are synchronous and can delay input and rendering. - What does
HttpOnlydo? It prevents JavaScript from reading that cookie; it does not prevent the browser from sending it to matching requests. - When choose IndexedDB? For larger structured data, indexes, transactions, or asynchronous persistence rather than tiny string preferences.
- Does clearing localStorage clear cookies or IndexedDB? No. They are separate storage mechanisms, though users and browser policies may clear an origin's data together.
IndexedDB transaction boundaries
IndexedDB requests are asynchronous, but the transaction is the unit of atomicity. Keep all related requests in the same readwrite transaction and listen to the transaction's oncomplete, onerror, and onabort, not only an individual request's success event.
function putNoteAndAudit(db, note) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(["notes", "audit"], "readwrite");
transaction.objectStore("notes").put(note);
transaction.objectStore("audit").add({ noteId: note.id, action: "put" });
transaction.oncomplete = resolve;
transaction.onabort = () => reject(transaction.error ?? new Error("transaction aborted"));
transaction.onerror = () => reject(transaction.error ?? new Error("transaction failed"));
});
}
If audit.add() violates a key constraint, the transaction aborts and the note write is rolled back. Calling resolve from the first request's onsuccess would falsely report success while the transaction can still fail. Requests must be made while the transaction is active; do not insert an unrelated await between requests and assume the transaction remains open across event-loop turns. Use a new transaction for later reads.
Upgrade and failure tests
Create version 1 with notes, then open version 2 and create audit in onupgradeneeded. Test onblocked by leaving an old connection open, handle db.onversionchange by closing that connection, and test duplicate keys plus an explicit transaction.abort().
const tx = db.transaction("notes", "readwrite");
tx.objectStore("notes").put({ id: 9, text: "temporary" });
tx.abort();
tx.onabort = () => {
const check = db.transaction("notes").objectStore("notes").get(9);
check.onsuccess = () => console.assert(check.result === undefined);
};
Interview questions: What does request success prove? Only that that request succeeded, not that the transaction committed. Why close on versionchange? An old connection can otherwise block schema upgrade. Why is IndexedDB not a drop-in localStorage replacement? Its values, requests, transaction lifetime, and error model are asynchronous and transactional rather than synchronous string access.
