I9 · Part 2 · Intermediate
Offline & storage
localStorage is fine for a theme or a small cache. Past a few MB, it stops being enough.
08On this page
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");
};
⚠ Everything here is event-based, not promise-based
The raw API predates promises entirely —
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.
Dry run: opening "my-app-db" for the very first time
| Step | Code / event | "notes" object store | What happens |
|---|---|---|---|
| 1 | indexedDB.open("my-app-db", 1) | doesn't exist | returns a pending request; the browser looks for an existing database of this name |
| 2 | none found → onupgradeneeded fires | doesn't exist yet | event.target.result is the new db, version bumped 0 → 1 |
| 3 | db.createObjectStore("notes", { keyPath: "id" }) | created, empty | only legal inside the versionchange transaction that onupgradeneeded runs in |
| 4 | upgrade transaction commits → onsuccess fires | empty | request.result is now the open db |
| 5 | db.transaction("notes", "readwrite") | empty | a new read-write transaction opens on "notes" |
| 6 | tx.objectStore("notes").put({ id: 1, text: "buy milk" }) | write queued, not yet committed | the put is queued inside the transaction |
| 7 | queued work finishes → transaction auto-commits → tx.oncomplete fires | { id: 1, text: "buy milk" } | "saved" is logged |
createObjectStore can only run inside onupgradeneeded's special versioned transaction — that's why schema changes are gated behind bumping the version number passed to indexedDB.open, not something you can do from an ordinary transaction.
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.
Registering a service worker — and handling its updates
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").then((registration) => {
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
showUpdateBanner(); // a new version is ready, but the OLD one still controls this page
}
});
});
});
}
Feature-detecting "serviceWorker" in navigator first
matters because older browsers and some in-app webviews still don't
have it at all — the site should work without offline support there,
not throw. Registration itself just points the browser at the worker
script; everything from install onward runs on the
browser's own schedule, not the moment register() is
called.
The update trap: a new version installs, but doesn't take over
A service worker update installs silently in the background the next
time the page loads, but an already-open tab keeps running the
old worker until every tab using it is closed — by design,
so a page never has its logic swapped out from underneath it
mid-session. A new worker sits in a waiting state until
then.
// inside the NEW worker's own script (sw.js):
self.addEventListener("message", (event) => {
if (event.data === "SKIP_WAITING") self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim()); // take control of already-open tabs immediately
});
// back on the page, after showUpdateBanner() above — on a button click:
registration.waiting?.postMessage("SKIP_WAITING");
window.location.reload();
skipWaiting() moves the new worker straight from
waiting to activating instead of waiting for
every tab to close on its own; clients.claim() does the
equivalent for the page side, taking control of already-open tabs
rather than only new ones from here on. Calling both unconditionally
on every install is a common shortcut — an update takes effect the
moment it's ready, at the cost of a page occasionally reloading itself
mid-session if the user isn't asked first.
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"));
⚠ navigator.onLine is optimistic, not reliable
It reports whether the device has a network connection at all — wifi
connected, but the actual internet down, still reads
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.
Interview answer, one sentence
"localStorage is synchronous and small, so anything larger belongs in IndexedDB; the Cache API stores responses and a service worker sits between the page and the network to serve them, with an install, waiting and activate lifecycle that decides when a new version takes over."
Before you move on
- Explain why
createObjectStorecan only be called insideonupgradeneeded, and what triggers that event. - Say what the Cache API stores (and what it doesn't), and how that differs from what IndexedDB stores.
- Walk through a service worker's
install→activate→fetchlifecycle, and say what each event is the right place to do. - Explain why an already-open tab keeps running the old service worker after an update installs, and what
skipWaiting()andclients.claim()do about it. - Pick the right storage mechanism (localStorage, IndexedDB, Cache API, or cookies) for a given size and access pattern, and justify it against the comparison table.
Opens in the editor: write it, run it, and check it against real tests.
All 33 for this level →Finished reading?
0 of 41 chapters read in this series