There are five places to store data in the browser. Most apps use two. The user's theme preference, a draft of a message they're typing, a session token, the occasional megabyte of offline data — each one wants a different place. And one of them is where most apps quietly get it wrong.
The choice for any given piece of data comes down to three questions: how big it is, who needs to read it (your JS, or the server), and how long it should stick around. Once those three answers land, the rest falls out. Here are the options.
Cookies
Cookies are the oldest of the five and have one feature none of the others do: the browser automatically attaches them to every HTTP request to the cookie's domain. You don't write code to send them. The browser just does, on every request, for every cookie that matches.
That's both the killer feature and the cost. It's what makes cookies the obvious place for things the server needs on every request, like a session token. It's also what makes them expensive: every cookie you set is bytes on every request, whether the endpoint needs it or not.
The server sets a cookie in a response:
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Max-Age=3600
And from that point on, every request the browser sends to that domain carries:
Cookie: session=abc123
The flags matter. HttpOnly means JavaScript can't read the cookie via document.cookie — only the browser uses it when sending requests. Secure means it's only sent over HTTPS. SameSite=Lax stops the cookie from being attached on cross-site POSTs, which is the CSRF defense. Max-Age or Expires sets when the cookie dies. Without one, the cookie disappears when the browser closes.
The size limit is small: about 4 KB per cookie, with most browsers capping you at ~50 cookies per domain. Use cookies for what the server actually needs, nothing more.
localStorage and sessionStorage
These two share an API and differ only in lifetime.
localStorage.setItem('theme', 'dark')
localStorage.getItem('theme') // 'dark'
sessionStorage.setItem('draft', '...')
Both are synchronous. Both store only strings — you JSON.stringify objects on the way in and JSON.parse on the way out. Both are scoped per origin. And both are invisible to the server until you read and send the value yourself.
The difference is lifetime. localStorage sticks around until the user clears it or your code does. sessionStorage dies when the tab closes. It survives a page refresh, it survives navigation within the tab, but close the tab and it's gone.
Each gives you somewhere around 5–10 MB per origin, depending on the browser. Plenty for UI preferences, form drafts, small bits of state. Not enough for image caches or large datasets.
Sync isn't free. Every setItem and getItem blocks the main thread. For small data it's invisible. For megabytes of data, you'll see jank in your UI.
Use localStorage for things that should survive across tabs and sessions: theme settings, language, sidebar collapsed state. Use sessionStorage for things that should die with the tab: in-progress form data, multi-step wizard state, anything you'd be fine losing if the user closed the tab.
IndexedDB
What if 5–10 MB isn't enough? Or you need to store actual objects with Blobs, Dates, ArrayBuffers? Then the browser gives you a real database.
const request = indexedDB.open('myDB', 1)
request.onupgradeneeded = (e) => {
const db = e.target.result
db.createObjectStore('users', { keyPath: 'id' })
}
request.onsuccess = (e) => {
const db = e.target.result
const tx = db.transaction('users', 'readwrite')
tx.objectStore('users').add({ id: 1, name: 'Alice' })
}
It's asynchronous, transactional, and scales to hundreds of megabytes or more, depending on the browser. It also has, by reputation, the most awkward API in the browser: event-handler-driven, ceremony-heavy, easy to write incorrectly. Most teams that use it reach for a wrapper library like idb or Dexie within the first hour.
You'd use IndexedDB for offline-first apps, image caches, large local datasets — anywhere you've outgrown the simpler options. You don't reach for it for everyday state.
The fifth option, the Cache API, lives in this same category: async, large, scoped per origin. But it specifically stores HTTP Request/Response pairs, and it's almost always used inside a service worker for offline PWA strategies. It's not what you reach for when you just want to store user data.
All five, side by side
| Cookies | localStorage | sessionStorage | IndexedDB | Cache API | |
|---|---|---|---|---|---|
| Size limit | ~4 KB per cookie | ~5–10 MB | ~5–10 MB | Hundreds of MB+ | Hundreds of MB+ |
| Sync or async | n/a | Sync | Sync | Async | Async |
| Sent to server automatically | Yes | No | No | No | No |
| Readable by JavaScript | Yes, unless HttpOnly
|
Yes | Yes | Yes | Yes |
| Lifetime | Until expiry | Until cleared | Until tab closes | Until cleared | Until cleared |
| Best for | Auth, server-readable state | UI preferences, small JSON | Tab-scoped state, drafts | Large or structured data | HTTP response caching |
Two of these rows do most of the actual work in real decisions. Sent to server automatically is what makes cookies the right place for auth and the wrong place for almost anything else. Readable by JavaScript is what makes localStorage the wrong place for auth and the right place for UI state. The other rows fill in the picture.
And the auth token?
Almost every tutorial puts the auth token in localStorage. Easy to write, easy to read, the fetch wrapper attaches it as an Authorization: Bearer <token> header on every request.
It's the wrong answer for most apps.
localStorage is readable by any JavaScript running on your origin. Any XSS vulnerability anywhere on your site — a forgotten dangerouslySetInnerHTML, an unsanitized rich-text input, a compromised third-party script — and the attacker reads the token, exfiltrates it, and replays the user's session from their own machine.
The better answer is an HttpOnly cookie, paired with Secure and SameSite=Lax JavaScript can't read it, so even an XSS exploit can't steal it. The browser attaches it to requests automatically, so the fetch code doesn't need a special header. Yes, you lose the ability to decode the JWT in the browser to display the user's email or expiration. The fix is a separate GET /api/me endpoint that returns the user info on load and caches the result in memory.
The "but my architecture forces localStorage" case exists. SPAs talking to APIs on a different domain. Certain mobile-app patterns. SSO setups where cookies can't span the boundary. In those cases: keep tokens short-lived, rotate refresh tokens, and consider a backend-for-frontend pattern where a thin proxy on your own domain holds the cookies and forwards authenticated calls to the API. These are workarounds, not defaults.
The decision, in one breath
Five questions, five answers. Does the server need it on every request? Cookie. Small client-only state that should persist across sessions? localStorage. Small client-only state that should die with the tab? sessionStorage. Large or structured data? IndexedDB. HTTP responses for an offline PWA? Cache API.
Most apps end up using three: an HttpOnly session cookie, localStorage for UI preferences, and either sessionStorage for ephemeral state or IndexedDB if there's offline support. The browser offers more, but three covers most apps you'll build.
Top comments (0)