Storing state

Keep state in the browser without losing it, corrupting it, or crashing when it is blocked.

24of 32
≈ 10Min read
8Parts

The four places

Storage is per origin, and both Storage APIs are synchronous — reading a large value blocks the thread. For anything beyond preferences and small state, IndexedDB is the right answer.

WhereSizeLives untilSent to the server
localStorage~5 MBExplicitly clearedNo
sessionStorage~5 MBThe tab closesNo
A cookie~4 KBIts expiryYes, on every request
IndexedDBHundreds of MBExplicitly clearedNo

Strings only

so everything goes through JSON
localStorage.setItem("count", 5);
localStorage.getItem("count");        // "5" — a string

localStorage.setItem("prefs", JSON.stringify({ theme: "dark" }));
JSON.parse(localStorage.getItem("prefs"));

localStorage.removeItem("prefs");
localStorage.clear();
localStorage.length;
Object.keys(localStorage);
getItem returns null, not undefined

JSON.parse(null) returns null rather than throwing, but JSON.parse(undefined) throws. Default it: JSON.parse(localStorage.getItem(k) ?? "null").

It throws more often than you think

  • Safari in private mode used to throw on every write. Some browsers throw on read when storage is disabled.
  • The quota is per origin, and exceeding it throws QuotaExceededError.
  • The stored JSON may have been written by an older version of your code, or edited by hand. Parsing it can throw.
  • A site in a cross-origin iframe may have no storage at all under some privacy settings.
so every access is wrapped
const store = {
  get(key, fallback = null) {
    try {
      const raw = localStorage.getItem(key);
      return raw === null ? fallback : JSON.parse(raw);
    } catch {
      return fallback;     // blocked, or corrupted JSON
    }
  },

  set(key, value) {
    try {
      localStorage.setItem(key, JSON.stringify(value));
      return true;
    } catch {
      return false;        // quota exceeded, or blocked
    }
  },

  remove(key) {
    try { localStorage.removeItem(key); } catch { /* blocked */ }
  },
};

Versioning what you store

Putting the version in the key means an old value is simply never read. Putting it inside the value lets you migrate it. Either is fine; having neither means a year-old value crashes your new code.

because your shape will change
const KEY = "dv.prefs.v2";

function load() {
  const saved = store.get(KEY);
  if (!saved || saved.v !== 2) return defaults();   // ignore anything older
  return saved;
}

store.set(KEY, { v: 2, theme: "dark" });

Reacting to another tab

It does not fire in the tab that made the change — only in the others. That is exactly what you want for keeping a theme or a sign-out in step across tabs.

the storage event fires in the OTHER tabs
addEventListener("storage", (e) => {
  if (e.key !== "dv.prefs.v2") return;
  applyPrefs(JSON.parse(e.newValue ?? "null"));
});

Cookies

  • A cookie is sent with every request to that origin, so it costs bandwidth on every page load and every asset.
  • A session cookie must be HttpOnly — set by the server, unreadable by JavaScript. That is what stops a cross-site scripting bug from stealing the session.
  • SameSite=Lax is the sensible default; Strict breaks links from other sites; None requires Secure.
  • Use a cookie only when the server needs the value. For anything client-side, use storage.
when the server needs to see it
document.cookie = "theme=dark; path=/; max-age=31536000; SameSite=Lax";
document.cookie;      // every readable cookie, as one string

Never store a secret

Anything in localStorage is readable by any JavaScript running on your origin — including a third-party script, and including anything injected through a cross-site scripting bug. An access token in localStorage is a token that a single XSS hands over.

Session identity belongs in an HttpOnly cookie set by the server. Storage is for preferences, drafts and caches — things it would be annoying, not dangerous, to lose.

IndexedDB, in one line

the API is verbose; a tiny wrapper is normal
// use it when: the data is large, structured, or queried —
// offline records, cached responses, a document editor's history.
// The raw API is event-based and awkward; idb-keyval is 600 bytes.

Practice