Cookies, localStorage, sessionStorage
The 5 places the browser keeps data and when to use each.
Updated
Why you need storage in the browser
HTTP is stateless: the server doesn't know that request 2 comes from the same person as request 1. And a page starts from scratch on every reload. To "remember" anything — login, cart, preferences, a half-filled form — you need a place where data survives.
The browser gives you 5 such places, each with a different purpose.
Storage types compared
| Cookie | localStorage | sessionStorage | IndexedDB | Cache Storage | |
|---|---|---|---|---|---|
| Holds | small text | text (key → string) | text (key → string) | objects, files, blobs | whole HTTP responses |
| Capacity | ~4 KB / cookie | ~5 MB / origin | ~5 MB / origin | hundreds of MB+ | hundreds of MB+ |
| Sent to the server automatically | yes, on every request | no | no | no | no |
| Lifetime | until Max-Age / Expires |
forever | as long as the tab lives | forever | forever |
| Visible in | all tabs | all tabs | only the current tab | all tabs | all tabs |
| API | header / document.cookie |
synchronous | synchronous | asynchronous | asynchronous |
| Readable from JS | yes, unless HttpOnly |
yes | yes | yes | yes |
| Use for | session, auth, anonymous id | UI preferences, drafts | wizard / form state per tab | offline data, large cache | PWA, offline (service worker) |
All of them are isolated per origin: a.com can't read what b.com saved.
Cookies
A cookie is a small piece of text the server sets, and the browser sends back automatically on every request to the same site. That's why it's the only way the server can recognize you.
Set-Cookie: session=abc123; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax
| Attribute | What it does |
|---|---|
Max-Age / Expires |
how long it lives; without them it's a session cookie (gone when the browser closes) |
HttpOnly |
JS can't read it → protection against XSS |
Secure |
sent over HTTPS only |
SameSite=Lax |
not sent with POSTs coming from other sites → protection against CSRF |
SameSite=Strict |
never sent from other sites |
SameSite=None |
sent everywhere (requires Secure) — for cross-site integrations |
Path / Domain |
which URLs it's sent to |
This app sets a vid cookie (httpOnly) — that's how it recognizes your progress on the server without an account.
In Next you read cookies with cookies() from next/headers and set them in Server Actions, Route Handlers or proxy.ts.
localStorage
Key → string storage, permanent, shared by all tabs of the same site.
localStorage.setItem('theme', 'dark')
localStorage.getItem('theme') // 'dark'
localStorage.removeItem('theme')
localStorage.setItem('cart', JSON.stringify(cart)) // objects → JSON
const cart = JSON.parse(localStorage.getItem('cart') ?? '[]')- Strings only — objects go through
JSON.stringify/JSON.parse. - Synchronous API: it blocks the thread; don't save megabytes on every keystroke.
- When one tab changes
localStorage, the other tabs receive astorageevent — that's how you sync the theme across tabs.
sessionStorage
The same API as localStorage, but data lives only as long as the tab:
| Situation | sessionStorage |
|---|---|
| reload the page (F5) | kept |
| navigate to another URL in the same tab, then back | kept |
| open the site in a new tab | empty — each tab has its own storage |
| "Duplicate tab" | copied into the new tab, then they diverge |
| close the tab | deleted |
sessionStorage.setItem('checkout-step', '2')
sessionStorage.getItem('checkout-step') // '2' — only in this tabWhen it beats localStorage: a checkout wizard or a long form open in two tabs — each tab keeps its own progress without stepping on the other.
IndexedDB
A database in the browser: tables (object stores), indexes, transactions, whole objects (not just strings), files. The API is asynchronous (doesn't block the page) but verbose — in practice you use a small wrapper:
import { get, set } from 'idb-keyval'
await set('drafts', [{ id: 1, text: '...' }]) // objects directly, no JSON
const drafts = await get('drafts')For: offline apps, large data caches, user files before upload.
Cache Storage
Stores whole request → response pairs. Service workers use it to make a site work offline (PWA). You rarely touch it directly in regular apps.
Safety rules
- Never put auth tokens in
localStorage/sessionStorage— any script on the page (including one injected through XSS) can read them. The session lives in anHttpOnlycookie. - Storage can throw (private mode, quota exceeded, site data blocked) → wrap every read / write in
try/catch. - It doesn't exist on the server: in Next, read it only in Client Components after mounting (in
useEffectoruseSyncExternalStore), otherwise you get hydration errors. - The user can clear it at any time — it's not a reliable database for important data.
How to choose
| You need... | Use |
|---|---|
| the server to know who you are | an HttpOnly cookie |
| a preference to survive restarts | localStorage |
| state separate per tab that disappears on close | sessionStorage |
| large data, objects, offline | IndexedDB |
| state that can be shared as a link (filters, page) | the URL, not storage |
| important data, on any device | the server (database) |
Summary
- A cookie is the only one sent to the server automatically;
HttpOnly; Secure; SameSite=Laxfor sessions. localStorage= permanent, all tabs;sessionStorage= per tab, until closed; both strings only, synchronous.- IndexedDB for large and offline data; never secrets in storage; always
try/catch.