Offline & storage
localStorage was the whole story two chapters ago. Past a few MB, it stops being enough.
Why localStorage runs out
Already covered: localStorage and sessionStorage hold roughly 5-10MB, strings only, and every operation is synchronous — a big read or write briefly blocks the main thread. Fine for a theme preference or a small cache; the wrong tool the moment "small" stops being true.
IndexedDB
A real, asynchronous, transactional database built into the browser — structured records (not just strings), indexes to query by, and a storage ceiling set by available disk space rather than a fixed few megabytes.
const request = indexedDB.open("my-app-db", 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore("notes", { keyPath: "id" }); // runs once, on first open or version bump
};
request.onsuccess = (event) => {
const db = event.target.result;
const tx = db.transaction("notes", "readwrite");
tx.objectStore("notes").put({ id: 1, text: "buy milk" });
tx.oncomplete = () => console.log("saved");
};
onsuccess/
onerror callbacks, not await. Most real
projects reach for a small wrapper library (idb is the
common one) that wraps the same operations in real promises, rather
than hand-rolling callback plumbing for every query.
The Cache API
const cache = await caches.open("v1");
await cache.put("/api/products", new Response(JSON.stringify(products)));
const cached = await cache.match("/api/products");
if (cached) console.log(await cached.json());
Stores actual Request/Response pairs —
built specifically to cache network responses, not arbitrary data.
Used directly, or (far more often) driven from inside a service
worker's fetch handler below, intercepting real network
requests and serving a cached response instead of hitting the
network at all.
Service Worker lifecycle
A service worker is a script that runs separately from any page,
sitting between the app and the network — it can only be installed
over HTTPS (or localhost), and it goes through a fixed
sequence of events every browser follows the same way.
| Event | Happens |
|---|---|
install | once, the first time — the usual place to pre-cache the app shell |
activate | once this version takes control — clean up old caches from a previous version here |
fetch | fires for every network request the page makes, for as long as the worker is active — this is the hook that makes offline actually work |
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open("v1").then((cache) => cache.addAll(["/", "/app.js", "/style.css"]))
);
});
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request))
); // cache-first: serve cached if we have it, otherwise hit the network
});
event.waitUntil() tells the browser "don't finish this
lifecycle step until this promise settles" — without it, the worker
could finish installing before the cache is actually populated.
event.respondWith() is the equivalent for
fetch: it hijacks the response the page will actually
receive.
PWA basics
{
"name": "My App",
"short_name": "MyApp",
"start_url": "/",
"display": "standalone",
"theme_color": "#1f3a73",
"background_color": "#fffdf6",
"icons": [{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }]
}
A manifest.json, linked from the page's
<head>, plus a registered service worker, is the
entire minimum bar for "installable" — the browser's own criteria,
not a separate framework. display: "standalone" is what
drops the browser chrome (address bar, tabs) once installed, so it
opens looking like a real app rather than a browser tab.
Being online-aware
if (!navigator.onLine) {
showOfflineBanner();
}
window.addEventListener("online", () => console.log("back online"));
window.addEventListener("offline", () => console.log("connection lost"));
true. Treat it as a hint for UI (show a banner), never
as proof a request will actually succeed; still handle a failed
fetch regardless of what navigator.onLine
said a moment earlier.
Background sync is the piece that closes the loop: register a
sync event from the page (registration.sync.register("send-queued-posts")),
and the browser holds onto it, firing the service worker's
sync event once connectivity actually returns — even if
the page itself has been closed the whole time. It's how "your
message will send once you're back online" gets implemented for
real, instead of just queuing in memory and hoping the tab stays
open.
Storage, side by side
| localStorage | sessionStorage | IndexedDB | Cache API | Cookies | |
|---|---|---|---|---|---|
| Size | ~5-10MB | ~5-10MB | disk-space limited | disk-space limited | ~4KB each |
| Data shape | strings | strings | structured objects, binary | Request/Response pairs | strings |
| Sync or async | sync | sync | async | async | sync (via document.cookie) |
| Survives tab close? | yes | no | yes | yes | yes, until expiry |
| Sent to the server automatically? | no | no | no | no | yes, every matching request |
That last row is the one with real consequences — it's exactly why the security chapter's CSRF discussion is about cookies specifically and not localStorage: only a cookie rides along on a request automatically, whether your own JavaScript asked for that or not.
Opens in the editor — write it, run it, and check it against real tests.