StorageGENERALBROWSER-SPECIFICSPEC-EVOLVING

Choosing Browser Storage

Cookies, localStorage, sessionStorage, IndexedDB and Cache Storage compared on the six axes that actually decide: lifetime, scope, capacity, synchronicity, automatic transmission and exposure.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Where should this piece of data live in the browser, and what am I signing up for when I put it there?

The user intent

A person wants the product to remember something — that they are signed in, that they prefer larger text, that they had a half-written draft open, that they worked offline on a train and did not lose it.

The obvious build

localStorage is one line, works in every browser, and needs no setup, so it becomes the default home for everything: the theme, the session token, the last search, a cached list of four thousand orders, the whole feature-flag payload.

Why it breaks

It is synchronous. A localStorage.getItem inside an input or scroll handler blocks the one thread that also owns layout, paint and the accessibility tree, and the block scales with the size of the value (What the Main Thread Owns).

How it breaks in a real browser
  • It is synchronous. A localStorage.getItem inside an input or scroll handler blocks the one thread that also owns layout, paint and the accessibility tree, and the block scales with the size of the value (What the Main Thread Owns).
  • It stores strings only, so a four-thousand-row cache becomes JSON.parse on every read — main-thread work proportional to the payload, repeated on every navigation (The Real Cost of JavaScript).
  • Its quota is small relative to what an offline-capable app wants, and exceeding it throws rather than evicting. On a full disk or in a private window it can throw on the very first write.
  • It is scoped to the origin, not the tab, so two tabs editing the same key overwrite each other with no ordering and no notification unless you subscribe to one (State Synchronization).
  • It is readable by every script running on the origin, including the analytics tag and the widget someone added last quarter (Third-Party Scripts and the Supply Chain).
  • And it is never sent to the server. If the thing you stored has to travel on a request, localStorage was the wrong shape and a cookie was the right one (Cookies).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Cookies are name/value pairs the browser attaches automatically to requests whose URL matches the cookie's Domain and Path. They are tiny, they are readable by script unless marked HttpOnly, and their defining property is that you do not have to send them — the browser does (Cookies).
  • `localStorage` is a synchronous string-to-string map, scoped to the origin, with no expiry. It survives browser restarts and is the only store in the list whose API blocks the main thread by design (localStorage and sessionStorage).
  • `sessionStorage` has the identical API and a completely different lifetime: one store per tab, cleared when that tab closes, and *not* shared with the tab next to it even on the same origin.
  • IndexedDB is an asynchronous, transactional, versioned object store. It holds structured values rather than strings, indexes them, and is the only store here with a defined schema-migration path (IndexedDB).
  • Cache Storage holds Request/Response pairs. It is a separate layer from the browser's own HTTP cache — you decide what goes in, what comes out, and when it is deleted, which is exactly why a service worker can serve a page with no network at all (Cache Storage).
  • The stores share a per-origin quota pool that the browser manages against available disk, and they share an eviction policy that can delete data you did not agree to lose. Persistence is a request, not a guarantee (Storage Security and Durability).

What this makes the browser do

And which of it is avoidable.

  • Cookies are serialized into a header on every matching request, parsed on every response with a Set-Cookie, and matched against Domain/Path for every URL the page touches — including images and fonts.
  • Web Storage reads and writes hit a backing store synchronously from the main thread; the browser may have to touch disk while your JavaScript is holding the thread.
  • IndexedDB work happens off the main thread, but the results are structured-cloned back onto it, so a huge getAll still pays a deserialization cost where the user can feel it (Structured Clone and Transferables).
  • Cache Storage writes bodies to disk. Matching a request is a key lookup plus, optionally, Vary header comparison — cheap, but it is still I/O.
  • Under storage pressure the browser scans origins and evicts. That work is invisible to you and its outcome is not something you can appeal.

Five stores on eight axes

BROWSER-SPECIFICCapacity and eviction rows describe browser policy, not specification: quotas are derived from free disk and vary by browser, platform and whether the window is private, and Safari in particular has applied shorter lifetimes to script-written storage than Chrome or Firefox.

This table is the lesson. Read a row rather than a column: the differences between these stores are not features you can tick off, they are the properties that decide whether your data survives, how much it costs, and who can read it.

Capacity is given in orders of magnitude on purpose. Every browser computes a quota against free disk and its own policy, so an exact number would be wrong on some platform the day it was written and wrong everywhere within a year. What is stable is the ranking: cookies are kilobytes, Web Storage is megabytes, IndexedDB and Cache Storage share a much larger origin budget.

CookieslocalStoragesessionStorageIndexedDBCache Storage
LifetimeExpires/Max-Age, or the sessionUntil deleted or evictedUntil the tab closesUntil deleted or evictedUntil deleted or evicted
ScopeDomain + path, shared across tabsOrigin, shared across tabsOrigin and tabOrigin, shared across tabs and workersOrigin, shared with the service worker
CapacityA few KB per cookie, tens per domainSingle-digit MB per originSingle-digit MB per originHundreds of MB to GB, quota-negotiatedSame origin quota pool as IndexedDB
Synchronous?Sync via document.cookie; async API existsSynchronous — blocks the main threadSynchronous — blocks the main threadAsynchronous, event- or promise-basedAsynchronous, promise-based
Sent to the server?Yes, automatically, on matching requestsNoNoNoNo
Structured data?No — one string, and a small oneNo — strings, so JSON in and outNo — strings, so JSON in and outYes — structured clone, plus indexesYes — whole Request/Response pairs
Typical useSession identity, server-visible flagsSmall preferences read at startupPer-tab, throwaway UI stateOffline data, large caches, queuesApp shell and assets for offline
How it failsWeight on every request; CSRF exposureThrows at quota; blocks; last-write-winsSilently absent in the next tabBlocked upgrades; migration bugsStale entries served forever

Choosing, in the order the questions actually arrive

The decision is rarely "which API is nicest". It is a sequence of constraints, and usually one of them is binding: the server needs it on the request, or it is too big for Web Storage, or it must not survive the tab, or it must be readable from a worker.

Note that "none of them" is a real option and often the right one. State that belongs in the URL should be in the URL, where it is shareable, bookmarkable and restored by the back button for free; state that belongs to the server should be fetched, not mirrored (The URL Is Application State).

Where should this live?

What is the binding constraint on this piece of data?

The server needs it on every matching request

when Session identity, a locale the server renders with, a routing hint the edge reads. Small, and needed before your JavaScript runs.

cost Bytes on every matching request including subresources, plus automatic transmission on cross-site requests unless SameSite prevents it (Cross-Site Request Forgery).

A small preference, read once at startup

when Theme, reduced-motion override, sidebar collapsed, last-used unit. Kilobytes, not megabytes.

cost A synchronous read on the main thread and a write that can throw at quota. Acceptable at this size, not at any other.

Throwaway state that must not outlive the tab

when A multi-step wizard, a scroll position, the page you were on before a redirect. Two tabs should not share it.

cost Same synchronous API and the same quota, plus a lifetime that surprises anyone expecting it to be shared.

Large, structured, queried, or written off the main thread

when Offline records, a mutation queue, a document cache, anything indexed or measured in megabytes.

cost An asynchronous, versioned API you now own, including an upgrade path and a blocked-upgrade case (The Offline Mutation Queue).

HTTP responses you want to serve without the network

when The app shell, fonts, images, API responses a service worker should answer from disk.

cost A service worker to install and update, plus a cache-invalidation problem that is now yours (Intercepting Fetch).

None of the above — do not store it

when It is derivable, it belongs in the URL, or it is server state that would go stale the moment you copied it.

cost A fetch, or a slightly longer URL. Usually the cheapest option in the list (Derived State).

The failures each choice buys you

Every store fails, and the useful skill is knowing in advance which failure you have signed up for so the code handles it rather than discovering it in a support ticket. The rows below are the ones that recur across real applications.

Notice how many of them present as something other than a storage problem: a slow interaction, a user who "keeps getting logged out", a page that will not update after a deploy. That distance between cause and symptom is what makes this module worth reading before you need it.

Storage failures and what they look like from the outside
TriggerSymptomCauseResponse
Origin quota reachedA write throws; the draft or preference silently does not persistWeb Storage and IndexedDB reject writes past quota rather than evicting to make roomWrap every write, surface the failure to the user, and prune your own data rather than hoping (localStorage and sessionStorage).
Browser evicts under disk pressureA returning user is treated as brand new; offline data is goneStorage is best-effort; the browser reclaims space from origins by its own policyRequest persistence where it matters, and design the empty state to recover rather than to reset (Storage Security and Durability).
Two tabs write the same keyOne tab shows stale data indefinitely; a change appears to have been undoneNo coordination and no ordering between tabs on the same originSubscribe to the storage event or a broadcast channel and reconcile explicitly (State Synchronization).
Deploy changes the stored shapeOld tabs throw on read; new code crashes on old dataThe store outlives the code that wrote it and carries no versionVersion the payload, validate on read, and migrate or discard deliberately (Long-Lived Clients and Version Skew).
A large value read in a handlerInput feels laggy; the interaction misses framesA synchronous read plus JSON.parse on the thread that owns renderingRead once at startup, keep it in memory, or move the data to IndexedDB (Long Tasks).
A cookie added for one pageEvery request to the origin grows, including images and fontsCookies match on Domain and Path, not on which page needed themScope the Path, or use a store the browser does not transmit (Cookies).

How to build it

Most important first.

  • Start from the six questions, not from the API you already know: how long must this live, who may see it, how big will it get, does the server need it on a request, can I afford to block the main thread reading it, and what happens if it is gone?
  • If the server needs it on every request and it is small, it is a cookie. Anything else in a cookie is weight added to every matching request forever (Cookies).
  • If it is a small preference the app reads once at startup, localStorage is genuinely the right tool — that is the workload it is good at (localStorage and sessionStorage).
  • If it is tab-scoped — a wizard step, a scroll position for this tab, the redirect you are returning from — sessionStorage gives you the lifetime for free instead of you writing cleanup code you will forget (The URL Is Application State).
  • If it is large, structured, queried, or written from a worker, it is IndexedDB. Wrap it, because the raw API is unpleasant, but do not avoid it for that reason (IndexedDB).
  • If it is HTTP responses you want to serve offline or instantly, it is Cache Storage behind a service worker, not a hand-rolled blob cache in IndexedDB (Caching Strategies).
  • Whatever you choose, treat the data as untrusted on read: it was written by an older version of your code, possibly in another tab, possibly by someone with devtools open (Long-Lived Clients and Version Skew).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Storing user preferences is a genuine accessibility use of this machinery. Reduced motion, high contrast, theme and text-size choices should survive a reload; making someone re-set them every visit is a real cost paid by the people least able to afford it (Contrast, Colour and Motion).
  • Prefer the platform signal first and storage second: read prefers-reduced-motion and prefers-color-scheme, and let stored state be an explicit override rather than the source of truth. A stored preference that contradicts the operating system setting is a bug you cannot see.
  • Reading a large value synchronously during an interaction blocks the main thread, and the accessibility tree is computed on that same thread. Focus moves late and announcements queue, with no visual cue that the page is busy (The Accessibility Tree).
  • Losing persisted state silently is worse for users who rely on it to resume — someone using switch access or voice control may have spent several minutes producing the input a mouse user would have retyped in ten seconds.

What can go wrong

Failure modes
  • The quota is reached and the write throws. Code that never wrapped the write loses the data silently and the user finds out at the worst moment (localStorage and sessionStorage).
  • The store is evicted under disk pressure and the app treats "empty" as "new user", wiping preferences and drafts that were never backed up anywhere.
  • Two tabs disagree. The last writer wins, the other tab keeps rendering stale state, and the bug only reproduces when someone has two tabs open (Auth Across Tabs).
  • The schema changes and old data fails to parse. Without a version marker every read is a guess about which shape you are looking at (IndexedDB).
  • A cookie set for convenience becomes a header on every request to the origin, including static assets, and the cost is spread so thinly nobody attributes it (Reading a Network Waterfall).
  • The mitigation fails too: a try/catch around a write that swallows the error turns a loud failure into a silent one, which is worse.
What can arrive out of order
  • Two tabs writing the same key: both reads see the old value, both writes succeed, and the second one wins. Nothing in Web Storage prevents it or reports it (State Synchronization).
  • A service worker updating Cache Storage while a page is reading from it — the page may serve the previous version of an asset for the rest of its life (The Service Worker Lifecycle).
  • An IndexedDB upgrade blocked by another tab holding an open connection to the old version: the upgrade waits, and the new tab hangs until the old one yields (IndexedDB).
Security
  • Every one of these stores except an HttpOnly cookie is readable by any script executing on the origin. That is the property to design around: a single successful injection reads all of it (Cross-Site Scripting).
  • The browser partitions storage by origin, and increasingly by top-level site as well, so a third-party frame does not necessarily see the store it would have seen a few years ago (Storage Security and Durability).
  • Cookies are the only store the browser transmits for you, which is why they are convenient for sessions and simultaneously the mechanism behind cross-site request forgery (Cross-Site Request Forgery).
  • None of these stores are encrypted at rest in any way that protects against someone with the device. "The user's own machine" is not a trust boundary you control (The Browser Security Model).
Misreads
  • "They are all just key-value stores." They differ on lifetime, scope, transmission, synchronicity and capacity — five axes on which they are not substitutable at all.
  • "sessionStorage is per-session, so it is shared across my tabs." It is per-tab. Two tabs on the same origin have two separate sessionStorage stores, and duplicating a tab copies the store rather than sharing it.
  • "IndexedDB is slow." It is asynchronous, which is a different thing. The synchronous store is the one that makes your page stutter.
  • "Cache Storage is the HTTP cache." It is a separate, script-controlled store with entirely different rules; the HTTP cache belongs to the browser (Browser HTTP Caching).
  • "Storage is permanent." It is best-effort. Eviction, private windows, clearing site data and profile resets are all normal (Storage Security and Durability).
  • "The quota is N megabytes." Quotas are computed by the browser against free disk and change with policy, browser and platform; write the code that handles the failure rather than the number.

Measuring it, and what changes in the field

How you would see this
  • The Application panel lists every store for the origin — cookies with their attributes, Web Storage keys, IndexedDB databases with their version, and Cache Storage buckets with their entries (A Mental Model of the Devtools).
  • A storage-estimate API reports usage and quota for the origin, which is the only honest way to know how close you are; it reports a browser policy, not a constant, so read it rather than assume it.
  • The Performance panel shows synchronous storage access as main-thread time inside your handler — that is how you catch a localStorage read that grew (Interaction Responsiveness).
  • The Network panel shows the request-header size, which is where an over-enthusiastic cookie becomes visible (Reading the Browser Waterfall in Observability & Performance).
  • In the field, count storage errors and quota failures like any other error class; they are invisible locally because your disk is not full (Frontend Error Tracking).
Slow device, slow network, large data, old tab
  • On a low-end device with little free disk, quotas shrink and eviction is aggressive. An app that assumes its offline cache is still there is assuming a property of your laptop.
  • In a private or incognito window, storage is typically ephemeral and quotas are much smaller; some stores may throw on first write. That is a supported configuration, not an edge case.
  • On a slow network, the value of Cache Storage rises sharply and the cost of a fat cookie rises with it — bytes on the request path are bytes before the response can start (The Critical Rendering Path).
  • In a long-lived tab, stores drift: one tab holds state written before a deploy, another after (Long-Lived Clients and Version Skew).
  • With a large dataset, only IndexedDB and Cache Storage are candidates; the others are not slow at that size, they are unusable.
What this costs
  • Choosing the right store usually means more code than reaching for localStorage: an async API, an upgrade path, error handling for quota. That cost is real and it is paid up front, while the cost of the wrong choice is paid in production by users you never hear from.
  • IndexedDB's capability comes with a schema you now own and must migrate. Ignoring versioning is how a client-side store becomes a data-loss incident.
  • Cookies buy automatic transmission and pay for it on every matching request, forever, including requests that have no use for them.
  • Storing preferences improves the returning experience and adds a synchronisation problem the moment the same user has two devices (Server State Is Not Your State).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe five stores, their APIs and their lifetime and scope semantics come from the specifications and behave the same way across Blink, Gecko and WebKit; what follows about quotas and eviction does not.
  • BROWSER-SPECIFICQuota size and eviction policy are implementation choices computed against available disk and user settings, so Chrome, Firefox and Safari can all give the same origin a different budget on the same machine, and each changes that policy between releases.
  • SPEC-EVOLVINGStorage partitioning by top-level site, the lifetime of third-party state and the future of third-party cookies are actively moving; treat any statement about what a cross-site frame can see as a snapshot and verify against current browser documentation.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — two tabs, an offline queue and a server are three replicas of the same data with no coordination protocol between them; the reconciliation rules belong to that domain.