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.
Where should this piece of data live in the browser, and what am I signing up for when I put it there?
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.
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.
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 is synchronous. A
localStorage.getIteminside 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.parseon 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,
localStoragewas the wrong shape and a cookie was the right one (Cookies).
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
DomainandPath. They are tiny, they are readable by script unless markedHttpOnly, 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/Responsepairs. 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 againstDomain/Pathfor 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
getAllstill 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,
Varyheader 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
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.
| Cookies | localStorage | sessionStorage | IndexedDB | Cache Storage | |
|---|---|---|---|---|---|
| Lifetime | Expires/Max-Age, or the session | Until deleted or evicted | Until the tab closes | Until deleted or evicted | Until deleted or evicted |
| Scope | Domain + path, shared across tabs | Origin, shared across tabs | Origin and tab | Origin, shared across tabs and workers | Origin, shared with the service worker |
| Capacity | A few KB per cookie, tens per domain | Single-digit MB per origin | Single-digit MB per origin | Hundreds of MB to GB, quota-negotiated | Same origin quota pool as IndexedDB |
| Synchronous? | Sync via document.cookie; async API exists | Synchronous — blocks the main thread | Synchronous — blocks the main thread | Asynchronous, event- or promise-based | Asynchronous, promise-based |
| Sent to the server? | Yes, automatically, on matching requests | No | No | No | No |
| Structured data? | No — one string, and a small one | No — strings, so JSON in and out | No — strings, so JSON in and out | Yes — structured clone, plus indexes | Yes — whole Request/Response pairs |
| Typical use | Session identity, server-visible flags | Small preferences read at startup | Per-tab, throwaway UI state | Offline data, large caches, queues | App shell and assets for offline |
| How it fails | Weight on every request; CSRF exposure | Throws at quota; blocks; last-write-wins | Silently absent in the next tab | Blocked upgrades; migration bugs | Stale 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).
What is the binding constraint on this piece of data?
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).
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.
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.
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).
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).
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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Origin quota reached | A write throws; the draft or preference silently does not persist | Web Storage and IndexedDB reject writes past quota rather than evicting to make room | Wrap every write, surface the failure to the user, and prune your own data rather than hoping (localStorage and sessionStorage). |
| Browser evicts under disk pressure | A returning user is treated as brand new; offline data is gone | Storage is best-effort; the browser reclaims space from origins by its own policy | Request persistence where it matters, and design the empty state to recover rather than to reset (Storage Security and Durability). |
| Two tabs write the same key | One tab shows stale data indefinitely; a change appears to have been undone | No coordination and no ordering between tabs on the same origin | Subscribe to the storage event or a broadcast channel and reconcile explicitly (State Synchronization). |
| Deploy changes the stored shape | Old tabs throw on read; new code crashes on old data | The store outlives the code that wrote it and carries no version | Version the payload, validate on read, and migrate or discard deliberately (Long-Lived Clients and Version Skew). |
| A large value read in a handler | Input feels laggy; the interaction misses frames | A synchronous read plus JSON.parse on the thread that owns rendering | Read once at startup, keep it in memory, or move the data to IndexedDB (Long Tasks). |
| A cookie added for one page | Every request to the origin grows, including images and fonts | Cookies match on Domain and Path, not on which page needed them | Scope 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,
localStorageis 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 —
sessionStoragegives 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-motionandprefers-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
- 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/catcharound a write that swallows the error turns a loud failure into a silent one, which is worse.
- 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).
- Every one of these stores except an
HttpOnlycookie 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).
- "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.
- "
sessionStorageis per-session, so it is shared across my tabs." It is per-tab. Two tabs on the same origin have two separatesessionStoragestores, 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
- 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
localStorageread 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).
- 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.
- 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.
- — 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.