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
RequestandResponsepairs, 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.
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.
- 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.
- 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.
- 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.
- 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.
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.
| Store | Sync or async | Main thread | Workers | Service worker |
|---|---|---|---|---|
Cookies (document.cookie) | Sync | Yes | No | No (cookieStore in current browsers) |
| localStorage | Sync | Yes | No | No |
| sessionStorage | Sync | Yes | No | No |
| IndexedDB | Async | Yes | Yes | Yes |
| Cache API | Async | Yes | Yes | Yes |
| OPFS | Async, plus sync handles in dedicated workers | Yes (async) | Yes | Yes (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.
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.
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"
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.
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.
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,
setItemthrows aDOMExceptionnamedQuotaExceededError. Some browsers and settings also throw aSecurityErrorjust from touchingwindow.localStorage, for example when the user blocks site data. Wrap access intryand 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.
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;
}
}
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.
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]));
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.
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);
}
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.
// 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
Decision Where should a note-taking app keep 50 MB of notes for offline use?
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.
| Browser | Per-origin quota (approximate) | Persistent storage |
|---|---|---|
| Chrome, Edge | Up to about 60% of total disk | Granted silently by engagement heuristics (installed app, bookmarked, high use) |
| Firefox | Best-effort: 10% of disk or 10 GB for the site, whichever is smaller | Asks 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 user | Supports persist(), granted silently by heuristics such as running as a Home Screen web app |
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
}
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.
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.
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.
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.
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,SameSitesession 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 anHttpOnlycookie. 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
innerHTMLcreates a stored XSS. Validate and escape as you would a server response.
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"
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.
The decision table
| Data | Store | Why | If it vanishes |
|---|---|---|---|
| Session id | HttpOnly cookie | The server needs it on every request; script must not read it | User logs in again |
| CSRF token, locale for server rendering | Cookie (readable) | The server needs it while rendering | Default locale until next set |
| Theme, sidebar collapsed | localStorage | Tiny, read on startup before first paint | Default theme for one load |
| Draft in a multi-step form | sessionStorage | Belongs to this tab, survives reloads | User refills the step |
| Feed or inbox cache for instant start | IndexedDB | Many records, partial updates, worker access | Slower start, refetch |
| Offline outbox of edits | IndexedDB (persisted) | Must survive restarts; the service worker sends it | User's work is lost: sync fast |
| App shell: HTML, JS, CSS, fonts | Cache API | HTTP responses replayed by the service worker | Online-only until reinstalled |
| Images and media for offline | Cache API or IndexedDB Blobs | Large binary, keyed by URL or id | Refetch when online |
| Large local database, editor files | OPFS | Fast byte-level access from a worker | Rebuild from server, or loss if unsynced |
| Access token for a third-party API | Memory only | Short-lived; any storage widens what XSS can steal | Refresh 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.
- 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
| Failure | How you notice | Fix |
|---|---|---|
QuotaExceededError on a write | Error logs; users report lost settings or drafts | Catch every write; evict your own old cache first; check estimate() before big writes |
| Store is empty after eviction or Safari's 7-day rule | Users say "my offline data disappeared" | Treat stores as caches; sync to the server; request persist() for offline users |
| Old data shape after a deploy | JSON.parse errors or undefined fields at startup | Version keys and databases; migrate or discard; never crash on bad data |
| Two tabs on different app versions | IndexedDB upgrade is blocked; one tab sees a versionchange | Close the database on versionchange and ask the old tab to reload |
| Two tabs writing the same localStorage key | Last write wins; edits vanish | Use IndexedDB transactions or Web Locks; one tab owns writes |
| Opaque responses filling the quota | estimate() shows far more usage than expected | Chromium 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 policy | SecurityError on first access | Feature-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
HttpOnlycookie, 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,SameSitecookie 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
| Level | What a strong answer includes |
|---|---|
| Junior | The six stores and their rough sizes; cookies are sent to the server; localStorage is sync and string-only; tokens belong in HttpOnly cookies |
| Mid | IndexedDB 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 |
| Senior | Quotas, 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?
The app shell (HTML, JS, CSS) goes in the Cache API, installed by a service worker so the app opens with no network.
The 200 notes go in IndexedDB, one record each, with an index on updatedAt so the list renders without loading every body.
Edits are written to an IndexedDB outbox before being sent; the service worker or the page flushes it when online, and removes entries the server confirmed.
When the user turns on offline mode, call navigator.storage.persist(). Show a "3 changes not synced" badge so the user knows what's at risk, because eviction can still clear data for non-persisted sites, and Safari's 7-day rule may apply either way.
The session stays in an HttpOnly cookie. Preferences such as theme stay in localStorage for a flash-free start.
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, andSameSite. - 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 catchQuotaExceededError. - 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?
Any script running on the origin can read localStorage, including an attacker's script injected through an XSS bug. They can copy the token and use it from their own machine until it expires.
Instead, the server sets an HttpOnly, Secure, SameSite=Lax (or Strict) cookie, ideally with the __Host- prefix. Scripts can't read it, the browser sends it automatically, and SameSite blocks most CSRF.
If a bearer token must be held in JavaScript, keep it in memory only, keep it short-lived, and refresh it through an HttpOnly refresh cookie.
Question 2
Compare localStorage and IndexedDB. When would you pick each?
localStorage is synchronous, string-only, about 5 MB per origin, main-thread only, and very simple. IndexedDB is asynchronous, stores structured values and blobs, has indexes and transactions, works in workers and service workers, and shares the large origin quota.
Pick localStorage for a few small values read at startup, such as theme or a dismissed banner, especially when they must be read before first paint.
Pick IndexedDB for anything that grows with the user's data, is written often, needs queries, or must be read by a service worker, such as an offline cache or an outbox of edits.
Question 3
Your offline-first app loses users' data on iPhones. What's going on, and how do you design around it?
Likely Safari's ITP rule: after 7 days of Safari use without the user interacting with the site, Safari deletes script-writable storage, including IndexedDB, localStorage, and caches. Eviction under storage pressure is another cause for best-effort storage.
Design so the server is the source of truth: sync edits as soon as possible and keep the unsynced window short. Show what is not yet synced.
Encourage installing to the Home Screen for heavy users, request navigator.storage.persist() when they turn on offline mode, and make every load handle empty stores gracefully.
Question 4
How do quotas and eviction work in the browser?
IndexedDB, the Cache API, OPFS, and service worker scripts share one per-origin quota that is a share of the disk: up to about 60% in Chromium and Safari, and 10% or 10 GB for best-effort sites in Firefox. Web Storage has its own fixed 5 MB.
navigator.storage.estimate() returns approximate usage and quota. A write past the quota fails with QuotaExceededError.
Storage is best-effort by default: under disk pressure, the browser evicts whole origins, least recently used first. navigator.storage.persist() asks for persistent storage, which is only cleared by the user.
Question 5
What is storage partitioning and what does it break?
Browsers key third-party storage by the top-level site as well as the frame's origin, so an iframe embedded on two different sites gets two separate stores. It stops cross-site tracking through embeds.
It breaks embeds that relied on shared state across sites: single sign-on iframes reading a shared token, widgets that remember a user everywhere, and third-party cookies.
Fixes are a redirect-based login flow, Partitioned cookies (CHIPS) for per-site embed state, and the Storage Access API when an embed needs its first-party state with the user's consent.
Practice it
These design problems lean on client storage options.
- Design a theme switcher with dark modeEasy
- Design a recently viewed products featureEasy
- Design a cookie consent managerEasy
- Design a podcast audio playerMedium
- Design a customer support chat widgetMedium
- Design an embeddable poll widgetMedium
- Design a client-side analytics SDKMedium
- Design a feature flag SDKMedium
- Design a frontend logging and error SDKMedium
- Design state sync across browser tabsMedium
- Design an embeddable third-party widgetHard
- Design a browser extensionHard
- Design a web photo editorHard
- Design a long insurance application formHard
References
- MDN: Storage quotas and eviction criteria Per-browser quotas, best-effort versus persistent, and how eviction works.
- web.dev: Storage for the web Which store to use for what, with quota and persistence advice.
- MDN: Using HTTP cookies Cookie attributes, prefixes, and security.
- MDN: Origin private file system OPFS and sync access handles in workers.
- WebKit: Updates to Storage Policy Safari's quota rules and persistent storage support.
- Privacy Sandbox: Storage partitioning Which APIs Chrome partitions by top-level site.
- WHATWG Storage Standard The spec for buckets, quotas, and persistence.
Read next
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Cache invalidationHow every copy of a piece of data, from the server cache to the CDN to the open tab, learns that it is out of date: by time, by event, or by a new name, in the right order and without a stale read sneaking back in.
- Service worker cachingUsing a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.
- HTTP cachingThe browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.