Browser storage
How to choose between cookies, localStorage, and IndexedDB
The browser gives you three storage mechanisms, and the folklore question ("which one is bigger?") is the wrong sort key. Ask instead: does the server need this value on a request? Does it outlive the tab? Is it a flag or actual data? Each answer points at exactly one mechanism.
How cookies work and what they cost
A cookie is the only storage the server can see. The server sets it with a Set-Cookie response header; the browser then attaches it to the Cookie header of every matching request, automatically. That auto-attaching is the whole point and also the whole cost: a cookie's bytes ride every fetch, upload direction included.
Scope and lifetime come from attributes:
Domainunset: only the exact host that set it. Set: that host plus subdomains.Pathis a prefix match;Path=/docsmatches/docs/webbut not/.Expires/Max-Ageabsent: a session cookie, gone when the browser session ends. Present: a date or a lifetime in seconds (Max-Agewins if both).Max-Age=0deletes.
Budget: ~4KB per cookie, a few hundred per domain. ❌ "Store the user's data in cookies so the server has it": those 4KB get re-uploaded on every request. Cookies carry identifiers and small flags, not data.
Where the auth token lives (cookie vs localStorage) is a security blast-radius decision. That debate belongs to the web-security topic; here we only cover mechanics.
localStorage vs sessionStorage
localStorage and sessionStorage share one API: key/value, strings only, synchronous, ~5MB per origin, invisible to the server. The difference is lifetime:
localStorageis per origin, survives restarts, and is shared by all tabs of the origin.sessionStorageis per origin and per tab. It survives a reload, but a new tab, even a duplicated one, starts empty. ❌ "sessionStorage is shared across tabs": it is the per-tab one, and that isolation is its feature (per-tab wizard state, "this visit" flags).
Right-sized uses are a theme, a language, a dismissed-banner flag: small values you want readable synchronously before first paint.
When data belongs in IndexedDB
IndexedDB is asynchronous, stores real objects (including Blobs and Files), and holds far more than 5MB. If you have a dataset (documents, messages, anything you'd map over), put it here instead of serializing it into a localStorage string. The API details are the middle-level story.
The decision table
| Question | Answer |
|---|---|
| Server needs it on requests? | Cookie (small, mind the per-request weight) |
| Tiny pref, read sync at startup? | localStorage |
| This-tab-only state? | sessionStorage |
| Real data, offline corpus, blobs? | IndexedDB |
One more contract to know: in private mode all of these still work, but everything is wiped when the window closes.
Why localStorage blocks the main thread
Every localStorage call is synchronous on the main thread, and every value is a string. So "we keep our data in localStorage" means: at boot, a blocking read plus a blocking JSON.parse; on every save, a blocking JSON.stringify plus a blocking write. At a few keys of a few bytes this is invisible. At megabytes it is your startup freeze, and there is no async mode to switch on. ❌ "localStorage is fine for big blobs, it's basically a small database": it is a synchronous string map, and size is exactly what it's bad at.
How the storage event works across tabs
When a tab writes to localStorage, a storage event fires on the window of other same-origin tabs. The writer itself never receives it. The event carries key, oldValue, newValue, and the writer's url:
addEventListener("storage", (e) => {
if (e.key === "theme") applyTheme(e.newValue);
});
The "other tabs only" rule makes sense once you notice the writer already knows what it wrote, and it turns the event into a poor man's cross-tab bus. It remains a hack: you serialize messages into storage to get a signal out. The purpose-built tool is BroadcastChannel, same-origin pub/sub with no storage involved. Reach for the storage event only when the state itself lives in localStorage anyway.
How IndexedDB is organized
A per-origin database holds object stores (think tables); stores hold records saved via structured clone: real objects, arrays, Dates, Blobs, Files. Functions and DOM nodes won't clone. Keys come from a keyPath (a property of the stored object) and/or an autoIncrement generator. Indexes give you lookup by non-key fields, with unique and compound variants.
Everything runs inside a transaction, readonly or readwrite, scoped to named stores. Transactions auto-commit: when you return to the event loop with no pending requests, the transaction is done, so you can't hold one open across an await fetch(). Unhandled errors bubble up and abort the whole transaction (rollback), which is what a transaction is for: two tabs writing concurrently can't half-apply.
How IndexedDB versioning and upgrades work
indexedDB.open("notes", 2): the integer version is your schema version. All schema changes (creating stores, adding indexes) are legal only inside onupgradeneeded, which fires when the stored version is lower than requested:
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (e.oldVersion < 1) db.createObjectStore("notes", { keyPath: "id" });
if (e.oldVersion < 2)
e.target.transaction.objectStore("notes").createIndex("byUpdated", "updatedAt");
};
Multi-tab wrinkle: an upgrade can't proceed while another tab holds a connection at the old version. The open request gets blocked, and the old tab gets versionchange, where it should close its connection (and prompt a reload).
Why everyone uses the idb wrapper
The raw API is event-based: request.onsuccess soup, no promises. Nearly everyone ships the tiny idb wrapper and writes await db.get("notes", id). Using a wrapper isn't a smell; hand-rolling the event plumbing is. One capability Web Storage can't match: IndexedDB works in Workers and Service Workers, so heavy reads and background sync can leave the main thread entirely.
How storage quota works
Quota is per origin and covers IndexedDB, the Cache API (the Service Worker one; strategies live in the http-caching topic, here it is only another bucket), and OPFS. Shapes differ: Chrome allows an origin up to ~60% of disk; Firefox caps a site group at 2GB; Safari gives ~1GB and then prompts the user in increments. The numbers matter less than the interface: navigator.storage.estimate() returns { usage, quota }, and a write past quota fails loudly (QuotaExceededError; in IndexedDB the transaction aborts). If you store at scale, ship a handler for that error path, because it fires in normal use.
When browsers evict your data
Unpersisted storage is best-effort: under disk pressure, Chromium and Firefox evict least-recently-used origins, and eviction is origin-wide. Your app doesn't lose its oldest records; it loses everything at once. navigator.storage.persist() exempts the origin from automatic eviction. Chrome decides silently by heuristics (engagement, installed, notification permission), Firefox shows a prompt, and there is no programmatic revoke. Two things persist() does NOT do: survive the user clearing site data, or override Safari's cap. ❌ "We called persist(), so the data is safe": you removed one of three deletion paths.
Safari's 7-day cap
ITP deletes all script-writable storage (localStorage, sessionStorage, IndexedDB, Service Worker registrations and their caches) after seven days of Safari use without the user interacting with the site. Interaction resets the counter; days Safari isn't used don't count. This is the one that kills "offline-first" on iOS: a notes app whose user goes on holiday comes back to an empty database, persist() or not. The exemption: home-screen-installed web apps get their own counter tied to app use. The honest mitigations are (a) installation, or (b) architecture, covered below.
Which contexts can access which storage
| Context | Web Storage | IndexedDB | Cache API | document.cookie |
|---|---|---|---|---|
| Window | ✓ | ✓ | ✓ | ✓ |
| Worker / Service Worker | ✗ | ✓ | ✓ | ✗ |
This matrix decides more designs than quota does: anything a Service Worker must read at fetch time (offline data, sync queues) cannot live in localStorage. IndexedDB is the only data store all contexts share.
Migrating versioned local data
Client schemas drift across deploys, and old tabs run old code. Three workable strategies, in escalating effort: IDB version chains (if (oldVersion < n) steps in onupgradeneeded, handling versionchange/blocked for stale tabs); schema-version-in-record, upgrading lazily on read; and the one that makes the others cheap, treating client data as a rebuildable cache. If a migration fails, drop the database and refetch. That works only when the server is the home of record, which is the design you wanted anyway.
When not to store client-side
Given eviction, the 7-day cap, "clear site data", and private mode, client storage is a disposable replica: good for latency, offline reads, and drafts-in-flight, never the home of anything the user would call "my data". Most apps need less client storage than they think: a session flag, a few prefs, a cache. If your design's failure mode is "the user lost work", the fix is a server, not a bigger quota.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.