cf.completefrontend
My progress
FUNDAMENTALS · MODULE 9

Client storage options

The six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.

Why the choice of storage matters

Picture a note-taking app. In version one, the team keeps everything in localStorage: the user's settings, the auth token, and every note as one big JSON string under the key notes. It works on day one. It is simple, it is synchronous, and it survives a reload.

Six months later, the support inbox fills up. Power users with 3,000 notes see the app freeze for 200 ms every time they type, because each save turns the whole notes array into a 4 MB string and writes it on the main thread. A few users lose everything: their notes grew past about 5 MB and the write threw a QuotaExceededError that nobody caught.

iPhone users who open the app once a fortnight from Safari find it empty. And a security review flags that a single XSS (cross-site scripting) bug would let an attacker read the auth token straight out of localStorage.

None of these bugs is in the note-taking logic. Every one of them comes from putting data in a store that was never meant for it. The browser gives you six different places to keep data, and they differ in size, speed, lifetime, and who can read them. Picking well is a design decision, and interviewers ask about it in almost every offline, editor, or dashboard question.

The six stores at a glance

Here is the cast. Each gets its own chapter later; for now, learn one sentence about each.

  • Cookies. Tiny strings (about 4 KB each) that the browser attaches to every matching HTTP request. Made for the server, not for the page.
  • localStorage. A small key-value store of strings (about 5 MB per origin), synchronous, kept until someone clears it.
  • sessionStorage. The same API as localStorage, but scoped to one tab and cleared when that tab closes.
  • IndexedDB. A real database in the browser: asynchronous, stores structured objects and binary blobs, indexes and transactions, hundreds of MB or more.
  • Cache API. A store of HTTP Request and Response pairs, usually driven by a service worker to serve pages and files offline.
  • OPFS (origin private file system). A private, fast file system per origin, with synchronous byte-level access inside web workers. Used for SQLite in the browser, editors, and media tools.
A bar chart on a log scale of how much each browser store can hold. A cookie: 4 KB each, sent with every request. sessionStorage: about 5 MB, one tab, synchronous, strings only. localStorage: about 5 MB, all tabs, synchronous, strings only. IndexedDB, the Cache API, and OPFS share one quota, which in Chrome on a 256 GB disk can reach about 150 GB.how much fits, log scale (each step is 10 times more)Cookie: sent with every request4 KB eachsessionStorage: one tab, sync, stringsabout 5 MBlocalStorage: all tabs, sync, stringsabout 5 MBIndexedDB + Cache API + OPFS: one shared quota~150 GB
Each step on this scale is ten times more. The gap between Web Storage and the shared quota is more than four steps: tens of thousands of times more room, and asynchronous.

Four questions decide the store

Every storage decision in this lesson comes back to the same four questions. Ask them about each kind of data, and the right store usually picks itself.

  1. Who needs to read it? Only the server (a session id), only the page, or a service worker too? Cookies are the only store the server sees automatically.
  2. How big can it get? A theme name is 10 bytes; a user's photo library is gigabytes. Web Storage tops out around 5 MB; IndexedDB, the Cache API, and OPFS share a much larger quota.
  3. How often and from where is it read or written? Synchronous APIs (localStorage, sessionStorage) block the main thread; asynchronous ones (IndexedDB, Cache API) don't. Workers can't use Web Storage at all.
  4. How long must it live, and what happens if it vanishes? Some data is a cache you can refetch; some is the only copy of the user's unsynced work. The browser can evict storage, and Safari deletes script-written storage for a site the user hasn't interacted with in 7 days of browsing.

Check your understanding · Exercise 1

In the note-taking story, which problems come from the choice of localStorage itself? Select all that apply.

The mental model: one locker per site, with shelves

Think of a gym with rows of lockers. Every website gets its own locker, and no site can open another site's locker. Inside each locker there are shelves of different shapes: a tiny pocket for notes you hand to the front desk (cookies), a small drawer of labelled index cards (Web Storage), a filing cabinet (IndexedDB), a box of photocopies of web pages (Cache API), and a private hard drive (OPFS).

Most of the locker shares one size limit, called the quota. If the gym runs out of space, the manager may empty whole lockers, starting with the ones nobody has opened for a while. You can ask the manager to mark your locker "do not empty", and sometimes the manager says yes. That is the whole model; the rest of the lesson fills in the numbers.

The locker number is the origin

The origin is the scheme, host, and port of a URL: https://app.example.com:443. Storage APIs are keyed by origin, so https://app.example.com and https://admin.example.com have separate lockers even though they share a domain. So do http:// and https:// versions of the same host. Cookies are the odd one out: they are keyed by domain and path, can be shared across subdomains with the Domain attribute, and by default are not split by port or scheme.

Modern browsers add a second part to the locker number: the top-level site the user sees in the address bar. A YouTube embed on news.example and the same embed on blog.example get two different lockers. This is storage partitioning, and chapter 7 shows what it breaks and why it exists.

A stack diagram of one origin's storage, https://app.example.com. Inside it, a shared quota box holds IndexedDB, the Cache API, OPFS, and service worker scripts; these are evicted together. Below it, localStorage with its own limit of about 5 MB and sessionStorage, one copy per tab. Beside the origin, outside it, is the browser's cookie jar, keyed by domain, which sends cookies to the server.Origin: https://app.example.comShared quota, evicted togetherIndexedDBCache APIOPFSSW scriptslocalStorageown ~5 MBsessionStorageone per tabCookie jarCookiesby domain4 KB eachsent to server
The four big stores share one quota and one fate: when the browser evicts the origin, they go together. Web Storage has its own small fixed limit. Cookies live in a separate jar, keyed by domain rather than origin.

Synchronous versus asynchronous

The second idea to hold on to is how each API talks to the disk. A synchronous API returns the answer directly: localStorage.getItem("theme") gives you the string on the same line. While it works, nothing else on that thread runs: no clicks, no animation frames, no rendering. On the main thread that means a frozen page.

An asynchronous API hands back a promise or fires an event later. The disk work happens on a browser background thread, and the main thread keeps running. IndexedDB, the Cache API, the async OPFS methods, and cookieStore are asynchronous. The one big exception is OPFS's sync access handle, which is synchronous on purpose but only allowed inside a dedicated web worker, where blocking hurts nobody.

Who can use each store, and whether it blocks. Web Storage is missing from workers and service workers entirely.
StoreSync or asyncMain threadWorkersService worker
Cookies (document.cookie)SyncYesNoNo (cookieStore in current browsers)
localStorageSyncYesNoNo
sessionStorageSyncYesNoNo
IndexedDBAsyncYesYesYes
Cache APIAsyncYesYesYes
OPFSAsync, plus sync handles in dedicated workersYes (async)YesYes (async only)

The worker column matters more than it looks. A service worker that handles a push message or a background sync cannot touch localStorage. If the offline outbox lives there, the service worker cannot send it. That alone pushes most offline designs to IndexedDB.

What shares the quota and what doesn't

IndexedDB, the Cache API, OPFS, and service worker registrations all draw from one shared quota per origin, and they are evicted together. Web Storage has its own small fixed limit of about 5 MB that does not grow with the disk. Cookies have per-cookie and per-domain limits and live in the browser's cookie jar, which has its own rules.

Check your understanding · Exercise 2

A user opens https://shop.example.com and https://help.example.com. Which statements are true? Select all that apply.

Cookies: small, sent everywhere, and the only store the server sees

A cookie is a name and a value, plus a few rules, that the browser stores and then attaches to every HTTP request that matches those rules. The server usually sets it with a Set-Cookie response header. On every later request to that site, the browser adds a Cookie request header with all matching cookies, without any JavaScript.

That automatic sending is the whole point of cookies, and also their cost. They are the right place for the one thing the server must see on every request: the session identifier. They are the wrong place for anything the page only needs for itself.

A sequence diagram between page JavaScript, the browser's cookie jar, and the server. The page posts to /login. The server answers with Set-Cookie sid, HttpOnly, which the jar stores. Later the page fetches /api/inbox and the jar adds the header Cookie: sid=8f3a on its own. The server answers 200 with the inbox. When the page reads document.cookie, the jar returns an empty string, because the HttpOnly cookie is hidden from scripts.Page JSCookie jarServerPOST /loginSet-Cookie: sid, HttpOnlyfetch /api/inbox+ Cookie: sid=8f3a200 inboxread document.cookieempty: sid is hidden
The browser attaches the cookie to every matching request with no code at all. HttpOnly keeps it out of document.cookie, so script can use the session but never copy it.
http
Set-Cookie: __Host-sid=8f3a91c2e7; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1209600
A well-configured session cookie: two weeks long, HTTPS only, hidden from scripts, not sent on most cross-site requests.
  • HttpOnly hides the cookie from document.cookie and every other script API. The browser still sends it on requests. This is the main defence for session cookies: an XSS bug can make requests as the user while the page is open, but it cannot copy the session id and use it from elsewhere.
  • Secure sends the cookie only over HTTPS. Without it, one request over plain HTTP on coffee-shop Wi-Fi leaks the session.
  • SameSite controls cross-site sending. Strict never sends it on requests that start from another site; Lax (the default in Chromium when the attribute is missing) sends it on top-level navigations like clicking a link, but not on cross-site fetch, forms that POST, or iframes; None always sends it and requires Secure. It is a strong defence against cross-site request forgery (CSRF); see the csrf lesson.
  • Max-Age or Expires make the cookie persistent. Without either, it is a session cookie that the browser drops when it decides the session ended, which can be days later because browsers restore sessions. Chromium caps any lifetime at 400 days.
  • Domain and Path widen or narrow where the cookie goes. Leave Domain off unless you mean to share with every subdomain, because a cookie for example.com is also sent to blog.example.com, which may be run by a different team or vendor.
  • __Host- prefix. A cookie named __Host-... is only accepted if it is Secure, has Path=/, and has no Domain. It locks the cookie to exactly one host, so a subdomain can't overwrite it.

Browsers allow about 4 KB per cookie (4,096 bytes for the name and value together) and a limit per domain, 180 cookies in Chromium. The tighter limit is often the server's: many servers and CDNs reject request headers larger than 8 to 16 KB with a 431 Request Header Fields Too Large or a plain 400. A site that keeps adding analytics and A/B test cookies eventually locks some users out entirely, and only those users, which makes it hard to debug.

The bigger cost is bandwidth. Cookies ride on every request to the domain: the HTML, every API call, and every image on the same host. A page with 2 KB of cookies that makes 80 requests to its own domain sends 160 KB of cookie headers upstream.

HTTP/2 and HTTP/3 compress repeated headers (HPACK and QPACK), which helps a lot after the first request. But the first requests on each new connection still pay in full, and cookies too big for the compression table (4 KB by default in HPACK) get little benefit. Upload bandwidth on mobile is the narrow side.

Cookies on the domain:
Requests to that domain per page:
Cookie bytes sent upstream for one page view with 2.0 KB of cookies and 80 requests. Over HTTP/1.1, 160.0 KB. Over HTTP/2 or HTTP/3 with header compression on one connection, about 2.6 KB.HTTP/1.1: full cookie on every request160.0 KBHTTP/2 or 3: compressed after the first2.6 KB
Bars to scale. Compression helps a lot on a warm connection when the cookies fit in its table, but every new connection, and every HTTP/1.1 request, pays the full cookie again.

160.0 KB of cookie headers over HTTP/1.1 is about 1280 ms of upload on a 1 Mbps mobile uplink, spread across the page's requests. Keep cookies for what the server needs; serve static files from a cookieless domain.

This is why big sites serve static files from a separate cookieless domain, such as an image CDN host, and why you should never put a feature flag blob or a cart into a cookie the page only reads itself.

Cookies from JavaScript

document.cookie is an old, awkward API. Reading it returns one string with every non-HttpOnly cookie joined by ; , which you have to parse, and writing to it synchronously sets exactly one cookie. The newer Cookie Store API (cookieStore.get, cookieStore.set, and a change event) is promise-based and works in service workers. It shipped in Chrome and Edge 87, Safari 18.4, and Firefox 140, so feature-detect it and keep a document.cookie fallback for older browsers.

Safari treats script-written cookies with suspicion. Under its Intelligent Tracking Prevention (ITP), a cookie set through document.cookie has its lifetime capped at 7 days, however long you ask for. Cookies set by your own server with Set-Cookie are not capped this way. If a login must last longer than a week in Safari, the server has to set it.

Check your understanding · Exercise 3

localStorage and sessionStorage: simple, small, and synchronous

Web Storage is the name for two objects with the same API: localStorage and sessionStorage. Both store string keys mapped to string values, per origin. The API is four methods and a length, which is why everyone reaches for it first.

ts
localStorage.setItem("theme", "dark");
localStorage.getItem("theme");      // "dark"
localStorage.getItem("missing");    // null
localStorage.removeItem("theme");

// Only strings: objects must be serialised by hand
localStorage.setItem("prefs", JSON.stringify({ density: "compact" }));
const prefs = JSON.parse(localStorage.getItem("prefs") ?? "{}");

// A number goes in and a string comes out
localStorage.setItem("count", String(3));
typeof localStorage.getItem("count"); // "string"
The whole API. Every value is a string; JSON is your job.

How the two differ

  • localStorage is shared by every tab and window of the origin and lasts until the site, the user, or the browser deletes it. It survives reloads and restarts.
  • sessionStorage belongs to one tab (strictly, one top-level browsing context) and one origin. It survives reloads and back and forward navigation in that tab, and is deleted when the tab closes. Two tabs of the same site have two separate copies. Duplicating a tab copies the data into the new tab, and from then on the two copies drift apart.

sessionStorage fits state that should belong to one tab's journey: a half-filled multi-step form, a scroll position to restore, or a wizard step, so that two tabs of the same checkout don't overwrite each other. It is not a security boundary; any script on the page reads it just as easily.

Two tabs of the same origin, both on /checkout. Tab A's sessionStorage holds step=3; Tab B's sessionStorage holds step=1, a separate copy. Both tabs point down to one shared localStorage holding theme=dark and prefs:v3. When Tab A changes localStorage, Tab B receives a storage event.Tab A: /checkoutsessionStoragestep=3Tab B: /checkoutsessionStoragestep=1storage eventlocalStorage (shared)theme=dark, prefs:v3duplicating a tab copies its sessionStorage once
sessionStorage is one copy per tab, so two checkouts don't clash. localStorage is one copy for the whole origin, and other tabs hear about changes through the storage event.

The storage event: free cross-tab messages

When one tab changes localStorage, every other same-origin tab gets a storage event with the key, the old value, and the new value. The tab that made the change does not. Apps use this to log out every tab at once, or to sync a theme switch. For anything richer, BroadcastChannel is the cleaner tool; the cross-tab-sync lesson compares them.

The main-thread cost

Web Storage is synchronous. Browsers usually load an origin's whole localStorage into memory the first time a page touches it, which can be a blocking disk read during startup, and then write changes back in the background. Reads after that are cheap. The expensive part is your own code: to store an object you must JSON.stringify all of it, and to read it you must JSON.parse all of it, on the main thread, every time.

For a 200-byte settings object this costs nothing. For a 4 MB array of notes, stringify and parse can each take tens of milliseconds on a fast laptop and well over 100 ms on a mid-range phone. If you save on every keystroke, every keystroke now risks a long task (any task over 50 ms) and a poor Interaction to Next Paint (INP) score.

Two timelines from 0 to 300 milliseconds. Top, localStorage: a keystroke handler runs for 8 ms, then JSON.stringify of 4 MB of notes takes 90 ms and setItem 40 ms on the main thread. A second key pressed at 40 ms has to wait until 138 ms, about 100 ms late. Bottom, IndexedDB: the handler runs for 8 ms and put() of one note takes 3 ms on the main thread; the write itself happens on a browser thread over about 30 ms. The second key at 40 ms runs on time.localStorage: stringify and save the whole arraystringify 90 mskey 2 runs 98 ms latekey 2 pressedIndexedDB: put() one notekey 2 runs on timebrowser storage threadthe write happens here0 ms100 ms200 ms300 ms
The cost of Web Storage is the synchronous work around it: stringifying everything, every time. Writing one record with IndexedDB moves the disk work off the main thread, so input stays responsive.

Limits and errors

  • Size. About 5 MB per origin in every major browser, with a separate 5 MB for sessionStorage. Chromium counts UTF-16 code units, so about 5 million characters, and keys count too. It does not grow with the disk and is not part of the larger shared quota.
  • Errors. When full, setItem throws a DOMException named QuotaExceededError. Some browsers and settings also throw a SecurityError just from touching window.localStorage, for example when the user blocks site data. Wrap access in try and have a fallback.
  • Strings only. No Date, Map, Blob, or binary data, unless you encode it, and base64 inflates binary data by a third.
  • No workers. Web workers and service workers can't use it at all.
ts
export function saveSetting(key: string, value: unknown): boolean {
  try {
    localStorage.setItem(key, JSON.stringify(value));
    return true;
  } catch (err) {
    // QuotaExceededError, or SecurityError when storage is blocked
    console.warn("Could not save setting", key, err);
    return false;
  }
}
A safe wrapper. The caller decides what to do when saving fails; the app keeps working from memory.

Check your understanding · Exercise 4

Which data fits sessionStorage better than localStorage? Select all that apply.

The big stores: IndexedDB, the Cache API, and OPFS

Past a few megabytes, three stores take over. All three are asynchronous, all three work in workers and service workers, and all three share the origin's large quota. They differ in what they store: objects, HTTP responses, or raw files.

IndexedDB: a database of objects

IndexedDB stores JavaScript values in named object stores, a bit like tables. Values are copied with the structured clone algorithm, so you can store objects, arrays, Date, Map, Set, Blob, File, and ArrayBuffer directly, with no JSON step. Functions and DOM nodes can't be stored.

Each store has a primary key and can have indexes on other fields, so you can ask "all notes in folder 7, sorted by updatedAt" without loading every note. Every read and write happens inside a transaction, which either commits completely or not at all. Schema changes happen in a versioned upgrade step. That is enough to build a real offline data layer: a local copy of the user's documents, a queue of unsent changes, and a sync cursor.

ts
import { openDB } from "idb"; // a tiny promise wrapper

const db = await openDB("notes-app", 2, {
  upgrade(db) {
    const notes = db.createObjectStore("notes", { keyPath: "id" });
    notes.createIndex("byFolder", ["folderId", "updatedAt"]);
  },
});

await db.put("notes", { id: "n42", folderId: 7, updatedAt: Date.now(), body: "..." });
const recent = await db.getAllFromIndex("notes", "byFolder",
  IDBKeyRange.bound([7, 0], [7, Infinity]));
Saving one note writes one record, not the whole list. The index answers the folder query without a scan.

The raw API is event-based and verbose, so most teams use a thin wrapper such as idb or a library such as Dexie. The indexeddb lesson goes deep on transactions, versions, and the traps; here, remember the shape and when to choose it.

The Cache API: a store of HTTP responses

The Cache API (caches) stores pairs of Request and Response objects in named caches. You put a response in, and later look it up by URL. It was built for service workers, which intercept a page's requests and can answer from these caches when the network is slow or gone. Pages can use it too.

ts
const cache = await caches.open("app-shell-v14");
await cache.addAll(["/", "/app.3f9a.js", "/app.81c2.css"]);

const hit = await cache.match("/app.3f9a.js"); // a Response, or undefined

// Old versions are deleted by name when a new worker activates
for (const name of await caches.keys()) {
  if (name !== "app-shell-v14") await caches.delete(name);
}
Cache API entries are whole responses, with headers and status. Nothing expires on its own: your code decides.

Two things surprise people. First, the Cache API ignores Cache-Control: an entry stays until your code deletes it or the origin is evicted. It is separate from the browser's HTTP cache, which follows headers and which the browser can trim at will.

Second, the Cache API is the wrong home for app data: a response is opaque bytes plus headers, with no indexes or partial updates. Store files and API responses you want to replay; store records in IndexedDB. The service-worker-caching lesson covers the strategies.

OPFS: a fast private file system

The origin private file system (OPFS) gives each origin a folder of real files that only it can see. The user won't find these files in their Downloads folder, and no permission prompt appears. You get a root directory with navigator.storage.getDirectory(), then create folders and files inside it.

Its special feature is the sync access handle. Inside a dedicated web worker, fileHandle.createSyncAccessHandle() gives you synchronous read, write, truncate, getSize, and flush at byte offsets. There are no promises in the hot path, so it is far faster for many small reads and writes than IndexedDB. This is what makes SQLite compiled to WebAssembly (WASM) practical in the browser: SQLite expects a file it can read and write at offsets, and OPFS provides one.

ts
// worker.ts: a dedicated worker, never the main thread
const root = await navigator.storage.getDirectory();
const file = await root.getFileHandle("project.db", { create: true });
const handle = await file.createSyncAccessHandle(); // takes an exclusive lock

const page = new Uint8Array(4096);
handle.read(page, { at: 8192 });   // synchronous, returns bytes read
handle.write(page, { at: 12288 });
handle.flush();
handle.close(); // release the lock so another worker or tab can open it
Byte-level, synchronous file access in a worker. A sync handle locks the file, so only one context can hold it at a time.
A comparison matrix of IndexedDB, the Cache API, and OPFS. What they hold: objects and Blobs; HTTP responses; files of bytes. Look up by: key or index; URL; path and byte offset. API: async; async; async, plus synchronous access in a worker. Transactions: yes; no; file locks instead. Best for: app data; the offline app shell; SQLite databases and large files.IndexedDBCache APIOPFSholdsobjects, BlobsHTTP responsesfiles of byteslook up bykey or indexURLpath, offsetAPIasyncasyncsync in workertransactionsyesnofile locksbest forapp dataoffline shellSQLite, files
All three are asynchronous and share the origin's quota. Pick by the shape of the data: records you query, responses you replay by URL, or files you read and write by byte offset.

Decision Where should a note-taking app keep 50 MB of notes for offline use?

localStorage with one JSON blob

Stringify the whole array on every save

Parse it all on startup

Pros

  • Simplest code
  • Synchronous reads, no async plumbing

Cons

  • Hard limit near 5 MB: it can't even hold the data
  • Every save blocks the main thread
  • Unavailable to the service worker

IndexedDB with an object store per entity

One record per note, indexes on folder and date

Read and write single records inside transactions

Pros

  • Async, so the page never freezes
  • Partial reads and writes, indexes, transactions
  • Works in workers and the service worker

Cons

  • Clumsy raw API (use a wrapper)
  • Complex queries such as full-text search are on you

SQLite (WebAssembly) on OPFS in a worker

Ship a WASM build of SQLite

Run it in a dedicated worker with a sync access handle

Pros

  • Full SQL, joins, full-text search
  • Very fast small reads and writes
  • Same schema as a native or server SQLite

Cons

  • Several hundred KB of WASM to download
  • All access goes through worker messages
  • Only one tab can hold the file lock, so multi-tab needs coordination

What we pick: IndexedDB is the default for structured app data. Move to SQLite on OPFS when the app needs real queries (joins, full-text search, aggregates) over a large local dataset and can afford the WASM download. localStorage is ruled out by size alone.

Check your understanding · Exercise 5

Match the data to the store. Which pairings are right? Select all that apply.

Quotas, persistence, and eviction

The big stores can hold a lot, but the browser decides how much, and it can take the space back. Every offline-capable design has to answer two questions: how much can I store, and what happens when the browser deletes it?

How much you get

Quotas are a share of the device's disk, not a fixed number. They change between browser versions, so treat the figures below as rough and always ask the browser at run time.

Rough quotas in 2025 browsers. The shared quota covers IndexedDB, the Cache API, OPFS, and service worker scripts; Web Storage has its own 5 MB.
BrowserPer-origin quota (approximate)Persistent storage
Chrome, EdgeUp to about 60% of total diskGranted silently by engagement heuristics (installed app, bookmarked, high use)
FirefoxBest-effort: 10% of disk or 10 GB for the site, whichever is smallerAsks the user with a prompt; persisted sites can use up to 50% of disk
Safari (17 and later)Up to about 60% of total disk; about 15% in other apps that embed WebKit. Safari 16 and earlier gave 1 GB, then asked the userSupports persist(), granted silently by heuristics such as running as a Home Screen web app
ts
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
console.log(`${(usage / 1e6).toFixed(1)} MB used of ${(quota / 1e6).toFixed(0)} MB`);

if (quota - usage < 200e6) {
  // Under 200 MB left: skip prefetching the next video episode
}
Ask before big writes. The numbers are estimates: browsers may round them or adjust the quota so sites can't fingerprint the device.

When a write would pass the quota, IndexedDB aborts the transaction with a QuotaExceededError, and cache.put rejects with the same error. Catch it, free space (drop old cached media first), and tell the user if their own data couldn't be saved.

Best-effort versus persistent

By default, an origin's storage is best-effort. When the device runs low on space, the browser evicts origins, usually starting with the least recently used, until it has enough room. Eviction removes an origin's whole storage at once: IndexedDB, Cache API, OPFS, and service worker registrations go together, never one store or one record at a time. Your code finds out on the next visit, when the stores are empty.

An origin can ask to become persistent with navigator.storage.persist(). If granted, the browser won't evict it under storage pressure; only the user can clear it, through settings or by clearing site data. The call returns true or false, and navigator.storage.persisted() checks the current state.

ts
async function askForPersistence(): Promise<boolean> {
  if (!navigator.storage?.persist) return false;
  if (await navigator.storage.persisted()) return true;
  return navigator.storage.persist(); // may prompt in Firefox
}

// Ask at a meaningful moment, for example after the user
// turns on "Available offline", not on first page load.
Ask when the user has shown they care about offline data. Chromium may grant silently; Firefox shows a prompt.
A state machine of an origin's storage. It starts Empty on the first visit. The first write makes it Best-effort, the default. If persist() is granted it becomes Persistent. Best-effort storage can be Evicted, all stores at once, under disk pressure with least recently used first, or by Safari after 7 days of use without interaction. Persistent storage is only removed when the user clears site data. After eviction, the next visit starts Empty again.Emptyfirst visitBest-effortthe defaultPersistentpersist() grantedEvictedall stores at oncefirst writepersist()disk pressure (LRU)Safari: 7 days idleuser clears datanext visit
Only two arrows lead to losing data from best-effort storage, and you control neither. persist() removes one of them; the user can always clear data by hand.

Safari's 7-day cap on script-writable storage

Safari adds a rule that catches many teams out. Under ITP, if a user hasn't interacted with your site (a tap, click, or key press on it) during 7 days of Safari use, Safari deletes all storage that scripts can write for that site: IndexedDB, localStorage, sessionStorage, service worker registrations, and Cache API data. Server-set cookies are not part of this rule.

The clock counts days the browser is used, not calendar days, and any interaction resets it. Web apps the user added to the Home Screen keep their own day counter, which resets each time the user opens them, so WebKit doesn't expect their data to be deleted. WebKit doesn't document whether a granted persist() exempts a site from this rule, so don't count on it.

The practical meaning: for an occasional Safari visitor, local storage is a cache that can vanish between visits. The server must hold the source of truth, and anything unsynced is at risk.

Browser:
Days of use since last interaction:
In safari, 8 days since the last interaction: Server-set cookie kept; Cookie set by JS deleted; localStorage deleted; IndexedDB deleted; Cache API deleted; OPFS at risk.keptServer-setcookiedeletedCookieset by JSdeletedlocalStoragedeletedIndexedDBdeletedCacheAPIat riskOPFS
Green with a bold outline: kept. Red: deleted. Hatched: at risk, because the behaviour varies or isn't documented. A simplified model of 2025 browsers.

Safari caps cookies written with document.cookie at 7 days, so a script-set cookie written on the last visit has expired. More than 7 days of Safari use without interaction: Safari deletes script-writable storage, including localStorage, IndexedDB, and Cache API data. Plan for OPFS to go too.

Check your understanding · Exercise 6

A user's laptop runs low on disk. Your site's storage is best-effort and hasn't been used for a month. What can happen? Select all that apply.

Partitioning, private browsing, and security

Storage is also how trackers follow people across the web. An ad iframe that loads on thousands of sites could once write an id into its own localStorage on one site and read it back on another. Browsers have closed that door, and the change affects legitimate embeds too.

Storage partitioning by top-level site

With storage partitioning, storage used by an embedded frame is keyed by two things: the frame's own origin and the top-level site in the address bar. A chat widget from widget.example embedded on shop-a.com and on shop-b.com gets two unrelated lockers. Chrome partitions third-party localStorage, sessionStorage, IndexedDB, the Cache API, OPFS, and service workers since version 115; Firefox (state partitioning, part of Total Cookie Protection) and Safari do the same in their own ways.

Three columns. On shop-a.com, a widget iframe from help.vendor.com gets store A, keyed by shop-a.com plus the vendor. On shop-b.com, the same widget gets store B, keyed by shop-b.com plus the vendor. When the user visits help.vendor.com directly, the vendor page uses store C, its unpartitioned first-party storage. The three stores can't see each other.shop-a.comtop-level sitewidget iframehelp.vendor.comstore Ashop-a + vendorshop-b.comtop-level sitewidget iframehelp.vendor.comstore Bshop-b + vendorhelp.vendor.comtop-level sitevendor pagefirst-partystore Cvendor only
Same frame origin, three different lockers. The top-level site in the address bar is part of the storage key for embedded frames, which stops an embed from tracking a user across sites.

For your own first-party app nothing changes: when a user visits app.example.com directly, it uses its normal storage. What breaks is anything that relied on one embed seeing the same state everywhere: a support widget that remembers you across customer sites, a single sign-on iframe that reads a shared token, or a payment frame that caches a device id.

Third-party cookies follow the same direction. Safari and Firefox block or partition them by default. For an embed that needs a cookie scoped to the site it's embedded in, the Partitioned attribute (known as CHIPS) opts into a per-top-level-site cookie jar.

When an embed truly needs its unpartitioned state, for example a logged-in comments widget, the Storage Access API (document.requestStorageAccess()) asks for it. The call usually needs a user gesture, and the browser may show a prompt or decide by its own rules. It covers cookies in every browser; access to other unpartitioned storage this way varies by browser.

Private browsing

In private or incognito windows, all the APIs exist and mostly work, but everything is thrown away when the private session ends. Quotas are usually much smaller, because the data is often kept in memory, and navigator.storage.estimate() may report a quota that doesn't match the real disk. Older Safari versions threw on every localStorage.setItem in private mode; current versions don't, but the lesson stands: never assume a write succeeded.

Design for it the same way you design for eviction. The app should start with empty storage, work without it, and treat "saved locally" as a speed-up rather than a promise.

What any script on the page can read

Every store except HttpOnly cookies is readable by any JavaScript running on your origin: your code, your third-party scripts, your analytics tag, and any script an attacker injects through an XSS bug. Data at rest is not encrypted from your origin's point of view, and on a shared computer anyone with the device can open DevTools and read it.

  • Tokens. Prefer an HttpOnly, Secure, SameSite session cookie. If you must hold a bearer token in JavaScript (a pure single-page app calling a third-party API), keep it in memory, keep it short-lived, and refresh it through an HttpOnly cookie. The auth-tokens lesson compares the patterns.
  • Personal data. Store the least you need offline. Clear user data on logout, from every store, including caches of API responses that contain personal details.
  • Shared devices. Offer "don't keep me signed in" and make it skip persistent storage.
  • Untrusted input. Data read back from storage is input. If a past bug or an attacker wrote HTML into it, rendering it with innerHTML creates a stored XSS. Validate and escape as you would a server response.
ts
async function clearAllLocalData(): Promise<void> {
  localStorage.clear();
  sessionStorage.clear();
  for (const name of await caches.keys()) await caches.delete(name);
  for (const db of await indexedDB.databases()) {
    if (db.name) indexedDB.deleteDatabase(db.name);
  }
  const root = await navigator.storage.getDirectory();
  for await (const name of root.keys()) await root.removeEntry(name, { recursive: true });
}
// The server can also send: Clear-Site-Data: "cache", "cookies", "storage"
Logout should clear every store. The Clear-Site-Data response header asks the browser to do it for you.

Check your understanding · Exercise 7

A support chat widget from help.vendor.com is embedded as an iframe on two customer sites. With storage partitioning, what is true? Select all that apply.

What to store where

With the six stores and their limits in hand, the choice becomes mechanical. Go through the four questions from chapter 1 for each kind of data, in order. The first answer that rules a store out usually settles it.

A flowchart of four yes or no questions. Does the server read it on every request? Yes: an HttpOnly cookie. No: is it an HTTP response to replay? Yes: the Cache API. No: is it large files or a SQL database? Yes: OPFS in a worker. No: is it tiny and needed before first paint? Yes: localStorage, or sessionStorage if it belongs to one tab. No: IndexedDB, which holds everything else.Server reads it every request?yesHttpOnly cookienoAn HTTP response to replay?yesCache APInoLarge files or a SQL database?yesOPFS in a workernoTiny, read before first paint?yeslocalStoragesessionStorage if per tabnoIndexedDBeverything else
Ask in this order. Most app data falls through every question and lands in IndexedDB, which is why it's the default store for offline-capable apps.

The decision table

Start here. The last column is the question to answer in an interview: what does the user lose when the browser deletes this?
DataStoreWhyIf it vanishes
Session idHttpOnly cookieThe server needs it on every request; script must not read itUser logs in again
CSRF token, locale for server renderingCookie (readable)The server needs it while renderingDefault locale until next set
Theme, sidebar collapsedlocalStorageTiny, read on startup before first paintDefault theme for one load
Draft in a multi-step formsessionStorageBelongs to this tab, survives reloadsUser refills the step
Feed or inbox cache for instant startIndexedDBMany records, partial updates, worker accessSlower start, refetch
Offline outbox of editsIndexedDB (persisted)Must survive restarts; the service worker sends itUser's work is lost: sync fast
App shell: HTML, JS, CSS, fontsCache APIHTTP responses replayed by the service workerOnline-only until reinstalled
Images and media for offlineCache API or IndexedDB BlobsLarge binary, keyed by URL or idRefetch when online
Large local database, editor filesOPFSFast byte-level access from a workerRebuild from server, or loss if unsynced
Access token for a third-party APIMemory onlyShort-lived; any storage widens what XSS can stealRefresh on reload

Read the last column first when you design. Rows where the answer is "slower start" are caches, and any store with room will do. Rows where the answer is "the user's work is lost" need IndexedDB or OPFS, a request for persistence, and a sync path that empties them quickly.

Patterns that use two stores together

  • Theme without a flash. Keep the theme in localStorage and read it in a tiny inline script in the head, before the first paint. An async store would resolve after the page painted in the wrong colours. Mirror it into a cookie if the server renders the HTML and should pick the right theme itself.
  • Instant start, fresh data. Render the last feed from IndexedDB immediately, then fetch from the network and update both the screen and the store: stale-while-revalidate at the app level. The cache-invalidation lesson covers when the local copy must be thrown away.
  • Files plus metadata. Store large media in the Cache API or OPFS, and their metadata (title, size, last opened) in IndexedDB, so a list can be shown and sorted without loading any file.
  • Write-ahead outbox. Write every user edit to an IndexedDB outbox first, then to the server. Remove it from the outbox when the server confirms. A crash or a lost connection leaves the edit queued, not lost.
Data:
Recommended storage: Session id in HttpOnly cookie, limit 4 KB, eviction risk low; Theme in localStorage, limit ~5 MB total, eviction risk medium; Offline outbox in IndexedDB, limit shared quota, eviction risk medium; App shell in Cache API, limit shared quota, eviction risk medium.datastorelimitriskSession idHttpOnly cookie4 KBlowThemelocalStorage~5 MB totalmediumOffline outboxIndexedDBshared quotamediumApp shellCache APIshared quotamedium
Columns: the data, where it goes, the size limit that applies, and how likely the browser removes it before you want it gone. Green: low risk; hatched: medium; red: high.
  • Session id: Server-set, so Safari's script cap doesn't apply.
  • Theme: Read in an inline script before first paint.
  • Offline outbox: The only copy of unsent edits: sync fast.
  • App shell: Installed by the service worker.

You keep unsent user work: ask for navigator.storage.persist() when the user turns on offline mode, and show what isn't synced yet.

Check your understanding · Exercise 8

A client-rendered app stores the user's dark-mode choice in IndexedDB. Users see a white flash on every load. Which fixes work? Select all that apply.

What breaks in production, and how real products do it

Storage bugs rarely show up in development, where the disk is big, the browser is fresh, and there is one tab. They show up for real users with full phones, old data, and ten tabs. Here are the ones that come up most.

The usual failures

Most of these share one fix: assume any storage operation can fail or return nothing, and keep the app usable when it does.
FailureHow you noticeFix
QuotaExceededError on a writeError logs; users report lost settings or draftsCatch every write; evict your own old cache first; check estimate() before big writes
Store is empty after eviction or Safari's 7-day ruleUsers say "my offline data disappeared"Treat stores as caches; sync to the server; request persist() for offline users
Old data shape after a deployJSON.parse errors or undefined fields at startupVersion keys and databases; migrate or discard; never crash on bad data
Two tabs on different app versionsIndexedDB upgrade is blocked; one tab sees a versionchangeClose the database on versionchange and ask the old tab to reload
Two tabs writing the same localStorage keyLast write wins; edits vanishUse IndexedDB transactions or Web Locks; one tab owns writes
Opaque responses filling the quotaestimate() shows far more usage than expectedChromium pads each cross-origin no-cors response by about 7 MB in quota maths: cache them sparingly or request them with CORS
Storage blocked by settings or policySecurityError on first accessFeature-detect inside try; fall back to memory

The common thread is defensive code at the boundary. Put all storage behind one small module, with typed get and set functions that catch errors, validate what they read, and report failures to your logging. Then the rest of the app never touches localStorage or indexedDB directly, and fixing a storage bug means changing one file.

In real products

  • Notion has written about moving its web app's local cache to SQLite compiled to WebAssembly, stored in OPFS and run in a worker, and reported faster page navigation. They also had to solve the one-writer problem: only one tab can hold the OPFS file, so tabs coordinate through a shared worker.
  • Adobe Photoshop on the web uses OPFS, according to Adobe's and Chrome's write-ups, for fast byte-level access to large working files.
  • Gmail and Google Docs can work offline once the user turns it on, keeping mail and documents on the device so the app opens and edits without a network, then syncing changes back.
  • Most single-page apps keep a few small preferences in localStorage, the login in an HttpOnly cookie, and their bundles in the HTTP cache. That plain setup is right for most products; the bigger stores earn their place only when the app must start or work offline.

Notice the pattern: none of these products trust local storage as the only copy. The server holds the truth, the device holds a fast copy plus a queue of changes not yet sent.

Check your understanding · Exercise 9

After a deploy, some users' apps crash on startup with TypeError: cannot read properties of undefined. The new version changed the shape of the cached prefs object. What would have prevented it? Select all that apply.

Client storage in a system design interview

Storage comes up in almost every frontend design question, usually as a sub-question: "what if the user is offline?", "how does the app start fast?", "where do you keep the token?". A short, precise answer shows depth; a vague "we'll cache it in local storage" is a red flag.

When to bring it up

  • In the data model step. For each entity, say where it lives: server only, in-memory store, or persisted on the client, and in which store.
  • When offline or slow networks are a requirement. Name IndexedDB for data, the Cache API for the shell, an outbox for writes, and persist() for users who opt in.
  • When auth comes up. HttpOnly, Secure, SameSite cookie for the session, and why not localStorage.
  • When performance comes up. Instant start from a local cache, theme from localStorage before paint, and never large synchronous writes on the main thread.

What to draw

Draw a client box with three inner layers: in-memory state at the top, the persisted stores below it (IndexedDB, Cache API, cookie), and the service worker beside them with arrows into the same stores. Draw the network to the server on one side. Then trace one write: user edits, memory updates, outbox in IndexedDB, request to server, outbox cleared. That one trace answers most follow-ups.

How deep to go by level

LevelWhat a strong answer includes
JuniorThe six stores and their rough sizes; cookies are sent to the server; localStorage is sync and string-only; tokens belong in HttpOnly cookies
MidIndexedDB for structured offline data and why; Cache API with a service worker for the shell; handling QuotaExceededError; versioning stored data; the storage event across tabs
SeniorQuotas, best-effort versus persistent, and Safari's 7-day rule shaping the sync design; partitioning effects on embeds; OPFS and SQLite for heavy local data; multi-tab coordination; what the user loses in each failure

Think first

The interviewer says: "Users should be able to open their last 200 notes offline and keep editing." Where does each piece of data go?

Check your understanding · Exercise 10

Which statements would worry an interviewer? Select all that apply.

Summary

  • Six stores: cookies for the server, localStorage and sessionStorage for small sync values, IndexedDB for structured data, the Cache API for HTTP responses, and OPFS for fast files in workers.
  • Cookies are about 4 KB each and ride on every matching request; keep them for the session id, set HttpOnly, Secure, and SameSite.
  • Web Storage is synchronous, strings only, about 5 MB per origin, and missing from workers: fine for a theme, wrong for user data.
  • IndexedDB, the Cache API, and OPFS share a large per-origin quota; check it with navigator.storage.estimate() and catch QuotaExceededError.
  • Storage is best-effort and evicted whole under disk pressure; persist() protects it from that, but Safari may still delete script-writable storage after 7 days without interaction.
  • Third-party storage is partitioned by top-level site, and private windows throw everything away at the end of the session.
  • Treat client storage as a cache: the server holds the truth, and every load must work with empty or outdated stores.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Why shouldn't you keep a session token in localStorage, and what do you do instead?

Question 2

Compare localStorage and IndexedDB. When would you pick each?

Question 3

Your offline-first app loses users' data on iPhones. What's going on, and how do you design around it?

Question 4

How do quotas and eviction work in the browser?

Question 5

What is storage partitioning and what does it break?

Practice it

These design problems lean on client storage options.

References

Read next