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?

Lesson 0115 min read#cookies#storage#security

On this page

  1. The five stores at a glance
  2. Cookies
  3. localStorage
  4. sessionStorage
  5. IndexedDB and the Cache API
  6. Decision table: where does this data belong?
  7. A safe storage wrapper
  8. Common mistakes
  9. Interview questions
  10. Key takeaways
  11. What's next

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

CookieslocalStoragesessionStorageIndexedDBCache API / Service Worker
LifetimeSession, or until Expires / Max-AgeUntil cleared by user, code or the browserUntil the tab closes (survives reload)Until clearedUntil cleared or evicted (removed by the browser)
ScopeDomain + path (+ subdomains if Domain is set)OriginOrigin and tabOriginOrigin
Sent to server automatically?Yes, on every matching requestNoNoNoNo
Readable by JavaScript?Yes, unless HttpOnlyAlwaysAlwaysAlwaysAlways
SizeAbout 4 KB per cookieAbout 5 MB per originAbout 5 MB per originLarge (the limit depends on disk space, often hundreds of MB or more)Large (same quota pool)
APIdocument.cookie or Set-Cookie headerSynchronous, strings onlySynchronous, strings onlyAsynchronous, structured data and blobsAsynchronous, request/response pairs
Works in Web Workers?NoNoNoYesYes

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-Age are cookie settings that say when the cookie dies. They are explained below.
  • HttpOnly is 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
HTTP/1.1 200 OKSet-Cookie: session_id=abc123; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=Lax

Every later request to that site carries it:

HTTP
GET /api/me HTTP/2Cookie: session_id=abc123

What each attribute does

AttributeMeaningWhy you care
Expires / Max-AgeWhen 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).
DomainWhich 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.
PathThe 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.
SecureThe cookie is sent only over HTTPS.Stops the cookie leaking over plain HTTP. Always set it in production.
HttpOnlyJavaScript 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.)
SameSiteStrict, 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.
PartitionedTies 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.

A prefix is a fixed word at the start of a cookie name. It makes the browser enforce (force) rules:

  • __Secure- requires the Secure attribute.
  • __Host- requires Secure, Path=/, and no Domain. 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

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.

JavaScript
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) and JSON.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 storage event (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:
JavaScript
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.open and does not use noopener, 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 with target="_blank" use noopener by default in modern browsers, so they do not copy. Do not rely on any of this to share data.)
  • It does not fire storage events 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.

JavaScript
// 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?

DataBest homeWhy
Session ID / auth cookieCookie: 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 cookieStorage that JavaScript can read is open to XSS (Lesson 03)
Theme, language, densitylocalStorage (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"sessionStorageOne tab only, and it ends with the tab
Progress of a multi-step checkout formsessionStorage (or a draft on the server)Limited to one flow in one tab
Shopping cart for logged-in usersServer (database)It must follow the user across devices
Cart for anonymous usersCookie ID + cart on the server, or localStorageIt must survive new tabs and restarts
Large offline data, drafts with filesIndexedDBBig, structured and asynchronous
Static files for offline useCache API via a Service WorkerStores request and response pairs
Secrets (API keys, passwords)Nowhere in the browserAnything 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.)

JavaScript
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

  1. 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.
  2. Using localStorage for 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.
  3. 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.
  4. Setting Domain on a cookie "to be safe". It does the opposite. It shares the cookie with every subdomain.
  5. Thinking storage is permanent. Treat browser storage as a cache that can vanish.
  6. Believing Path is security. It only limits where the cookie is sent. Any page on the same origin can still read the cookie with script (unless it is HttpOnly) or through an iframe.
  7. Forgetting try/catch around 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 no Domain) are your security controls.
  • localStorage is shared by the whole origin. It is synchronous, strings only, and every script can read it. Never keep secrets there.
  • sessionStorage belongs 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

Related series