FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·075·10 MIN READ

075: Browser Storage: localStorage, sessionStorage, Cookies, and IndexedDB

TOPICS COVERED: Browser Storage: localStorage, sessionStorage, Cookies, and IndexedDB

Outcomes

By the end of this lesson, you can:

  • use localStorage.getItem(), setItem(), and removeItem();
  • 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

Before working through the examples, retrieve a few ideas from the earlier lessons:

  1. Why is browser-side validation not a security boundary?
  2. In the Todo architecture, what changes first after a user action?
  3. 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

Think of localStorage as a small cupboard belonging to one origin. It contains string key/value pairs and usually remains available after a browser restart. The cupboard is not a database guarantee, though. The user may clear it, private browsing may remove it when the session ends, policy may block access, a write may exceed quota, browser management may evict data, and another script on the same origin may change the contents.

That model leads to two deliberately different paths:

text
load: storage string -> parse -> validate -> state -> render
save: state -> stringify -> try storage write

While the page is running, the application should continue to use in-memory state if persistence fails. Storage is a convenience, not the runtime source of truth, and it is not an appropriate place for secrets.

Self-Study Example: Persistent Todo List

Build this complete index.html:

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>

Now add app.js:

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

Work through this sequence:

  1. Add two tasks and reload. They should return.
  2. Open DevTools Application or Storage, find Local Storage, and inspect the JSON string.
  3. Change the value to invalid JSON such as {broken, then reload. The app falls back to [] rather than crashing.
  4. Change it to valid but wrong-shaped JSON such as {"admin":true}. Schema validation rejects it.
  5. Block site storage if your browser tools permit and confirm the app still works in memory with an honest status.

This implementation attaches listeners as each task item is created. Since replaceChildren() removes the old elements and their listeners together, listeners do not accumulate. There is a related focus detail: after render(), the checkbox that fired change is gone. The handler therefore finds the replacement using its stable, escaped ID and restores focus. After Delete all, the initiating button becomes disabled, so focus moves to the title input instead. Lesson 077 will use event delegation and keep rendering purely structural.

Strings and JSON

Web Storage values are strings. If you pass another type, the API converts it to a string:

js
localStorage.setItem("count", 3);
localStorage.getItem("count"); // "3"

For objects and arrays, perform the conversion explicitly with JSON:

js
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 stick to plain objects, arrays, strings, booleans, numbers, and null where appropriate.

JSON.parse() can throw. Even when it succeeds, it has established only that the text was valid JSON. It has not established that the result is a task array or that each task has safe, expected fields. That is why the example checks both Array.isArray() and isStoredTask().

Storage Scope and Limits

localStorage is separated by origin. https://example.com and http://example.com are different origins, and ports are part of the boundary as well. Do not build an application around the behavior of pages opened directly from file: URLs; use a local development server instead.

The API is synchronous. A large read or write, or frequent serialization, can block the main thread. That is acceptable for this small educational Todo list, but not for large documents, media, or high-frequency data. IndexedDB is the usual browser database alternative when larger asynchronous storage is needed.

The HTML Standard permits setItem() to throw QuotaExceededError when a value cannot be stored. Storage access or getters can throw SecurityError when policy denies persistence or the origin is unsuitable. Treat each operation as fallible and catch the operations that may fail.

Intermediate Example: Versioned Envelope

A versioned envelope gives future migrations an explicit home:

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

Use the same try/catch strategy as in the guided example around calls that can fail. Do not add migration code merely because a version field exists; wait until an actual previous shipped version needs migration. For now, the version simply makes that future decision clear.

Optional Advanced Example: Synchronize Another Tab

When storage changes, the storage event is delivered to other same-origin documents. It is not normally delivered to the document that performed the write:

js
window.addEventListener("storage", (event) => {
  if (event.storageArea !== localStorage || event.key !== STORAGE_KEY) {
    return;
  }

  tasks = loadTasks();
  status.textContent = "Tasks changed in another tab.";
  render();
});

This can keep another tab current, but it is not transactional synchronization. The specification warns authors not to assume that tabs lock storage against one another. Two tabs can read the same old value and then overwrite each other's updates. Multi-user or critical data needs server coordination and an appropriate conflict strategy.

Mistakes and Debugging

  • Parsing null carelessly: 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 produce an object, number, or maliciously edited data.
  • Using localStorage.tasks: property syntax works in many cases but can collide with built-ins. Prefer getItem()/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.

When debugging, inspect the exact stored string rather than only the parsed result. Parse it manually inside a try/catch, check the schema, and watch the Console for caught failures. Exercise the boundaries deliberately: missing, malformed, wrong-shaped, oversized, and blocked storage should all be tested.

Accessibility, Security, and Performance

Accessibility: persistence should not surprise users. Provide a clearly named delete-all control and announce save failures without reporting success that did not happen. Essential preferences should not be trapped in storage without a reset path. Persisted content still belongs in 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 that data persists, and give users control to delete it. Render loaded strings with textContent; stored data is untrusted.

Performance: Web Storage is synchronous. Save after meaningful state changes, keep the payload small, and avoid repeated JSON work inside loops. For larger or high-frequency data, use an asynchronous API such as IndexedDB. Measure the actual behavior rather than guessing.

Exercises

Core

Persist a theme string under todo-course.theme.v1. Accept only "light" or "dark"; use "light" for every other value.

Practice

Add delete buttons to tasks. When a task is deleted, 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 and shapes.

Core

js
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

js
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

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

Cookies, Web Storage, and IndexedDB

Cookies and Web Storage solve different problems. Cookies are small name/value pairs sent with matching HTTP requests according to Domain, Path, SameSite, Secure, and expiry rules; JavaScript can read only cookies that do not have HttpOnly. localStorage and sessionStorage are origin-scoped, synchronous string stores 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.

js
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-based authentication introduces CSRF design responsibilities. Neither mechanism 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 still needs explicit handling. This runnable smoke test creates a database, writes one record, reads it, and asserts the result:

js
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 and private-policy failures, an upgrade from version 1 to 2, transaction aborts, duplicate keys, and closing or deleting the database. Use an IndexedDB wrapper only after you understand the underlying transaction boundaries; it is not a synchronous drop-in replacement.

Interview questions

  1. Why is localStorage risky in a hot input handler? Serialization and storage access are synchronous and can delay input and rendering.
  2. What does HttpOnly do? It prevents JavaScript from reading that cookie; it does not prevent the browser from sending it to matching requests.
  3. When choose IndexedDB? For larger structured data, indexes, transactions, or asynchronous persistence rather than tiny string preferences.
  4. 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 related requests in the same readwrite transaction and listen for the transaction's oncomplete, onerror, and onabort, not just an individual request's success event.

js
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. Resolving from the first request's onsuccess would falsely report success while the transaction could 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. Start 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().

js
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 the individual request succeeded, not that the transaction committed. Why close on versionchange? An old connection can otherwise block a 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.

Reader page: /javascript/lesson/075/browser-storage-localstorage-sessionstorage-cookies-and-indexeddb