Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Intermediate/I9
26 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSI9 · Part 2 · Intermediate

Offline & storage

localStorage is fine for a theme or a small cache. Past a few MB, it stops being enough.

7 min read8 sections2 exercises
26 of 41

In this chapter

  • Why localStorage runs out
  • IndexedDB
  • The Cache API
  • Service Worker lifecycle
  • Registering a service worker — and handling its updates

and 3 more

about 7 min
08On this page
  1. Why localStorage runs out
  2. IndexedDB
  3. The Cache API
  4. Service Worker lifecycle
  5. Registering a service worker — and handling its updates
  6. PWA basics
  7. Being online-aware
  8. Storage, side by side

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

StepCode / event"notes" object storeWhat happens
1indexedDB.open("my-app-db", 1)doesn't existreturns a pending request; the browser looks for an existing database of this name
2none found → onupgradeneeded firesdoesn't exist yetevent.target.result is the new db, version bumped 0 → 1
3db.createObjectStore("notes", { keyPath: "id" })created, emptyonly legal inside the versionchange transaction that onupgradeneeded runs in
4upgrade transaction commits → onsuccess firesemptyrequest.result is now the open db
5db.transaction("notes", "readwrite")emptya new read-write transaction opens on "notes"
6tx.objectStore("notes").put({ id: 1, text: "buy milk" })write queued, not yet committedthe put is queued inside the transaction
7queued 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.

EventHappens
installonce, the first time — the usual place to pre-cache the app shell
activateonce this version takes control — clean up old caches from a previous version here
fetchfires 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-10MBdisk-space limiteddisk-space limited~4KB each
Data shapestringsstringsstructured objects, binaryRequest/Response pairsstrings
Sync or asyncsyncsyncasyncasyncsync (via document.cookie)
Survives tab close?yesnoyesyesyes, until expiry
Sent to the server automatically?nonononoyes, 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 createObjectStore can only be called inside onupgradeneeded, 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 → fetch lifecycle, 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() and clients.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.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsA cache-first lookup, the shape a service worker usesintermediate
  • 9 testsPick the right storage for the jobintermediate
All 33 for this level →
I9

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. I8Previous · I8Real-time connections
  2. I9you are here
  3. Next · I10Browser APIs, in depthStorage, the URL bar, and the event system — past what the beginner DOM chapter reached.5 min read

01On this page

8 sections · 0%

  1. 01Why localStorage runs out
  2. 02IndexedDB
  3. 03The Cache API
  4. 04Service Worker lifecycle
  5. 05Registering a service worker — and handling its updates
  6. 06PWA basics
  7. 07Being online-aware
  8. 08Storage, side by side

[] chapters · t top