# Browser Storage and Cookies

> How browsers remember things between page loads: cookies and their security attributes, localStorage/sessionStorage/IndexedDB, and which one to reach for depending on what you're storing.


---

# Browser Storage and Cookies

Every time a site remembers you're logged in, remembers your dark mode setting, or keeps a shopping
cart alive across tabs, it's using one of a handful of browser storage mechanisms. Pick the wrong one
and you leak an auth token to a malicious script, or lose a user's cart the moment they close a tab.

This guide covers the four tools browsers give you to persist data: cookies, `localStorage`,
`sessionStorage`, and IndexedDB. Each has a different lifetime, size limit, and threat model. The
skill isn't memorizing APIs - it's knowing which one fits a given piece of data.

## What you'll learn

1. **Cookies: What They Are and Why They Got Complicated** - the `name=value` string that rides along
   on every matching request, and the attributes (`HttpOnly`, `Secure`, `SameSite`) that keep that
   automatic behavior from becoming a liability.
2. **localStorage, sessionStorage, and IndexedDB** - the three client-side storage APIs, their limits,
   and when localStorage's simplicity stops being enough.
3. **Choosing the Right Storage for the Job** - a decision guide mapping real data (auth tokens, UI
   preferences, offline datasets) to the right mechanism, worked through end to end.

## Who this is for

You've worked through the DOM and forms guides and you're comfortable with JavaScript reading and
writing values. This guide assumes that baseline and builds the storage layer on top of it.

Ready? Start with [Phase 1: Cookies](01-cookies-explained.md).


---

# Cookies: What They Are and Why They Got Complicated

A cookie is a string, capped around 4KB, stored as a `name=value` pair. That's the entire idea. The
part that makes cookies useful - and occasionally dangerous - is what happens after the browser stores
one: it attaches that cookie to every subsequent request that matches the cookie's domain and path,
without your JavaScript doing anything.

That's the whole mechanism behind "staying logged in." Your server sets a session cookie once; the
browser replays it automatically on every request to that site, so the server recognizes you without
you re-entering a password on every page.

## Setting a cookie

The server sets a cookie via the `Set-Cookie` response header:

```
Set-Cookie: session_id=a1b2c3d4; HttpOnly; Secure; SameSite=Lax; Max-Age=3600; Path=/
```

From JavaScript, you can read and write the non-`HttpOnly` ones through `document.cookie`:

```js
// Set a cookie (JS can only touch cookies without HttpOnly)
document.cookie = "theme=dark; Max-Age=31536000; Path=/";

// Read all accessible cookies - comes back as one semicolon-joined string
console.log(document.cookie); // "theme=dark; other_cookie=value"
```

Notice `document.cookie` gives you one flat string, not a map. Parsing it yourself for every cookie
you need gets tedious fast - one more reason cookies aren't a great fit for anything beyond small
identifiers.

## The attribute that matters most: automatic sending

Every attribute below exists to control that one behavior: which requests get the cookie attached.
Get this part wrong and you've either broken your login flow or opened a hole for an attacker.

**`HttpOnly`** - blocks JavaScript from reading the cookie via `document.cookie`. It's still sent on
requests; your code can't see its value. This is the single most effective defense against
session-token theft via XSS: if an attacker injects a `<script>` into your page, `HttpOnly` means
`document.cookie` won't hand over the session ID no matter what that script does.

**`Secure`** - the browser only sends the cookie over HTTPS. Without it, a cookie set on a secure page
can still leak in plaintext if any request to that domain happens to go out over HTTP (a mixed-content
edge case, a stray `http://` link, a downgrade attack). Always set this in production.

**`SameSite`** - controls whether the cookie is sent on cross-site requests, and it's the main defense
against CSRF (Cross-Site Request Forgery - a malicious site tricking your browser into making a request
to a site you're logged into). Three values:

| Value | Sent on cross-site requests? | Typical use |
|---|---|---|
| `Strict` | Never | Banking, anything where losing the session on external links is fine |
| `Lax` | Only on top-level navigation (clicking a link), not on background requests | Default for most session cookies |
| `None` | Always (requires `Secure`) | Third-party embeds, payment widgets |

A concrete CSRF scenario `SameSite=Lax` blocks: you're logged into `bank.com`. You visit
`evil.com`, which has a hidden form auto-submitting a POST to `bank.com/transfer`. Without
`SameSite`, your browser happily attaches your `bank.com` session cookie to that forged request, and
the bank's server can't tell it apart from a real one. `SameSite=Lax` or `Strict` stops the cookie
from riding along on that background POST.

## Why third-party cookies are going away

A "third-party cookie" is one set by a domain other than the one in your address bar - an ad network
embedded on a hundred different sites, using cookies to recognize the same browser across all of them
and build a cross-site profile. That tracking use case is why browsers (Safari and Firefox already,
Chrome moving the same direction) increasingly block third-party cookies by default. If you're
building anything that depends on cross-site cookie tracking, plan for it to stop working - the
industry is shifting toward first-party data and server-side alternatives instead.

## Quick check

```quiz
[
  {
    "q": "Why does HttpOnly protect against XSS-based session theft?",
    "choices": ["It encrypts the cookie value", "It blocks document.cookie from reading the cookie, even though the cookie still gets sent on requests", "It prevents the cookie from being set at all"],
    "answer": 1,
    "explain": "HttpOnly cookies are invisible to JavaScript but still sent automatically by the browser - the server can use them, but an injected script can't read and exfiltrate them."
  },
  {
    "q": "A malicious site auto-submits a hidden form that POSTs to your bank's transfer endpoint while you're logged in elsewhere. Which attribute stops your session cookie from riding along?",
    "choices": ["Secure", "SameSite=Strict or Lax", "Max-Age"],
    "answer": 1
  },
  {
    "q": "What does the Secure attribute do?",
    "choices": ["Encrypts the cookie's contents", "Restricts the cookie to HTTPS requests only", "Makes the cookie HttpOnly automatically"],
    "answer": 1
  }
]
```


---

# localStorage, sessionStorage, and IndexedDB

Cookies get sent to the server on every request. `localStorage`, `sessionStorage`, and IndexedDB never
do - they live entirely in the browser, and only your JavaScript reads them. That single difference
shapes what each one is good for: nothing here belongs in a network request unless your code sends it
there deliberately.

## localStorage

Persists until a script or the user explicitly clears it - no expiry date, survives browser restarts.
Roughly 5-10MB per origin depending on the browser. The API is synchronous: every call blocks the main
thread until it completes.

```js
localStorage.setItem("theme", "dark");
localStorage.getItem("theme");      // "dark"
localStorage.removeItem("theme");
localStorage.clear();               // wipes everything for this origin

// Values are always strings - objects need JSON round-tripping
localStorage.setItem("prefs", JSON.stringify({ theme: "dark", fontSize: 16 }));
const prefs = JSON.parse(localStorage.getItem("prefs"));
```

Synchronous means fine for small reads/writes (a theme flag, a feature toggle) but a real cost if you
call it in a hot loop or store something large - it can block rendering.

## sessionStorage

Same API, different lifetime: cleared when the tab closes. Reloading the page or navigating within the
tab keeps it; opening a new tab to the same site does not share it (each tab gets its own).

```js
sessionStorage.setItem("draft_id", "abc123");
sessionStorage.getItem("draft_id");
```

Good fit for anything that shouldn't outlive the current visit - a multi-step form's in-progress state,
a "don't show this banner again this session" flag.

## IndexedDB

The one for when localStorage's limits actually bite: you need to store more than a few MB, you need
structured records instead of flat strings, or synchronous calls are blocking work you can't afford to
block. IndexedDB is asynchronous, transactional, and holds hundreds of MB to GB (browser- and
disk-dependent) of structured, queryable data - think of it as a database that ships inside the
browser.

The raw API is verbose - opening a database, defining object stores, wrapping everything in
transactions and `onsuccess`/`onerror` callbacks. Most real projects reach for a thin wrapper library
(like `idb`) rather than hand-writing it. At this stage, know what it's for rather than its full
surface:

```js
// Opening a database and reading a record - illustrative, not exhaustive
const request = indexedDB.open("notes-app", 1);

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  db.createObjectStore("notes", { keyPath: "id" });
};

request.onsuccess = (event) => {
  const db = event.result;
  const tx = db.transaction("notes", "readonly");
  const getReq = tx.objectStore("notes").get("note-1");
  getReq.onsuccess = () => console.log(getReq.result);
};
```

Reach for IndexedDB when you're building offline-first apps, caching large API responses, or storing
files/blobs client-side. For a settings flag or a cart with a dozen items, it's overkill.

## The three side by side

| | Lifetime | Size | Access | Sent to server? |
|---|---|---|---|---|
| Cookie | Set via `Expires`/`Max-Age` | ~4KB | Sync (`document.cookie`) | Yes, automatically |
| `localStorage` | Until cleared | ~5-10MB | Sync | No |
| `sessionStorage` | Until tab closes | ~5-10MB | Sync | No |
| IndexedDB | Until cleared | Hundreds of MB+ | Async | No |

Check the [DOM guide](/guides/the-dom-explained) if the event-driven callback style in the IndexedDB
example feels unfamiliar - it's the same pattern as DOM event listeners.

## Quick check

```quiz
[
  {
    "q": "You need to store a 50MB offline dataset for a PWA. What should you reach for?",
    "choices": ["localStorage", "sessionStorage", "IndexedDB"],
    "answer": 2,
    "explain": "IndexedDB is built for large, structured data - localStorage and sessionStorage cap out around 5-10MB and only hold strings."
  },
  {
    "q": "What's the key lifetime difference between localStorage and sessionStorage?",
    "choices": ["localStorage is per-tab, sessionStorage is shared across tabs", "sessionStorage clears when the tab closes; localStorage persists until explicitly cleared", "They have identical lifetimes but different size limits"],
    "answer": 1
  },
  {
    "q": "Does data in localStorage get sent to the server automatically like a cookie?",
    "choices": ["Yes, on every request", "No, only your JavaScript can read or send it", "Only over HTTPS"],
    "answer": 1
  }
]
```


---

# Choosing the Right Storage for the Job

Four storage options, one question to ask of each piece of data: **does the server need to see this
automatically, and can JavaScript be trusted with it?** Answer that and the right mechanism falls out.

## The decision guide

**Auth token → `HttpOnly` cookie.** The server needs it on every request, and if an attacker ever gets
a `<script>` running on your page (XSS), you don't want that script able to read the token and ship it
off to an attacker's server. `HttpOnly` cookies are invisible to JavaScript but still sent
automatically - the server gets what it needs, malicious scripts get nothing. Pair with `Secure` and
`SameSite=Lax` (or `Strict`) as covered in Phase 1.

**UI preference (dark mode, sidebar collapsed, language) → `localStorage`.** Purely client-side,
doesn't need to touch the server, should persist across visits. No reason to pay the cost of sending
it on every request like a cookie would.

**In-progress form state, one-time-per-visit flags → `sessionStorage`.** Should vanish when the tab
closes rather than sticking around forever.

**Large or structured data (offline article cache, downloaded dataset, file blobs) → IndexedDB.**
Anything past a few hundred KB, or anything that's genuinely a collection of records rather than a
handful of flags.

The rule underneath all four: never put anything sensitive in `localStorage` or `sessionStorage`.
Both are plain JavaScript-readable strings with zero protection against XSS - a single injected script
can call `localStorage.getItem` and exfiltrate whatever's there. That's exactly the attack `HttpOnly`
cookies exist to prevent, which is why auth tokens don't belong in `localStorage` even though it's the
more convenient API.

## Worked example: remembering dark mode

The full flow, end to end, for "remember the user's theme choice across visits."

**1. Read the saved preference on load, falling back to system preference:**

```js
function getInitialTheme() {
  const saved = localStorage.getItem("theme");
  if (saved === "dark" || saved === "light") return saved;

  // No saved choice yet - respect the OS-level setting
  return window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
}

document.documentElement.dataset.theme = getInitialTheme();
```

**2. Apply it with CSS** (see [CSS Without Tears](/guides/css-without-tears) for selector basics):

```css
[data-theme="dark"] {
  --bg: #16181d;
  --text: #e8e8e8;
}
[data-theme="light"] {
  --bg: #ffffff;
  --text: #16181d;
}
body {
  background: var(--bg);
  color: var(--text);
}
```

**3. Wire up a toggle button and persist the choice:**

```js
const toggle = document.querySelector("#theme-toggle");

toggle.addEventListener("click", () => {
  const current = document.documentElement.dataset.theme;
  const next = current === "dark" ? "light" : "dark";

  document.documentElement.dataset.theme = next;
  localStorage.setItem("theme", next);
});
```

That's it - three small pieces, no server round trip, no cookie overhead on every request. The choice
survives closing the browser entirely, because `localStorage` doesn't expire. If this were instead
"remember whether the user dismissed today's announcement banner," swap `localStorage` for
`sessionStorage` and the rest of the pattern is identical.

## Where you've been, where to go next

Zoom out and this closes the loop the whole Web Fundamentals category opened: [client-server
communication](/guides/what-the-web-actually-is) delivers [HTML](/guides/html-from-zero) that
[CSS](/guides/css-without-tears) and [layout](/guides/flexbox-and-grid) turn into a page, the
[DOM](/guides/the-dom-explained) and [forms](/guides/forms-that-work) make it interactive, the
[browser renders](/guides/how-the-browser-renders-a-page) it and adapts it
[responsively](/guides/responsive-design) and [accessibly](/guides/accessibility-from-day-one), and
now you know how it remembers you between visits.

That's the full page lifecycle, client-side. From here, the natural next step is the language driving
all of it in depth: [JavaScript From Zero](/guides/javascript-from-zero) if you want to go deeper on
the language itself, or a framework once you're ready to build something bigger than plain DOM
manipulation comfortably supports.

## Try it

```exercise
[
  {
    "type": "predict",
    "task": "A site stores a user's shopping cart JSON in localStorage under the key \"cart\". The user closes the tab and reopens the site an hour later. Is the cart still there?",
    "accept": ["yes", "/yes.*persist/i"],
    "hint": "localStorage has no expiry - it only clears when something explicitly removes it."
  },
  {
    "type": "task",
    "task": "You're building a \"remember me\" checkbox for a login form. Decide where the session token goes and where the checkbox's own on/off state goes, and why they're different.",
    "reveal": "The session token goes in an HttpOnly, Secure, SameSite cookie set by the server - it must ride along automatically and must stay out of reach of any injected script. The checkbox state itself (a UI flag, not a secret) can live in localStorage if you want to pre-check it next visit.",
    "checklist": ["Token isn't readable from JavaScript", "Token attributes include Secure and SameSite", "UI-only state isn't over-engineered into a cookie"]
  }
]
```

Quick check before you go:

```quiz
[
  {
    "q": "Where should an auth token live, and why?",
    "choices": ["localStorage, because it persists across visits", "An HttpOnly cookie, because JavaScript can't read it even though the server still receives it automatically", "sessionStorage, because it clears when the tab closes"],
    "answer": 1,
    "explain": "HttpOnly keeps the token out of reach of any injected script while still letting the browser send it automatically to the server."
  },
  {
    "q": "A dark mode toggle's current setting - where does it belong?",
    "choices": ["HttpOnly cookie", "localStorage", "IndexedDB"],
    "answer": 1,
    "explain": "It's a non-sensitive UI preference that only the client needs - no reason to send it to the server on every request or reach for a database."
  },
  {
    "q": "Why shouldn't you store a session token in localStorage?",
    "choices": ["It's too small to hold a token", "Any script running on the page (e.g. via XSS) can read localStorage directly", "localStorage doesn't support strings"],
    "answer": 1
  }
]
```
