As soon as an app works without the network, one question surfaces: where does the data physically live. Data of different natures needs different stores and rules. HTTP responses, domain records, and commands not yet confirmed by the server are three distinct things, and mixing them in one container bakes in bugs. It pays to split client state into three layers from the start.
The first layer is Cache Storage: a programmable store of request-response pairs (Request and Response) managed by the service worker. Into it goes the app shell - the static skeleton: HTML, scripts, styles, fonts, - images and, optionally, responses to API GETs. Cache Storage thinks in HTTP terms: the key is a request, the value is a ready response. The typical mistake is to treat it as a database of domain objects: it is about returning the same response the network would, not about entities and relations.
The second layer is IndexedDB: a transactional database built into the browser, with object stores and indexes. Here lives the app's real data: entities, indexes, blobs, and the outbox - the queue of outgoing commands. IndexedDB is durable storage, but with a price: it has schemas and versions; you cannot store critical data without migrations - a schema upgrade will one day break access to what was already created.
The third layer is in-memory state: the draft in the input field, selections, the transient optimistic status shown before confirmation. The fastest and the least reliable: it vanishes on a tab reload. The mistake is to treat it as long-term storage. An optimistic update in memory is a UX technique, not a durability guarantee. Until a record has landed in IndexedDB, treat it as not there.
| Layer | What it stores | Typical mistake |
|---|---|---|
| Cache Storage | Request/Response pairs, app shell, images, GET | Treating it as a database of domain objects |
| IndexedDB | Structured entities, indexes, blobs, outbox | Storing critical data without migrations |
| Memory/UI state | Current draft, selections, transient optimistic state | Taking it for durable storage |
Let us map this onto Offline Notes. The notes, the outbox queue, and a meta record (the last-sync cursor) live in IndexedDB: domain data that must survive a restart. The app shell goes into the Cache Storage precache when the worker installs. GET responses we cache only if that is a useful layer. A separate rule: mutating commands are never cached - a new note gets a client-generated ID (created on the client before sending) and a durable record in the outbox.
Now the foundation, without which nothing starts. A Service Worker sees and can substitute the page's network requests, so the browser hands it over only in a secure context - over HTTPS. The one concession is localhost: it is trusted - development does not go through tunnels. In production without valid TLS the worker simply will not register - not a bug but a defense against attacks where a man in the middle swaps the worker and seizes the traffic.
The second point is scope, the worker's area of control. By default it controls only the path where its script sits, and everything below. A worker at /assets/sw.js does not control pages under /app/. Hence the rule: place sw.js as high as the scope needs to be wide; to control the whole site, put it at the root - /sw.js. The Service-Worker-Allowed header widens scope beyond the script's folder, but if you needed it, that is usually a sign of an awkward deploy structure.
Assembled together, the minimal skeleton is simple. The HTML links the manifest and theme, and a script registers the worker at the root scope. Registration is hung on the load event so as not to disturb the first load, and support is checked via 'serviceWorker' in navigator. The flag updateViaCache: 'none' tells the browser not to take the sw.js file from the HTTP cache when checking for updates - otherwise you risk serving a stale worker for months. This is enough for the worker to gain control of the page.
<link rel="manifest" href="/app.webmanifest">
<meta name="theme-color" content="#087f56">
<script type="module">
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none'
});
console.log('SW scope:', registration.scope);
});
}
</script>