The Browser Contract
Storage in the Browser: Cookies, localStorage, sessionStorage and IndexedDB
How long each one lasts, how big it can be, and who can read it: the tab, the site, the server or JavaScript. It ends with a table that answers: where should this data go?
On this page
In one line: a browser gives you five places to keep data (called storage). They differ in how long the data lives, who can read it and whether the server gets it automatically. Pick a place by asking those three questions, not by habit.
"Where should I store this?" is a daily decision. It is also a favourite interview question. A wrong answer can leak a login token, lose a user's cart, or send 4 KB of extra data on every request. This lesson explains each store and gives you a table you can remember.
The five stores at a glance
| Cookies | localStorage | sessionStorage | IndexedDB | Cache API / Service Worker | |
|---|---|---|---|---|---|
| Lifetime | Session, or until Expires / Max-Age | Until cleared by user, code or the browser | Until the tab closes (survives reload) | Until cleared | Until cleared or evicted (removed by the browser) |
| Scope | Domain + path (+ subdomains if Domain is set) | Origin | Origin and tab | Origin | Origin |
| Sent to server automatically? | Yes, on every matching request | No | No | No | No |
| Readable by JavaScript? | Yes, unless HttpOnly | Always | Always | Always | Always |
| Size | About 4 KB per cookie | About 5 MB per origin | About 5 MB per origin | Large (the limit depends on disk space, often hundreds of MB or more) | Large (same quota pool) |
| API | document.cookie or Set-Cookie header | Synchronous, strings only | Synchronous, strings only | Asynchronous, structured data and blobs | Asynchronous, request/response pairs |
| Works in Web Workers? | No | No | No | Yes | Yes |
A few words in this table need a short explanation:
- Origin means the scheme, host and port together. For example,
https://app.example.com:443. Scheme is the start of the URL (https), host is the name (app.example.com), port is the number after the colon (443). - Synchronous means the code stops and waits until the job is done. Asynchronous means the code keeps running and gets the result later.
- A Web Worker is a JavaScript file that runs in a separate background thread next to your page, so heavy work does not freeze the page. (A thread is one line of work that a program runs.)
Expires/Max-Ageare cookie settings that say when the cookie dies. They are explained below.HttpOnlyis a cookie setting that hides the cookie from JavaScript. It is explained below.- The Cache API is a browser store for saved network responses. It is explained below.
- A blob is a chunk of raw binary data, such as an image or a file.
Learn the first three rows well: lifetime, scope, and automatically sent. Most interview answers are built from them.
Cookies
A cookie is a small piece of text (a name and a value) that the browser stores for a site. The server asks the browser to remember it with a Set-Cookie response header. (A header is a named line of extra information that travels with a request or a response.) After that, the browser sends it back on later requests to the same site, in a Cookie request header. It is like a wristband at a festival: you show it every time you walk through a gate.
HTTP/1.1 200 OKSet-Cookie: session_id=abc123; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=LaxEvery later request to that site carries it:
GET /api/me HTTP/2Cookie: session_id=abc123What each attribute does
| Attribute | Meaning | Why you care |
|---|---|---|
Expires / Max-Age | When the cookie dies. Expires is a date. Max-Age is a number of seconds. No value means a session cookie. It lasts until the browser closes (a browser that restores old sessions may keep it longer). | Controls how long a login lasts. Browsers limit very long values (Chrome allows at most 400 days). |
Domain | Which hosts receive the cookie. (A host is a site name, such as app.example.com.) If you leave it out, only the exact host gets it. If you set Domain=example.com, all subdomains get it too. | Setting Domain makes the cookie more exposed. If blog.example.com is hacked, it can then read or overwrite cookies meant for app.example.com. |
Path | The first part of the URL path that the cookie applies to (for example /shop). | It is a convenience, not a security wall. Other paths on the same origin can still read it with script. |
Secure | The cookie is sent only over HTTPS. | Stops the cookie leaking over plain HTTP. Always set it in production. |
HttpOnly | JavaScript cannot read the cookie (document.cookie hides it). | Stops an XSS attack (an attacker's script running on your page) from stealing the cookie. It does not stop that script from using the session. (A session is the logged-in state of a user.) |
SameSite | Strict, Lax or None. It decides if the cookie is sent on requests that start from another site. Strict never sends it cross-site. Lax sends it only when the user clicks a link to your site. None always sends it. | This is your main defence against CSRF (cross-site request forgery, an attack where another site tricks the browser into sending a request). See Lesson 03. Chrome and Edge treat a missing value as Lax. Firefox and Safari do not yet, so always set it yourself. None requires Secure. |
Partitioned | Ties a third-party cookie to the top-level site, so it is kept in a separate jar for each site (this is called CHIPS, Cookies Having Independent Partitioned State). A third-party cookie is one set by a site other than the one in the address bar. | It lets embedded widgets, such as a chat box, keep state without tracking users across sites. |
Cookie name prefixes
A prefix is a fixed word at the start of a cookie name. It makes the browser enforce (force) rules:
__Secure-requires theSecureattribute.__Host-requiresSecure,Path=/, and noDomain. This ties the cookie to the exact host. A sibling subdomain cannot overwrite it. Use__Host-for session cookies when you can.
Why cookies are special
Cookies are the only store that the server receives automatically. This makes them a good home for a session ID (a random value that stands for one logged-in visit). It is also the reason CSRF exists: the browser adds the cookie even when another site started the request.
Cookies also cost bandwidth (the amount of data you send over the network). Say a domain serves 60 files and your cookie is 3 KB. Then about 180 KB of cookie data is uploaded for one page load. So keep cookies small. Where it matters, serve static files from a domain or path that has no cookies.
Reading and writing from JavaScript
// Writing (cannot set HttpOnly this way, only the server can)document.cookie = "theme=dark; Max-Age=31536000; Path=/; SameSite=Lax; Secure";// Reading gives ONE string of every non-HttpOnly cookieconsole.log(document.cookie); // "theme=dark; locale=en-IN"The document.cookie API is awkward. You get one long string and must split it yourself. So people use small libraries, or the newer Cookie Store API (an async, easier way to read and write cookies) in browsers that support it.
localStorage
localStorage is a browser store that keeps data as key-value pairs (like a dictionary: you save a value under a name and read it back by that name). It keeps strings (text). It is limited to the origin (scheme + host + port). The data stays until someone clears it.
localStorage.setItem("theme", "dark");localStorage.getItem("theme"); // "dark"localStorage.setItem("prefs", JSON.stringify({ density: "compact" }));JSON.parse(localStorage.getItem("prefs") ?? "{}");localStorage.removeItem("theme");Things you should know:
- It is synchronous. A big read or write blocks the main thread (the one thread that runs your JavaScript and also draws the page), so the page can freeze. Do not store megabytes of JSON that you parse again on every render.
- It stores strings only. Everything goes through
JSON.stringify(turns an object into text) andJSON.parse(turns that text back into an object). - All tabs of the origin share it. When one tab changes a value, the other tabs of the same origin get a
storageevent (the browser tells them something changed; the tab that made the change does not get it). You can use this to sync a logout or a theme across tabs:
window.addEventListener("storage", (e) => { if (e.key === "theme") applyTheme(e.newValue);});- All scripts on the origin share it. This includes your analytics snippet, your chat widget and any hacked dependency. There is no permission per script. This is the reason for the token-storage warning in Lesson 03. (A token is a piece of data that proves who you are.)
- It is not sent to the server. So it costs no bandwidth. But the server cannot use it when it builds the page. It does not exist on the server. In Next.js, a component that reads it must run on the client (in the browser) after the page loads.
- It is not guaranteed to stay. Users clear site data. Browsers delete data when space runs low. Safari's tracking prevention can delete script-written storage if the user has not used the site for about seven days.
sessionStorage
sessionStorage has the same API as localStorage. The difference is the scope: it belongs to one tab (a browsing context), and it is cleared when the tab closes.
- It survives a page reload and moving to other pages inside the same tab.
- Each tab has its own copy. If you open the same site in a second tab by typing the URL, that tab starts empty. (If code opens a new tab with
window.openand does not usenoopener, the new tab may get a one-time copy of the old tab's values, and then the two tabs go their own ways. Links withtarget="_blank"usenoopenerby default in modern browsers, so they do not copy. Do not rely on any of this to share data.) - It does not fire
storageevents across tabs, because tabs do not share it.
Good uses: a draft of a multi-step form, the progress of a wizard, a one-time redirect target, or "did I already show this banner during this visit?".
IndexedDB and the Cache API
IndexedDB is a database built into the browser. It is asynchronous, and it supports transactions (a group of changes that all succeed or all fail together). It can store structured data such as objects, arrays, Blobs and Files. It has indexes (extra lookup lists), so you can search by a range of keys. It also works in Web Workers and Service Workers (a Service Worker is a background script that sits between your page and the network and can answer requests itself).
Difference in one line: localStorage is a small, simple, blocking store for text. IndexedDB is a large, non-blocking database for structured data.
Use IndexedDB when you have a lot of data or data that is not a string. For example: records for an offline-first app, cached API lists, or drafts with attachments.
// Minimal raw usage (in practice you would wrap it with a small library such as idb)const open = indexedDB.open("app", 1);open.onupgradeneeded = () => open.result.createObjectStore("drafts", { keyPath: "id" });open.onsuccess = () => { const tx = open.result.transaction("drafts", "readwrite"); tx.objectStore("drafts").put({ id: 1, body: "Hello", updatedAt: Date.now() });};The Cache API is a browser store that saves whole network requests together with their responses. It is used mostly from Service Workers. It stores Request and Response pairs (a Request is what the page asked for, a Response is what came back). This is exactly what you need for an offline page shell or for cached files.
Quota and eviction: The browser gives each origin a share of disk space. This share is called the quota (the maximum you may use). Eviction means the browser deletes your data to free space. Use navigator.storage.estimate() to see how much you use and how much you may use. Use navigator.storage.persist() to ask the browser not to delete your data when space is low. The browser may say no. Any data you store may disappear. So treat it as a cache of something you can get back, not as the only copy.
Decision table: where does this data belong?
| Data | Best home | Why |
|---|---|---|
| Session ID / auth cookie | Cookie: HttpOnly; Secure; SameSite, preferably __Host- | The server needs it on every request, and JavaScript should not read it |
| Access token for an API (a short-lived token that lets code call an API) | In memory (a JS variable), renewed with an HttpOnly cookie | Storage that JavaScript can read is open to XSS (Lesson 03) |
| Theme, language, density | localStorage (or a cookie if the server must use it to build the page) | Not sensitive, and should stay. A cookie avoids a flash of the wrong theme on server rendering |
| "Shown the welcome banner this visit" | sessionStorage | One tab only, and it ends with the tab |
| Progress of a multi-step checkout form | sessionStorage (or a draft on the server) | Limited to one flow in one tab |
| Shopping cart for logged-in users | Server (database) | It must follow the user across devices |
| Cart for anonymous users | Cookie ID + cart on the server, or localStorage | It must survive new tabs and restarts |
| Large offline data, drafts with files | IndexedDB | Big, structured and asynchronous |
| Static files for offline use | Cache API via a Service Worker | Stores request and response pairs |
| Secrets (API keys, passwords) | Nowhere in the browser | Anything sent to the client is public |
The rule for logins: if your JavaScript can read it, an attacker's JavaScript can read it too. OWASP (the Open Worldwide Application Security Project, a well-known web security group) says clearly that session IDs and tokens should not live in localStorage or sessionStorage. Lesson 03 explains the trade-offs and the patterns that work.
A safe storage wrapper
Storage calls can throw errors. This happens in private modes, when cookies are blocked, when the quota is full, or inside an iframe without access. Never assume storage works. (try/catch is JavaScript syntax that runs the catch block if the try block throws an error.)
type Json = string | number | boolean | null | Json[] | { [k: string]: Json };export const safeStorage = { get<T extends Json>(key: string, fallback: T): T { try { const raw = localStorage.getItem(key); return raw === null ? fallback : (JSON.parse(raw) as T); } catch { return fallback; // storage blocked, or corrupted JSON } }, set(key: string, value: Json): boolean { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch { return false; // QuotaExceededError, SecurityError, ... } },};In Next.js, remember that Server Components and the first server render have no localStorage. Read it in an effect after the page loads. If you do not, you will get a hydration mismatch (hydration is when the browser attaches your JavaScript to the HTML from the server; a mismatch means the browser's result differs from the server's HTML).
Common mistakes
- Storing JWTs (JSON Web Tokens, signed tokens that prove identity; see Lesson 02) or session IDs in
localStorage. Any XSS bug (XSS, cross-site scripting, means an attacker's JavaScript runs on your page), even one in a third-party script, can read the value and send it to the attacker. - Using
localStoragefor data the server needs on the first render (such as theme or language). The server cannot see it, so the page flashes the wrong theme. Use a cookie for this. - Putting big objects in cookies. Cookies are sent on every request, even for images. Keep an ID in the cookie and the data on the server.
- Setting
Domainon a cookie "to be safe". It does the opposite. It shares the cookie with every subdomain. - Thinking storage is permanent. Treat browser storage as a cache that can vanish.
- Believing
Pathis security. It only limits where the cookie is sent. Any page on the same origin can still read the cookie with script (unless it isHttpOnly) or through an iframe. - Forgetting
try/catcharound storage calls.
Interview questions
When would you choose sessionStorage over localStorage?
Choose it when the data belongs to one tab and one visit. Examples are wizard progress, a draft, or a one-time flag. sessionStorage is separate for each tab and is cleared when the tab closes. So two tabs cannot overwrite each other, and nothing stays behind. Use localStorage when the data should stay between visits and be visible in every tab, such as a theme.
What do HttpOnly, Secure, SameSite, Domain and Path each do?
HttpOnly: hides the cookie from JavaScript, so XSS cannot read it.Secure: send the cookie only over HTTPS.SameSite: decides if the cookie is sent on cross-site requests (Strict,Lax,None). It is a defence against CSRF.Domain: which hosts get the cookie. If you leave it out, only the exact host gets it. If you set it, subdomains get it too.Path: limits which URL paths send the cookie. It is a convenience, not a security wall.
Why is localStorage shared by every script on an origin, and why does that matter?
Storage is split by origin, not by script. Your own code, the analytics tag, a chat widget and any hacked npm package all run on the same origin. So they can all read everything. That is why secrets and tokens do not belong there. One bad or buggy script can leak all of it.
Scenario: a user's cart disappears when they open the site in a new tab. Which storage choice caused it?
The cart is in sessionStorage, which is separate for each tab. The new tab has its own empty copy. For anonymous users, move the cart to localStorage. A better way is to keep the cart on the server. Tie it to the user, or to an anonymous ID in a cookie. Then it also follows the user across devices.
How would you sync a logout across all open tabs?
When the user logs out, write a flag to localStorage, for example localStorage.setItem("logout", Date.now()). In every tab, listen for the storage event. When it fires, clear the state in memory and redirect. A BroadcastChannel is a browser API that lets tabs of the same origin send messages to each other directly. It does the same job without using storage.
Why are cookies sent with every request, and how do you limit the cost?
The browser adds every cookie whose domain and path match. This includes requests for images and scripts. To limit the cost: keep cookies tiny (store an ID, not data), use a precise Path and host, avoid Domain when you do not need it, and serve static files from a hostname with no cookies or from a CDN (a network of servers that serve files from near the user) on another domain.
When is IndexedDB the right answer?
Use it when the data is large, structured or binary (raw bytes, like images). Also use it when you need queries or indexes, when you need asynchronous access, or when a worker must use the data. Examples are offline-first apps, drafts with attachments, and large cached lists. localStorage is synchronous, strings only and about 5 MB, so it is the wrong tool for these jobs.
Is browser storage a safe place to keep the only copy of important data?
No. Users clear data. Browsers delete data when space is low. Private modes throw it away. Some browsers expire script-written storage after a time of no use. Keep the real copy on the server, and treat the browser copy as a cache.
Scenario: Safari users complain that their saved preferences disappear after about a week away. Why, and what do you do?
Safari's tracking prevention limits how long script-written storage can live. This covers cookies set with document.cookie and localStorage. It applies to sites the user has not used for a period, about seven days. Treat browser storage as a cache. For logged-in users, keep important preferences on the server. Where it makes sense, set cookies from the server with Set-Cookie. When storage is empty, rebuild sensible defaults without errors.
Key takeaways
- Choose a store by lifetime, scope, and whether the server needs the data automatically.
- Cookies are the only store sent to the server automatically. Their attributes (
HttpOnly,Secure,SameSite, and noDomain) are your security controls. localStorageis shared by the whole origin. It is synchronous, strings only, and every script can read it. Never keep secrets there.sessionStoragebelongs to one tab and ends with the tab.- Use IndexedDB for big or structured data. Always wrap storage calls in
try/catch.
What's next
Lesson 02: Sessions vs JWT. Now you know where a credential can live. Next we look at what the credential is: a plain session ID that the server looks up, or a signed token that the client carries.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Securing LLM Applications: The OWASP Top 10 for LLM Apps in Code · AI Engineering
- Prompt Injection and Safe Tool Use for Agents · AI Engineering
- API Authentication and Authorization: API Keys, OAuth 2.0 and Scopes · API Design
Related series