Free lesson · Frontend System Design
"I'll just put it in localStorage." The four most expensive words in client storage.
Watch a developer who hasn't thought carefully about client storage, and you'll see a reflex: whatever the data is - a theme preference, a draft, a cached API response, an auth token - it goes in localStorage, because localStorage is the one they know. It's the storage equivalent of reaching for WebSockets for every realtime feature: defaulting to the familiar tool instead of the right one. And it's a genuinely costly reflex, because the browser offers several distinct storage mechanisms that differ on dimensions that matter enormously - how much they hold, how long they last, whether they block the main thread, whether they get sent to your server on every request, and, critically, how exposed they are to attackers.
That last point makes one of these mistakes a security mistake, not just an inefficiency. Putting an authentication token in localStorage is a pattern you'll see everywhere, and it's the one this module will teach you to recognize as dangerous. So before we go deep on each mechanism, this orientation lesson maps the whole spectrum - what each option is, the dimensions that distinguish them, and a decision framework for "where should this piece of data actually live?" By the end, "I'll just put it in localStorage" should feel as naive as "I'll just use a WebSocket," and you'll reach for the right mechanism deliberately.
The mechanisms on the spectrum
There are five client storage options worth knowing, and they sort roughly by capability and complexity:
- In-memory (JavaScript variables / your app's state). The fastest, but gone on refresh. Not "storage" in the persistence sense, but it's where most data actually lives during a session, and it's the baseline the others are measured against.
- Cookies. The oldest mechanism, and fundamentally different because they're automatically sent to the server on every matching request. Small (~4KB), and the home of authentication and sessions.
- localStorage. Simple key-value string storage, persistent across sessions, per-origin, synchronous, ~5–10MB. The famous one, and the over-used one.
- sessionStorage. Nearly identical to localStorage, but scoped to a single tab and cleared when that tab closes. For ephemeral, per-tab data.
- IndexedDB. A full asynchronous, transactional database in the browser - large capacity, stores structured data (not just strings), supports indexes and queries. The heavyweight, for serious offline data.
There's also the Cache API (used by service workers to store network responses), which we'll cover in the caching lessons since it's purpose-built for that. For now, hold these five as the storage spectrum.
The dimensions that actually distinguish them
The reason "just use localStorage" is wrong is that these mechanisms differ on dimensions that determine correctness, performance, and security. The dimensions to evaluate any storage decision against:
Capacity. How much can it hold? Cookies are tiny (~4KB), localStorage and sessionStorage are modest (~5–10MB), IndexedDB is large (often hundreds of MB or more, subject to disk-based quotas). Trying to stuff a megabyte of data into a cookie or storing a large dataset in localStorage is a capacity mismatch.
Lifetime / persistence. How long does it survive? sessionStorage dies when the tab closes. localStorage and IndexedDB persist indefinitely until explicitly cleared. Cookies have an explicit expiry you set (or are session-scoped). Matching lifetime to need is essential - you don't want a draft to vanish on refresh, and you don't want sensitive session data lingering forever.
Scope. Who can see it? sessionStorage is per-tab. localStorage, IndexedDB, and cookies are per-origin (shared across all tabs of the same site). This matters when you have multiple tabs open - should they share this data or not?
Synchronous vs asynchronous. This is a performance dimension people forget. localStorage and sessionStorage are synchronous - reading or writing them blocks the main thread, which (recall the rendering module) is the same thread that does layout, paint, and your JavaScript. For small data it's negligible; for large data it can cause jank. IndexedDB is asynchronous - it doesn't block - which is part of why it's the right choice for larger datasets.
Sent to the server? Cookies are automatically attached to every matching HTTP request. localStorage, sessionStorage, and IndexedDB are never sent to the server - they're purely client-side. This single dimension is why cookies are used for auth (the server needs the session) and why putting large data in cookies is wasteful (it's uploaded on every request).
Data type. localStorage, sessionStorage, and cookies store strings only - you serialize objects with JSON.stringify. IndexedDB stores structured data natively (objects, arrays, even files/blobs) and supports indexes for querying.
Security exposure. How accessible is it to an attacker who achieves cross-site scripting (XSS)? Anything readable by JavaScript - localStorage, sessionStorage, regular cookies, IndexedDB - is readable by injected malicious JavaScript. The crucial exception is the HttpOnly cookie, which JavaScript cannot read, making it the secure home for auth tokens. This dimension is the one that turns a storage choice into a security decision.
graph TD
Data[A piece of data to store] --> Q1{Does the server<br/>need it on every request?<br/>e.g. auth/session}
Q1 -->|yes| Cookie[Cookie<br/>HttpOnly + Secure + SameSite<br/>for auth]
Q1 -->|no, client-only| Q2{How much / what shape?}
Q2 -->|large or structured,<br/>needs queries/offline| IDB[IndexedDB<br/>async, big, structured]
Q2 -->|small key-value| Q3{Survive tab close?}
Q3 -->|persist across sessions| LS[localStorage]
Q3 -->|only this tab/session| SS[sessionStorage]
This decision tree answers where a piece of data should live. First: does the server need it on every request, such as an auth or session token? If yes, use a cookie, specifically HttpOnly, Secure, and SameSite for authentication. If it's client-only, ask how much and what shape: if it's large or structured and needs queries or offline support, use IndexedDB, which is asynchronous, large-capacity, and stores structured data. If it's small key-value data, ask whether it should survive the tab closing: if it should persist across sessions, use localStorage; if it should only live for this tab and session, use sessionStorage.
The decision matrix
Pulling the dimensions together into the reference you'd reason from:
| Cookies | localStorage | sessionStorage | IndexedDB | |
|---|---|---|---|---|
| Capacity | ~4KB | ~5–10MB | ~5–10MB | Large (100s of MB+) |
| Lifetime | Set expiry / session | Until cleared | Tab close | Until cleared |
| Scope | Per-domain | Per-origin | Per-tab | Per-origin |
| Sync/async | n/a | Synchronous | Synchronous | Asynchronous |
| Sent to server | Yes, every request | No | No | No |
| Data type | String | String | String | Structured |
| JS-readable | Unless HttpOnly | Yes | Yes | Yes |
| Best for | Auth/session | Persistent prefs, small state | Per-tab ephemeral state | Large/offline/structured data |
Read the "Sent to server" and "JS-readable" rows together - they're the two that most often drive the right answer. "Does the server need it?" routes you to cookies. "Is it sensitive?" warns you away from anything JS-readable and toward HttpOnly cookies. Everything else is a capacity-and-lifetime detail.
The mistakes this module will teach you to avoid
Three storage mistakes are common enough to name up front, because recognizing them is half the value of this whole module:
Storing auth tokens in localStorage. This is the big one. localStorage is readable by any JavaScript on the page, so if an attacker injects a script via XSS, they can read the token and impersonate the user. The secure pattern is an HttpOnly cookie, which JavaScript can't touch. We'll dissect this in the cookies lesson, and it recurs in the security module.
Putting large data in synchronous storage. Writing a big object to localStorage blocks the main thread during the synchronous JSON.stringify and write, which can cause visible jank. Large datasets belong in asynchronous IndexedDB.
Bloating cookies. Since cookies are sent on every request, every extra byte in a cookie is uploaded repeatedly, wasting bandwidth on a slow connection. Cookies should hold a small identifier, not a payload - anything substantial belongs in client-only storage.
Important: The two questions that resolve most storage decisions: (1) Does the server need this data on each request? If yes → cookie. If no → client-only storage. (2) Is this data sensitive (tokens, secrets)? If yes → it must not be readable by JavaScript, so an HttpOnly cookie, never localStorage. Answer those two first, and the remaining choice between localStorage, sessionStorage, and IndexedDB is just capacity and lifetime. Most storage mistakes come from skipping question 2.
Interview: When an interviewer asks "where would you store X?", don't name a mechanism reflexively - reason through the dimensions out loud: "Does the server need it per request? Is it sensitive? How big is it, and should it survive a refresh or a tab close?" For a JWT: "Sensitive and server-needed, so an HttpOnly Secure cookie, not localStorage - localStorage is XSS-readable." For a theme preference: "Small, client-only, should persist - localStorage is perfect." For a multi-step form's progress: "Per-tab, ephemeral - sessionStorage." That dimensional reasoning is the senior signal; naming localStorage for everything is the tell that you haven't internalized the tradeoffs.
Quotas, eviction, and the storage you don't fully control
A dimension the simple matrix hides, but which matters for any serious client-storage design, is that your storage is not guaranteed to stick around. Browsers impose quotas and can evict data under pressure, and a robust design accounts for this rather than assuming storage is permanent.
The capacity numbers (~4KB cookies, ~5–10MB Web Storage) are limits, and exceeding them fails - typically with a QuotaExceededError thrown synchronously, which your code must handle rather than letting it crash a save. IndexedDB's quota is larger and dynamic, often a percentage of available disk, but it too has limits. More subtly, browsers treat client storage as best-effort by default: under disk pressure, or for sites the user hasn't engaged with much, the browser may evict stored data to reclaim space. So data you wrote can simply be gone on a later visit, through no action of yours or the user's.
This is why the Persistent Storage API exists - a way to request that the browser treat your storage as durable and not evict it under pressure (granted based on signals like the user having installed the app or engaged with it). For an app that genuinely depends on offline data surviving - the kind of thing built on IndexedDB - requesting persistent storage is part of a correct design. The broader lesson for a system design round: never assume client storage is permanent. Design so that losing it degrades gracefully - the canonical data lives on the server, and client storage is a cache or convenience that can be repopulated, not the source of truth. Treating client storage as authoritative is a real-world mistake that eviction eventually punishes.
The cross-cutting concerns every storage choice shares
Beyond picking a mechanism, several concerns apply across all of them, and addressing them is what separates a toy usage from a production one:
Serialization and its limits. The string-based stores (cookies, localStorage, sessionStorage) require JSON.stringify/parse, which silently loses things JSON can't represent - Date objects become strings, undefined and functions vanish, Map and Set don't survive, and circular references throw. IndexedDB avoids most of this by storing structured data via the structured-clone algorithm, which handles far more types. Knowing that "I stored a Date and got back a string" is a serialization artifact, not a bug in your logic, saves real debugging time.
Failure modes. Storage can fail for reasons beyond quota: some browsers' private/incognito modes restrict or disable persistent storage, and a few environments block it entirely. Code that assumes setItem always succeeds will break for those users. The robust pattern is to wrap storage access in try/catch and degrade gracefully - fall back to in-memory state if persistence isn't available - rather than letting a storage failure take down a feature.
Schema and migration. Stored data outlives your code. A user who hasn't visited in months has whatever shape you stored back then, and your new code must handle it. Versioning your stored data (a version field, or IndexedDB's built-in versioned schema migrations) lets you migrate or discard incompatible old data instead of crashing on it. This is easy to forget and a frequent source of "works for me, breaks for returning users" bugs.
Important: The mindset that prevents most storage bugs: client storage is a cache, not a database of record. It can be evicted by the browser, disabled in private mode, fail on quota, and outlive the code that wrote it. So the server holds the canonical data, and client storage is an optimization that must degrade gracefully when it's missing, fails, or holds an old shape. Designs that treat client storage as the authoritative source eventually lose data and confuse users - a point worth making in any round that involves offline or local persistence.
Routing several data types at once
To make the decision framework feel automatic, let's route a realistic grab-bag of data the way you would when designing a real app, because a feature rarely has just one thing to store. Picture a typical authenticated web app and walk its data:
- The auth/session token → server needs it every request and it's sensitive → HttpOnly Secure SameSite cookie. No deliberation; both decisive questions point here.
- The user's theme and language preferences → small, non-sensitive, client-only, should persist across visits → localStorage.
- An in-progress multi-step form → should survive refresh but reset on tab close, isolated per tab → sessionStorage.
- A cache of the user's last 500 messages for offline viewing → large, structured, queryable, must not block the main thread → IndexedDB.
- The current scroll position or open/closed UI panels for this session → ephemeral, per-tab → sessionStorage (or just in-memory if it needn't survive a refresh).
- A CSRF token for form submissions → needs to be readable by your JS to attach to requests, short-lived → a regular (non-HttpOnly) cookie or in-memory value, deliberately not HttpOnly because your code must read it.
Notice that a single app naturally uses four or five storage mechanisms at once, each matched to its data - which is the whole point of this lesson. The anti-pattern is forcing all of it into localStorage. The skill is the per-item routing, and in a round, narrating that routing ("the token's an HttpOnly cookie, prefs are localStorage, the form's sessionStorage, the offline cache is IndexedDB") demonstrates command of the spectrum far more than any single correct choice.
Interview: When storage comes up, resist the single-answer reflex and route the data by type. A strong line: "I'd use a mix - the session token in an HttpOnly cookie since it's sensitive and server-needed, user preferences in localStorage since they're small and should persist, in-progress form state in sessionStorage so it resets per tab, and any large offline dataset in IndexedDB so it doesn't block the main thread. The mistake I'd avoid is putting everything in localStorage, especially the token." That per-type routing, plus naming the localStorage-token anti-pattern, is the complete senior answer.
What you should carry out of this lesson
The browser offers a spectrum of storage mechanisms - cookies, localStorage, sessionStorage, and IndexedDB (plus in-memory and the Cache API) - that differ on capacity, lifetime, scope, synchronous-versus-asynchronous access, whether they're sent to the server, data type, and security exposure. The two dimensions that most often determine the right choice are "does the server need it per request?" (→ cookies) and "is it sensitive?" (→ HttpOnly cookies, never JS-readable storage). The most consequential mistakes are storing tokens in localStorage (XSS-readable), putting large data in synchronous storage (main-thread jank), and bloating cookies (uploaded on every request).
With the map in hand, we go deep on each mechanism, starting with the famous, over-used, deceptively simple one - the one that's the right answer for a narrower set of cases than people assume, and the wrong answer for the most important case of all. That's localStorage, next.