localStorage vs sessionStorage vs Cookies
localStorage, sessionStorage, and cookies all store data in the browser, but differ in lifetime, size limits, and whether the server can see them.
localStorage, sessionStorage, and cookies are the three built-in ways a browser persists data client-side, and they differ on three axes: how long the data survives, how much you can store, and whether the server sees it automatically on every request. Picking the wrong one usually shows up as a subtle bug — data that vanishes on tab close when it shouldn’t, or a cookie silently bloating every request to your API.
The three mechanisms
- Cookies are small key-value strings, capped around 4KB each, set by either the server (
Set-Cookieheader) or JavaScript (document.cookie). Their defining trait is that the browser automatically attaches matching cookies to every HTTP request to that domain — no JavaScript required to send them. - localStorage is a synchronous key-value store scoped to the page’s origin, with no automatic expiry — data persists until a script or the user clears it, even across browser restarts. Storage limits are much larger than cookies, typically several megabytes depending on the browser.
- sessionStorage has the same API as localStorage but a different lifetime: data persists only for the tab’s session, cleared when that tab closes. Opening the same site in a new tab starts with empty sessionStorage — it’s not shared across tabs the way localStorage and cookies are.
Comparison
| Cookies | localStorage | sessionStorage | |
|---|---|---|---|
| Sent to server automatically | Yes, on every matching request | No | No |
| Lifetime | Set via Expires/Max-Age, or session | Until explicitly cleared | Until tab closes |
| Size limit | ~4KB per cookie | ~5-10MB (browser-dependent) | ~5-10MB (browser-dependent) |
| Shared across tabs | Yes | Yes | No |
| Accessible to JavaScript | Yes, unless HttpOnly | Yes | Yes |
| API | document.cookie string parsing | localStorage.setItem/getItem | sessionStorage.setItem/getItem |
Why “sent to the server automatically” matters
This is the most consequential difference and the one that decides which mechanism fits a given job. Cookies were designed for server-side session state: the server sets a session ID, and the browser hands it back on every subsequent request without any client code needing to remember to attach it. That’s exactly what you want for authentication — see how cookie attributes like HttpOnly and SameSite work for the security implications of that automatic behavior.
localStorage and sessionStorage do the opposite: nothing leaves the browser unless a script explicitly reads the value and sends it, typically as a header on a fetch call. That makes them a poor fit for session identifiers you want attached to every request, but a good fit for anything that should stay purely client-side — UI preferences, a draft of a form the user hasn’t submitted yet, cached data from an API response, or feature-flag overrides.
Where each one actually fits
Cookies remain the right tool for server-readable session state, especially when paired with HttpOnly (invisible to JavaScript, closing off a class of XSS-driven theft) and SameSite (limiting when the cookie is sent on cross-site requests, mitigating CSRF). Storing an auth token as a cookie the server sets and reads is generally safer than storing it in localStorage, precisely because the browser handles delivery and HttpOnly prevents any injected script from reading it directly.
localStorage fits data the client itself needs across visits but the server doesn’t need to see automatically: a saved theme preference, a dismissed-banner flag, or a locally cached copy of infrequently changing data. It’s a poor place for anything sensitive — unlike an HttpOnly cookie, localStorage is fully readable by any script running on the page, which means any successful XSS attack can read it wholesale. This is the practical reason many auth guides steer away from storing raw JWTs in localStorage despite how convenient the API looks.
sessionStorage fits data that’s genuinely scoped to one browsing session in one tab — a multi-step form’s in-progress state, or a one-time flag that shouldn’t leak into a second tab the user opens to the same site. It’s less commonly reached for than the other two, precisely because “only this tab, only until it closes” is a narrower need than most storage problems.
A note on IndexedDB
For anything beyond simple key-value pairs — structured records, larger datasets, data you want to query rather than just fetch by key — IndexedDB is the better-suited browser API. It supports transactions, indexes, and asynchronous access, none of which localStorage or sessionStorage provide; both are synchronous, which can block the main thread if used heavily. Offline-capable apps and PWAs that need to cache real datasets client-side typically lean on IndexedDB (often through a wrapper library) rather than localStorage, reserving localStorage for small, simple flags.
Security considerations that cut across all three
None of these mechanisms are private from the user sitting at the browser — anyone with devtools open can read cookies (unless HttpOnly), localStorage, and sessionStorage alike. The threat model they protect against is a different origin or a malicious script reading data that isn’t meant for it, not the user themselves. That’s also why none of the three should ever hold a raw password, and why sensitive tokens benefit from short expiry regardless of which storage mechanism carries them. A service worker caching responses adds yet another place data can persist, worth remembering when reasoning about what a page can access offline.
The takeaway
Reach for cookies when the server needs to see the value on every request, especially for auth state — HttpOnly and SameSite give cookies protections the other two mechanisms can’t replicate. Reach for localStorage when the client needs to remember something across visits that the server doesn’t need to know about. Reach for sessionStorage for state that’s genuinely scoped to a single tab’s lifetime. And once the data stops being simple key-value pairs, move to IndexedDB rather than stretching localStorage past what it was built for.
Tagged
Keep reading
Takina · · 5 min read Promise.all() vs allSettled() vs race() Compared
Promise.all() fails fast, allSettled() waits for every result, and race() returns whichever promise finishes first — how to choose correctly.
Takina · · 4 min read JavaScript Spread vs Rest Operators, Explained
The spread operator (...) expands an iterable into individual elements; the rest operator collects elements back into an array. Same syntax, opposite jobs.
Takina · · 4 min read ResizeObserver API Explained: Watching Element Size
The ResizeObserver API lets JavaScript watch an element's box size and react without polling or resize-event hacks. How it works and when to use it.