The previous lesson pointed out a weakness: Iván goes down to the screen-printing storeroom, where the wifi does not reach, and Nómada Tasks does not even open. The board is stored in localStorage, but it makes no difference, because index.html, the CSS and the fifteen ES modules live on the server and without a network the browser has nothing to load. Crossing that boundary requires something you had not seen: a script that runs outside the page, that stays alive when the tab is closed, and that sits between your application and the network in order to answer requests from a cache of its own. That script is the service worker, and it is the piece that turns a website into a progressive web app: installable on the device, able to start without a connection and to behave like a native application. In this lesson you will understand its life cycle, master the Cache API and its strategies, make Nómada Tasks work offline with a queue of pending changes, write the manifest, solve the update problem and learn to debug it without losing your mind.
Contents
- What a PWA is
- The three requirements
- The service worker: a proxy that lives outside the page
- Registration and scope
- The life cycle:
install,waiting,activate,fetch - Why it has no DOM and no
localStorage - The Cache API
- Precaching the app shell in
install - Cleaning up old versions in
activate - The network strategies
- Intercepting with
fetch: the service worker's router - Working offline: fallback and change queue
- The manifest and installation
- Updating without leaving anybody behind
- Debugging in DevTools
- Push notifications, briefly
- Nómada Tasks: the complete
sw.js - Common Mistakes and Tips
- Exercises
- Conclusion
- What a PWA is
A progressive web app is not a technology: it is a set of capabilities that, added together, make a website behave like an installed application. It is still HTML, CSS and JavaScript served from a URL.
| Normal website | PWA | Native app | |
|---|---|---|---|
| Installation | No | Yes, from the browser | App store |
| Icon on the desktop | No | Yes | Yes |
| Works offline | No | Yes, if you program it | Yes |
| Its own window, no browser bar | No | Yes (display: standalone) |
Yes |
| Push notifications | Limited | Yes | Yes |
| Updating | Instant, on reload | Instant (controlled by you) | Store review |
| Distribution | One URL | One URL | Store, review, commission |
| Hardware access | Limited | Limited (improving) | Complete |
| Download size | Whatever the site weighs | Whatever the site weighs | Tens of MB |
The adjective progressive is the key and it explains the approach: the application works in any browser, and in the ones that support more capabilities it adds features. Nobody is left out. It is the same progressive enhancement you applied in 07-01 when degrading to an in-memory store if localStorage failed.
For Taller Nómada the proposition is concrete: Marta puts Nómada Tasks on the room's screen as an installed application, and Iván opens it on his phone in the storeroom with no signal, sees the board, marks a task as done, and that change is sent on its own when he comes back upstairs.
- The three requirements
| Requirement | What it is | Why |
|---|---|---|
| HTTPS | The page must be served over a secure connection | A service worker intercepts every request; over HTTP, an attacker on the network could inject a malicious and permanent one |
| Manifest | A manifest.json linked from the HTML |
It gives the installed application its name, icons and presentation mode |
| Service worker | A script registered with at least one fetch handler |
It is what makes working offline possible |
One practical exception: http://localhost is allowed for development. That is what makes it possible to test all of this on your machine without certificates.
- The service worker: a proxy that lives outside the page
This is the central idea, and it has to be understood before writing any code. A service worker is not a script belonging to your page. It is an independent worker that the browser runs on its own thread, with its own global context, and that sits between all the pages of your origin and the network.
flowchart LR
subgraph Before["Without a service worker"]
P1["Page"] -->|fetch| R1["Network"]
end
subgraph After["With a service worker"]
P2["Page"] -->|fetch| SW["Service Worker<br/>(own thread)"]
SW -->|"is it cached?"| C[("Cache API")]
SW -->|"if not, or per strategy"| R2["Network"]
C -.->|"response"| SW
R2 -.->|"response"| SW
SW -.->|"response"| P2
end
Five properties that define what it is and what it is not:
- It lives outside the page and survives the tab being closed. The browser starts it when it needs it and stops it when it does not.
- It intercepts every request from the pages in its scope: HTML, CSS, JS, images, API calls. They all go through its
fetchevent. - It has no access to the DOM. There is no
document, nowindow, none of your elements. - It is entirely asynchronous. No synchronous APIs: that is why it cannot use
localStorage. - It can be stopped at any moment. Do not keep state in the worker's global variables: when it starts up again, they will be gone.
The consequence of the second property is what demands respect: a badly written service worker can break your site for every visitor, and because it is stored on the device, it stays broken even if you fix the server. It is a powerful and persistent tool. Hence the HTTPS requirement.
- Registration and scope
Registration is done from the page, with an implicit fetch of the worker file:
// js/app.js — at the end, once the essentials already work
if ('serviceWorker' in navigator) { // ← progressive enhancement
window.addEventListener('load', async () => { // do not compete with the initial load
try {
const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
console.log('[nomada] Service worker registered. Scope:', registration.scope);
} catch (error) {
console.error('[nomada] Failed to register the service worker:', error);
}
});
}The scope decides which pages it controls, and it follows a strict rule: a service worker can only control URLs that are in its folder or below it.
| File location | Default scope | Controls |
|---|---|---|
/sw.js |
/ |
The whole site |
/js/sw.js |
/js/ |
Only /js/… — almost never what you want |
/app/sw.js |
/app/ |
Only /app/… |
That is why sw.js goes in the site root, not in js/ alongside the other modules. It is the exception to the project's folder organization, and the number-one cause of "I registered it and it intercepts nothing".
nomada-tasks/ index.html manifest.json ← new sw.js ← new, IN THE ROOT (scope '/') offline.html ← new, fallback page css/styles.css js/app.js js/…
A server can widen the scope of a worker sitting in a subdirectory by means of the
Service-Worker-Allowedheader, but it is an unnecessary complication: put the file in the root.
And a textbook warning: register() returns a promise that resolves when the registration has been accepted, not when the worker is active and controlling the page. A user's first visit is not controlled by the service worker unless you force it (section 14). That is extremely confusing when testing.
- The life cycle:
install, waiting, activate, fetch
install, waiting, activate, fetchThe life cycle is the hardest part of service workers, and understanding it avoids 80 % of the problems.
stateDiagram-v2
[*] --> Downloaded: register() · the browser downloads sw.js
Downloaded --> Installing: install event
Installing --> Installed: waitUntil() resolved (precache ready)
Installing --> Failed: waitUntil() rejected
Installed --> Waiting: ANOTHER sw is already controlling pages
Installed --> Activating: there is none (first time)
Waiting --> Activating: all tabs are closed<br/>or skipWaiting()
Activating --> Active: activate event finished
Active --> Active: fetch event (for every request)
Failed --> [*]
| Phase | Event | What to do in it |
|---|---|---|
| Installation | install |
Precache the app shell. It runs only once per version of the worker |
| Waiting | — | The new worker waits for the old one to stop controlling pages |
| Activation | activate |
Clean up caches from old versions. A safe moment: there is no other worker now |
| Operation | fetch |
Intercept and answer every request |
The waiting phase is the disconcerting one. If there is an active service worker controlling open tabs, the new one stays in waiting and does not take control, even if you reload with F5. It only steps in when all the site's tabs are closed. It is a deliberate protection: it stops the rules changing under the user's feet mid-session, with an old page requesting resources that the new cache has already deleted.
The browser decides that a worker is "new" by comparing the file byte for byte with the one it has stored. A single different character —typically the cache version number— is enough to trigger the whole cycle.
And one essential piece: event.waitUntil(). Since the browser can stop the worker as soon as the handler returns, you have to tell it explicitly to wait for a promise.
self.addEventListener('install', (event) => {
event.waitUntil( // ← without this, the worker can die halfway
caches.open('nomada-v1').then((cache) => cache.addAll(ASSETS))
);
});Without waitUntil, the installation would be considered finished before the cache had been filled, and you would have an "installed" service worker with a half-built cache.
- Why it has no DOM and no
localStorage
localStorageInside sw.js, the global object is not window, it is self (a ServiceWorkerGlobalScope). What is there and what is not:
| Available | Not available |
|---|---|
fetch, caches, indexedDB |
document, window, the DOM |
postMessage, clients |
localStorage and sessionStorage |
setTimeout, Promise, async/await |
alert, confirm, prompt |
importScripts() and ES modules (with type: 'module') |
Direct access to the interface |
The two important absences have different reasons:
- There is no DOM because the worker belongs to no page: there may be zero, one or five tabs open, or none. To talk to the pages you use
postMessage, and to change the interface, the page listens and acts. - There is no
localStoragebecause it is synchronous, and in a worker that serves network requests that would be a performance disaster. It is the same warning from 07-01 taken to its conclusion: the alternatives are the Cache API (for HTTP responses) and IndexedDB (for data).
This has a practical consequence for Nómada Tasks: the copy of the board you stored in localStorage is not accessible from the service worker. If you want the worker to manage a queue of pending changes, that queue has to live in IndexedDB.
Communication between page and worker, in both directions:
// From the page, to the worker
navigator.serviceWorker.controller?.postMessage({ type: 'clear-api-cache' });
// From the page, listening to the worker
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'synced') {
view.notify(`${event.data.count} changes sent to the server.`);
}
});// Inside sw.js
self.addEventListener('message', (event) => {
if (event.data?.type === 'skip-waiting') self.skipWaiting();
});
/** Notifies ALL controlled tabs. */
async function notifyClients(message) {
const clients = await self.clients.matchAll({ includeUncontrolled: true });
for (const client of clients) client.postMessage(message);
}
- The Cache API
caches is a store of HTTP request/response pairs. It does not store data like localStorage does: it stores complete Response objects, with their headers and their status.
// Open (or create) a named cache
const cache = await caches.open('nomada-shell-v1');
// Store: download and save
await cache.add('/css/styles.css');
await cache.addAll(['/index.html', '/js/app.js', '/css/styles.css']);
// Store a response you already have
await cache.put('/api/tasks', response.clone()); // ← clone(), the body is read once (07-02)
// Look up
const stored = await cache.match('/css/styles.css');
const anywhere = await caches.match('/css/styles.css'); // searches ALL the caches
// Manage
await cache.delete('/js/old.js');
const names = await caches.keys(); // ['nomada-shell-v1', 'nomada-data-v1']
await caches.delete('nomada-shell-v0');| Method | On | What it does |
|---|---|---|
caches.open(name) |
caches |
Opens or creates a named cache |
caches.match(request) |
caches |
Searches all the caches |
caches.keys() |
caches |
Lists the names |
caches.delete(name) |
caches |
Deletes a whole cache |
cache.add(url) |
one cache | Downloads and stores |
cache.addAll([urls]) |
one cache | The same, in bulk. If one fails, they all fail |
cache.put(request, response) |
one cache | Stores a response you already have |
cache.match(request) |
one cache | Searches that cache |
cache.delete(request) |
one cache | Deletes an entry |
Four details you learn by tripping over them:
addAllis atomic. If a single URL 404s, the promise rejects and none of them are stored. It is useful (a half-built precache is worse than none) and disconcerting: one misspelled path breaks the whole installation.putdoes not check the status. It will happily store a 404 or a 500 response. Checkresponse.okfirst.- The body is consumed when read, just as in 07-02. If you are going to return the response and store it, clone it.
- The cache belongs to the origin, with the same boundary as
localStorage, and it shares quota with IndexedDB. Caching videos fills it fast.
- Precaching the app shell in
install
installThe app shell is the minimal set of resources the application needs in order to paint its structure: the HTML, the CSS, the JavaScript modules, the font, the icons. The data is not part of the shell; it is requested separately.
// sw.js
const VERSION = 'v3'; // ← bump it on every deployment
const CACHE_SHELL = `nomada-shell-${VERSION}`;
const SHELL_ASSETS = [
'/', // important: the root, as well as index.html
'/index.html',
'/offline.html',
'/manifest.json',
'/css/styles.css',
'/js/app.js',
'/js/model/task.js',
'/js/model/board.js',
'/js/model/errors.js',
'/js/data/backlog.js',
'/js/data/local-repository.js',
'/js/data/tasks-api.js',
'/js/data/http.js',
'/js/data/realtime.js',
'/js/util/dates.js',
'/js/util/format.js',
'/js/util/time.js',
'/js/view/dom.js',
'/js/view/card.js',
'/js/view/paint.js',
'/js/view/board-view.js',
'/js/view/events.js',
'/js/view/controller.js',
'/js/view/form.js',
'/icons/icon-192.png',
'/icons/icon-512.png'
];
self.addEventListener('install', (event) => {
console.log(`[sw] Installing ${VERSION}`);
event.waitUntil(
caches.open(CACHE_SHELL)
.then((cache) => cache.addAll(SHELL_ASSETS))
.then(() => console.log('[sw] App shell precached'))
);
});Here the price of the ES modules from 05-04 shows up: every file is a request, and they all have to be on the list. If you forget one, the application will start offline right up to the missing import and die there. Two pieces of advice: keep it sorted by folder so you can audit it at a glance, and remember that in a project with a bundler this list is generated automatically (it is one of the reasons bundlers exist, and you will see it in 09-05).
Notice too that '/' and '/index.html' are two different entries as far as the cache is concerned, even if the server returns the same thing. If you only cache one, the other will fail offline.
- Cleaning up old versions in
activate
activateEvery version creates its own cache. Without cleanup, the device would accumulate nomada-shell-v1, v2, v3… until the quota ran out. The activate is the safe moment to delete: the old worker no longer controls anything.
self.addEventListener('activate', (event) => {
console.log(`[sw] Activating ${VERSION}`);
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(
names
.filter((name) => name.startsWith('nomada-') && !name.endsWith(VERSION))
.map((name) => {
console.log('[sw] Deleting old cache:', name);
return caches.delete(name);
})
);
await self.clients.claim(); // takes control of the already-open tabs
})());
});Two points:
- Filter by the
nomada-prefix. The origin may have other caches (from another application on the same domain, or from a library). Deleting everything there is would be the equivalent of thelocalStorage.clear()we advised against in 07-01. clients.claim()makes the newly activated worker take control of tabs that were already open without reloading them. Without it, they would stay uncontrolled until the next navigation.
- The network strategies
Here is the real design work. Intercepting requests is no use unless you decide what to do with each one, and not every resource deserves the same treatment.
| Strategy | How it works | Advantage | Drawback | In Nómada Tasks |
|---|---|---|---|---|
| Cache first | Look in the cache; if it is not there, the network | Instant, works offline | May serve stale content | CSS, JS, fonts, icons, images |
| Network first | Try the network; if it fails, the cache | Always the freshest | Slow if the network is bad | The tasks API |
| Stale-while-revalidate | Returns the cache now and updates in the background | Fast and stays fresh | The first time it shows the old one | Avatars, catalogs, tag lists |
| Network only | Always the network, no cache | Never stale data | Does not work offline | POST/PATCH/DELETE, WebSocket |
| Cache only | Cache only | Predictable | Fails if it was not precached | Versioned shell resources |
The first three, in code:
/** Cache first: for what does not change within a single version. */
async function cacheFirst(request) {
const stored = await caches.match(request);
if (stored) return stored;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_SHELL);
cache.put(request, response.clone()); // clone: the original is returned
}
return response;
}
/** Network first: for data, with the cache as a safety net. */
async function networkFirst(request, cacheName = CACHE_DATA) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch {
const stored = await caches.match(request);
if (stored) {
// We mark the response so the interface can warn that it is old
const headers = new Headers(stored.headers);
headers.set('X-From-Cache', 'true');
return new Response(stored.body, { status: 200, headers });
}
throw new Error('No network and no cached copy');
}
}
/** Stale-while-revalidate: the best of both worlds for non-critical data. */
async function staleWhileRevalidate(request, cacheName = CACHE_DATA) {
const cache = await caches.open(cacheName);
const stored = await cache.match(request);
const updating = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
})
.catch(() => null); // no network: never mind, we already returned the cache
return stored ?? await updating; // ← returns what is stored RIGHT AWAY if there is any
}That X-From-Cache deserves a comment: it is a header we invented that lets the application tell fresh data apart from data recovered from the past. Without it, the user would see yesterday's board believing it was today's, and that is exactly the kind of lie to avoid.
- Intercepting with
fetch: the service worker's router
fetch: the service worker's routerThe fetch handler receives every request. Applying a single strategy to all of them would be a mistake; what you write is a router.
self.addEventListener('fetch', (event) => {
const request = event.request;
const url = new URL(request.url);
// 1 · GET only: never intercept writes
if (request.method !== 'GET') return; // no respondWith: it goes to the network normally
// 2 · Our origin only (plus whatever we decide to allow)
if (url.origin !== self.location.origin && !isAllowedApi(url)) return;
// 3 · Navigations (the user opens or reloads the page)
if (request.mode === 'navigate') {
event.respondWith(handleNavigation(request));
return;
}
// 4 · API calls: network first
if (url.pathname.startsWith('/api/') || isAllowedApi(url)) {
event.respondWith(networkFirst(request));
return;
}
// 5 · Everything else (CSS, JS, images): cache first
event.respondWith(cacheFirst(request));
});Three golden rules for the fetch handler:
event.respondWith()must be called synchronously. You cannotawaitbefore deciding whether to respond; you callrespondWithfirst with a promise, and that promise does the work. If the handler finishes without calling it, the request follows its normal course, which is exactly what you want for whatever you do not handle.- Never intercept requests that are not
GET. A cached or duplicatedPOSTcreates phantom data. And in 07-03 you already learned how expensive repeating aPOSTis. - Always return something. If the
respondWithpromise rejects, the browser shows a generic network error. It is better to respond with a fallback page or a fallback JSON.
- Working offline: fallback and change queue
With the above, the application starts without a connection. Two gaps remain to be closed.
A fallback page for navigations to paths that are not cached:
async function handleNavigation(request) {
try {
return await fetch(request); // network first: always the freshest HTML
} catch {
const stored = await caches.match('/index.html');
return stored ?? await caches.match('/offline.html');
}
}<!-- offline.html — self-contained: it cannot depend on anything that is not cached -->
<main class="offline">
<h1>No connection</h1>
<p>Nómada Tasks cannot reach the server right now.</p>
<p>Your changes are being stored on the device and will be sent as soon as the connection is back.</p>
<button type="button" onclick="location.reload()">Retry</button>
</main>Detecting the connection state from the page:
// js/app.js
function updateConnectivity() {
const online = navigator.onLine;
$('#network-status').textContent = online ? '' : 'No connection — working locally';
$('#network-status').hidden = online;
document.body.classList.toggle('offline', !online);
}
window.addEventListener('online', () => { updateConnectivity(); syncPending(); });
window.addEventListener('offline', updateConnectivity);
updateConnectivity();An important warning about navigator.onLine: it only tells you whether there is an active network interface, not whether there is real Internet. A wifi connected to a router with no way out gives true. It is a useful hint, never a guarantee; the only real proof is attempting the request, with the error handling from 07-03.
Queue of pending changes. Since the service worker cannot use localStorage, the queue lives in IndexedDB, which it does share with the page:
// js/data/pending-queue.js (in the PAGE, not in the worker)
const DB = 'nomada-pending';
const STORE = 'changes';
function open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function enqueue(change) {
const db = await open();
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).add({ ...change, ts: Date.now() });
return new Promise((r) => { tx.oncomplete = r; });
}
export async function readAll() {
const db = await open();
return new Promise((resolve) => {
const request = db.transaction(STORE).objectStore(STORE).getAll();
request.onsuccess = () => resolve(request.result);
});
}
export async function remove(id) {
const db = await open();
db.transaction(STORE, 'readwrite').objectStore(STORE).delete(id);
}// js/app.js — sends what has piled up when the network comes back
export async function syncPending() {
const pending = await readAll();
if (pending.length === 0) return;
view.notify(`Sending ${pending.length} pending changes…`);
for (const change of pending) {
try {
await api.updateTask(change.id, change.data); // with the retries from 07-03
await remove(change.queueKey);
} catch (error) {
if (!error.retryable) await remove(change.queueKey); // do not insist on a 400
break; // the rest, on the next attempt
}
}
view.notify('Synchronization complete.');
}There is also the Background Sync API, which lets you register a synchronization that the browser will run when there is a connection, even if the tab is closed:
// From the page
const registration = await navigator.serviceWorker.ready;
if ('sync' in registration) await registration.sync.register('send-changes');// Inside sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'send-changes') event.waitUntil(sendPendingChanges());
});It is elegant, but its support is not universal, so treat it as an enhancement on top of synchronizing with the online event, never as the main mechanism.
- The manifest and installation
The manifest is a JSON file describing the installed application:
{
"name": "Nómada Tasks — Taller Nómada",
"short_name": "Nómada",
"description": "Task management for the workshop and the coworking space",
"start_url": "/?origen=pwa",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#faf7f2",
"theme_color": "#2b6b5b",
"lang": "en",
"dir": "ltr",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"shortcuts": [
{ "name": "New task", "url": "/?accion=nueva", "description": "Create a task" }
]
}<!-- index.html, inside <head> -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2b6b5b">
<link rel="apple-touch-icon" href="/icons/icon-192.png">| Field | What for | Note |
|---|---|---|
name |
Full name, on the installation screen | — |
short_name |
Under the icon | About 12 characters maximum or it gets cut off |
start_url |
What opens when the icon is pressed | The parameter lets you measure how many people use it installed |
scope |
Which URLs belong to the application | Outside the scope the browser opens |
display |
standalone, fullscreen, minimal-ui, browser |
standalone is the usual one: no address bar |
theme_color |
Color of the system bar | — |
background_color |
Background of the splash screen | Set it to the real background color, or you will see a flash |
icons |
Icons | 192 and 512 px as a minimum; add a maskable one |
The maskable icon is a detail that shows: Android crops icons into different shapes (circle, rounded square), and without it your logo can end up decapitated. A maskable one leaves a safety margin around the edges.
The install prompt. When the requirements are met, the browser fires beforeinstallprompt, and you can control the timing:
let installPrompt = null;
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault(); // ← prevents the browser's automatic prompt
installPrompt = event;
$('#install').hidden = false; // show YOUR button, at YOUR moment
});
$('#install').addEventListener('click', async () => {
if (installPrompt === null) return;
installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
console.log('[nomada] Installation:', outcome); // 'accepted' | 'dismissed'
installPrompt = null; // the event is single-use
$('#install').hidden = true;
});
window.addEventListener('appinstalled', () => {
$('#install').hidden = true;
console.log('[nomada] Installed');
});Two rules of good manners, siblings of the ones you will see in 07-06 with permissions: do not ask to install anything the moment someone arrives —the user does not yet know whether your application interests them— and offer it once they have shown interest, for example after they create their third task. And if they decline, do not ask again for weeks.
- Updating without leaving anybody behind
This is the most annoying practical problem with PWAs: the user can be stuck on an old version indefinitely. With a normal website, an F5 brings the latest; with a badly configured PWA, the service worker serves the old cache forever.
The browser checks whether sw.js changed on navigation (and at most every 24 h). If it changed, it installs the new one… which then sits waiting. There are three strategies:
| Strategy | How | Advantage | Drawback |
|---|---|---|---|
| Wait (default) | The new one steps in when all tabs are closed | Never breaks a session in progress | The user who never closes the tab never updates |
Immediate skipWaiting() |
The new one takes control right away | Guaranteed update | Dangerous: the page in progress may request resources that have already been deleted |
| Notify and let them decide | "There is a new version. Update" | Safe and transparent | A little more code |
The third one is the right one, and this is how it is implemented:
// sw.js
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_SHELL).then((c) => c.addAll(SHELL_ASSETS)));
// ← we do NOT call skipWaiting() here: we wait for the user to accept
});
self.addEventListener('message', (event) => {
if (event.data?.type === 'skip-waiting') self.skipWaiting();
});// js/app.js
const registration = await navigator.serviceWorker.register('/sw.js');
// 1 · Detect that there is a new worker waiting
registration.addEventListener('updatefound', () => {
const incoming = registration.installing;
incoming.addEventListener('statechange', () => {
// 'installed' + there is a controller = this is an UPDATE, not the first installation
if (incoming.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateNotice(incoming);
}
});
});
function showUpdateNotice(newWorker) {
$('#version-notice').hidden = false;
$('#version-notice-update').addEventListener('click', () => {
newWorker.postMessage({ type: 'skip-waiting' }); // 2 · the user accepts
}, { once: true });
}
// 3 · When the new one takes control, reload ONCE
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return; // ← guard against loops
reloading = true;
window.location.reload();
});
// Check for updates when coming back to the tab
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') registration.update();
});That reloading flag is not optional: without it, controllerchange can fire more than once and cause an infinite reload loop, one of the nastiest failures a user can suffer.
And a deployment rule that avoids the worst-case scenario: the sw.js file must never be cached on the server. Configure it with Cache-Control: no-cache. If a CDN serves an old sw.js for hours, your users are frozen on an old version and there is nothing you can do from the client.
- Debugging in DevTools
In DevTools → Application you have the complete control panel:
| Section | What for |
|---|---|
| Service Workers | See the state (installing / waiting / activated), force skipWaiting, Unregister, see the logs |
| Manifest | Check that the manifest is read correctly and see the detected icons |
| Cache Storage | Inspect each cache entry by entry, and delete them |
| Storage → Clear site data | The nuclear button: wipes everything and unregisters the worker |
Three checkboxes that will save your life during development:
Update on reload: forces the new service worker to install and activate on every reload, skipping the wait. Turn it on while you develop. It is the difference between iterating in seconds and fighting with caches.Bypass for network: ignores the service worker entirely, as if it did not exist.Offline(in Network): the only way of really testing that your application works offline.
The cache trap during development deserves a paragraph of its own because it happens to everybody: you change the CSS, reload and do not see the change. The cause is your own cache-first strategy serving the old file. The remedies, in order:
- Tick
Update on reloadandDisable cachein DevTools. - Bump the
VERSIONconstant insw.json every change to the resources. - As a last resort, Clear site data and reload.
And the most valuable advice: register the service worker only in production while you are developing the application, or behind a flag. Debugging an application with a caching proxy in the middle multiplies the time of every iteration.
const IS_LOCAL = ['localhost', '127.0.0.1'].includes(location.hostname);
if ('serviceWorker' in navigator && (!IS_LOCAL || location.search.includes('sw=1'))) {
navigator.serviceWorker.register('/sw.js');
}
- Push notifications, briefly
A service worker can receive messages from the server even when the application is closed, and show a system notification. The mechanism, in three steps:
// 1 · The page asks for permission and subscribes (with the server's public VAPID key)
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY
});
await api.saveSubscription(subscription); // the server needs it in order to send you things// 2 · In sw.js: the push arrives
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(self.registration.showNotification('Nómada Tasks', {
body: data.message ?? 'There is news on the board',
icon: '/icons/icon-192.png',
data: { url: data.url ?? '/' }
}));
});
// 3 · The user taps the notification
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(self.clients.openWindow(event.notification.data.url));
});Three things to know before considering it:
- It requires a server that manages the subscriptions and signs the sends with VAPID keys. It is not client-only.
userVisibleOnly: trueis compulsory in practice: you cannot use push to do silent work.- Permissions are covered in 07-06, along with the golden rule: do not ask for notification permission the moment someone arrives. It is the fastest way to have it denied forever.
For Nómada Tasks it is probably unnecessary: with the WebSocket from 07-04 the board already updates live while it is open, and notifications add value when the application is not.
- Nómada Tasks: the complete
sw.js
sw.js// sw.js — in the site ROOT, so the scope is '/'
const VERSION = 'v3';
const CACHE_SHELL = `nomada-shell-${VERSION}`;
const CACHE_DATA = `nomada-data-${VERSION}`;
const API = 'http://localhost:3000'; // in production, the API's real URL
const SHELL_ASSETS = [
'/', '/index.html', '/offline.html', '/manifest.json',
'/css/styles.css',
'/js/app.js',
'/js/model/task.js', '/js/model/board.js', '/js/model/errors.js',
'/js/data/backlog.js', '/js/data/local-repository.js',
'/js/data/tasks-api.js', '/js/data/http.js', '/js/data/realtime.js',
'/js/util/dates.js', '/js/util/format.js', '/js/util/time.js',
'/js/view/dom.js', '/js/view/card.js', '/js/view/paint.js',
'/js/view/board-view.js', '/js/view/events.js',
'/js/view/controller.js', '/js/view/form.js',
'/icons/icon-192.png', '/icons/icon-512.png'
];
// ══════════════════ INSTALLATION ══════════════════
self.addEventListener('install', (event) => {
console.log(`[sw] install ${VERSION}`);
event.waitUntil(
caches.open(CACHE_SHELL).then((cache) => cache.addAll(SHELL_ASSETS))
);
// No skipWaiting(): the user decides about the update (section 14)
});
// ══════════════════ ACTIVATION ══════════════════
self.addEventListener('activate', (event) => {
console.log(`[sw] activate ${VERSION}`);
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(
names
.filter((n) => n.startsWith('nomada-') && !n.endsWith(VERSION))
.map((n) => caches.delete(n))
);
await self.clients.claim();
})());
});
// ══════════════════ MESSAGES FROM THE PAGE ══════════════════
self.addEventListener('message', (event) => {
if (event.data?.type === 'skip-waiting') self.skipWaiting();
});
// ══════════════════ STRATEGIES ══════════════════
async function cacheFirst(request) {
const stored = await caches.match(request);
if (stored) return stored;
try {
const response = await fetch(request);
if (response.ok) (await caches.open(CACHE_SHELL)).put(request, response.clone());
return response;
} catch (error) {
if (request.destination === 'image') return caches.match('/icons/icon-192.png');
throw error;
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) (await caches.open(CACHE_DATA)).put(request, response.clone());
return response;
} catch {
const stored = await caches.match(request);
if (stored) {
const headers = new Headers(stored.headers);
headers.set('X-From-Cache', 'true'); // the interface will warn that it is old
return new Response(stored.body, { status: 200, headers });
}
// Fallback JSON response: better than an opaque network error
return new Response(
JSON.stringify({ error: 'offline', message: 'No connection and no local copy.' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
);
}
}
async function handleNavigation(request) {
try {
return await fetch(request);
} catch {
return (await caches.match('/index.html')) ?? (await caches.match('/offline.html'));
}
}
// ══════════════════ INTERCEPTION ══════════════════
self.addEventListener('fetch', (event) => {
const request = event.request;
const url = new URL(request.url);
if (request.method !== 'GET') return; // never POST/PATCH/DELETE
if (url.protocol.startsWith('ws')) return; // the WebSocket from 07-04 goes its own way
if (request.mode === 'navigate') {
event.respondWith(handleNavigation(request));
return;
}
if (url.origin === API) {
event.respondWith(networkFirst(request));
return;
}
if (url.origin === self.location.origin) {
event.respondWith(cacheFirst(request));
}
// Any other origin: no respondWith, it goes to the network as it is
});Check that it works with this script, which is also the one you should repeat on every deployment:
- Serve the site (
npx servein the project folder) and open it. - DevTools → Application → Service Workers: it must say activated and running.
- Cache Storage:
nomada-shell-v3must be there with all the resources. - Network → tick Offline.
- Press F5. The application starts, with the board from
localStorage(07-01). - Mark a task as done: it gets queued.
- Untick Offline: the change is sent and the WebSocket reconnects with the backoff from 07-04.
That step 5 is the moment when Nómada Tasks stops being a website and becomes an application.
Common Mistakes and Tips
- Putting
sw.jsinjs/. Its scope would be/js/and it would control nothing. It goes in the root. - Forgetting
event.waitUntil(). The browser can stop the worker before the precache finishes, leaving it half-built. - A 404 in
SHELL_ASSETS.addAllis atomic: one misspelled URL means nothing gets stored. - Intercepting requests that are not
GET. Duplicated or lost data. - Always calling
skipWaiting(). The page in progress can be left requesting resources the new cache has already deleted. Notify and let them decide. - Not putting the guard in
controllerchange. An infinite reload loop. - Caching
sw.json the server. Your users freeze on an old version.Cache-Control: no-cache. - Forgetting to delete old caches. Versions pile up until the quota runs out.
- Storing responses without checking
response.ok. A cached 500 gets served as if it were good. - Forgetting
clone().TypeError: body stream already read, the same one from 07-02. - Believing that
navigator.onLine === truemeans there is Internet. It only says there is a network interface. - Confusing the first visit with being controlled. The worker does not control the page it was registered on unless you use
clients.claim(). - Testing without switching Offline on. An offline mode that has never been tested does not work; it is a law.
- Tip:
Update on reloadwhile you develop. It saves hours. - Tip: bump
VERSIONon every deployment. It is what triggers the install cycle and the cleanup. - Tip: register the worker only in production while you are building the application.
- Tip: run Lighthouse (DevTools → Lighthouse → Progressive Web App). It tells you exactly what is missing.
- Tip: do not ask to install or to notify the moment someone arrives. Wait for the user to show interest.
Exercises
Exercise 1 — Data cache with expiry.
The networkFirst strategy stores API responses with no age limit, and a board from three days ago is worse than none. Write cacheWithExpiry(request, maxAgeMs) that, when storing, adds an X-Cached-At header with Date.now(), and when retrieving from the cache checks that stamp: if the response is older than maxAgeMs, it deletes it and throws instead of returning it. Add a clearStale(cacheName, maxAgeMs) function that runs in activate.
Exercise 2 — Accessible new-version notice.
Write watchForUpdates(registration, { onNewVersion }) that detects a new service worker in the installed state (telling an update apart from a first installation), calls onNewVersion with an apply() function, checks for updates when the tab becomes visible again and every 60 minutes, and handles controllerchange with the guard against loops. The notice in the HTML must use role="status" and aria-live="polite", offer "Update now" and "Later", and remember the dismissal for the session with sessionStorage.
Exercise 3 — Auditing the precache.
The SHELL_ASSETS list falls out of sync the moment somebody adds a module. Write a Node script audit-shell.js that reads every .js file under js/, extracts their paths, compares them with the list declared in sw.js and reports the ones missing and the ones left over, exiting with code 1 if there are any differences. Do not use external libraries: node:fs and regular expressions are enough.
Solutions
Solution 1
const DATE_HEADER = 'X-Cached-At';
async function saveWithStamp(cacheName, request, response) {
const headers = new Headers(response.headers);
headers.set(DATE_HEADER, String(Date.now()));
// The Response has to be rebuilt: its headers are immutable
const stamped = new Response(await response.clone().blob(), {
status: response.status,
statusText: response.statusText,
headers
});
const cache = await caches.open(cacheName);
await cache.put(request, stamped);
}
function isStale(response, maxAgeMs) {
const stamp = Number(response.headers.get(DATE_HEADER));
if (!Number.isFinite(stamp)) return true; // no stamp: we treat it as stale
return Date.now() - stamp > maxAgeMs;
}
async function cacheWithExpiry(request, maxAgeMs = 3600000) {
try {
const response = await fetch(request);
if (response.ok) await saveWithStamp(CACHE_DATA, request, response);
return response;
} catch (error) {
const cache = await caches.open(CACHE_DATA);
const stored = await cache.match(request);
if (!stored) throw error;
if (isStale(stored, maxAgeMs)) {
await cache.delete(request);
throw new Error('Cached copy too old');
}
return stored;
}
}
async function clearStale(cacheName, maxAgeMs) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
await Promise.all(requests.map(async (request) => {
const response = await cache.match(request);
if (response && isStale(response, maxAgeMs)) await cache.delete(request);
}));
}The detail you have to discover the hard way: the headers of a Response are immutable. You cannot do response.headers.set(...); you have to construct a new Response with the modified headers, and for that you first read the body of the clone.
Solution 2
const DISMISS_KEY = 'nomada:update-dismissed';
export function watchForUpdates(registration, { onNewVersion }) {
let reloading = false;
function evaluate(worker) {
if (worker.state !== 'installed') return;
if (!navigator.serviceWorker.controller) return; // first installation, not an update
if (sessionStorage.getItem(DISMISS_KEY) === 'yes') return;
onNewVersion(() => worker.postMessage({ type: 'skip-waiting' }));
}
// A worker that was already waiting when we loaded
if (registration.waiting && navigator.serviceWorker.controller) evaluate(registration.waiting);
registration.addEventListener('updatefound', () => {
const incoming = registration.installing;
incoming?.addEventListener('statechange', () => evaluate(incoming));
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return; // ← guard against the infinite loop
reloading = true;
window.location.reload();
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') registration.update().catch(() => {});
});
setInterval(() => registration.update().catch(() => {}), 3600000);
}<div id="version-notice" role="status" aria-live="polite" hidden>
<p>There is a new version of Nómada Tasks.</p>
<button type="button" id="version-update">Update now</button>
<button type="button" id="version-later">Later</button>
</div>watchForUpdates(registration, {
onNewVersion(apply) {
const notice = document.querySelector('#version-notice');
notice.hidden = false;
document.querySelector('#version-update')
.addEventListener('click', apply, { once: true });
document.querySelector('#version-later').addEventListener('click', () => {
sessionStorage.setItem(DISMISS_KEY, 'yes'); // do not insist during this session
notice.hidden = true;
}, { once: true });
}
});The initial registration.waiting check is the one everybody forgets: if the user opens the application and there was already a worker waiting from an earlier visit, the updatefound event will not fire again and the notice would never appear.
Solution 3
// audit-shell.js — run with: node audit-shell.js
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
const ROOT = process.cwd();
/** Walks a directory recursively returning the paths of the .js files */
function listJs(dir) {
const output = [];
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
if (statSync(path).isDirectory()) output.push(...listJs(path));
else if (entry.endsWith('.js')) output.push('/' + relative(ROOT, path).replaceAll('\\', '/'));
}
return output;
}
const onDisk = new Set(listJs(join(ROOT, 'js')));
// Extracts the quoted strings from the SHELL_ASSETS list
const sw = readFileSync(join(ROOT, 'sw.js'), 'utf8');
const block = sw.match(/const SHELL_ASSETS\s*=\s*\[([\s\S]*?)\]/)?.[1] ?? '';
const declared = new Set([...block.matchAll(/'([^']+)'/g)].map((m) => m[1]));
const missing = [...onDisk].filter((r) => !declared.has(r));
const extra = [...declared].filter((r) => r.startsWith('/js/') && !onDisk.has(r));
if (missing.length) console.error('❌ Missing from SHELL_ASSETS:\n ' + missing.join('\n '));
if (extra.length) console.error('❌ Declared but non-existent:\n ' + extra.join('\n '));
if (missing.length === 0 && extra.length === 0) {
console.log(`✅ The precache is up to date (${onDisk.size} modules).`);
process.exit(0);
}
process.exit(1);The exit code 1 is what makes this script genuinely useful: it can be hooked into an npm run build or into continuous integration, and the deployment fails if somebody added a module and forgot the precache. It is also a good illustration of why bundlers exist: they generate this list on their own, and you will see it in 09-05.
Conclusion
Nómada Tasks now works offline and can be installed on the device. You know what a PWA is —not a technology, but a set of capabilities on top of HTML, CSS and JavaScript— and its three requirements: HTTPS (with localhost as the development exception), a manifest and a service worker. And you understand the idea that holds it all up: the service worker is a proxy that lives outside the page, on its own thread, that survives the tab being closed, intercepts every request in its scope and can be stopped at any moment —hence never keeping state in its global variables.
You have mastered its life cycle: install to precache the app shell, the waiting phase that protects open tabs and explains why your new worker "does not step in", activate to clean up old versions, and fetch to serve every request; with event.waitUntil() as the compulsory piece that stops the browser halting the worker halfway. You know that the scope is determined by the file's location, and that this is why sw.js goes in the root. You know why it has no DOM (it belongs to no page) and no localStorage (it is synchronous), and that the alternatives are the Cache API and IndexedDB, with postMessage and clients to talk to the tabs.
You handle the Cache API with its traps —atomic addAll, put that does not check the status, the compulsory clone()— and, above all, you know how to choose a strategy by resource type: cache-first for the shell, network-first for the API with the cache as a safety net, stale-while-revalidate for the non-critical parts, network-only for writes. The fetch handler you have written is a router, not a single rule, and it respects the three norms: synchronous respondWith, never intercept anything that is not GET, and always return something. With that, plus the fallback page, the change queue in IndexedDB and the online/offline events —remembering that navigator.onLine only tells you there is a network interface, not Internet— Nómada Tasks starts up in the screen-printing storeroom and sends what is pending on its own when the signal comes back.
And you have solved the two operational problems that sink badly built PWAs: the manifest with its 192 and 512 pixel icons, the maskable one, the short short_name and the controlled beforeinstallprompt so as not to ask for installation the moment someone arrives; and the update, with the notify-and-let-them-decide pattern, the registration.waiting check that almost everybody forgets, the guard against the reload loop in controllerchange, and the deployment rule that avoids disaster: sw.js is never cached on the server. Add Update on reload, Bypass for network and Clear site data, and you now know how to get out of the cache trap during development instead of suffering it.
With this, the application has its big capabilities covered: it remembers (07-01), it talks to a server (07-02), it stands up to network failures (07-03), it syncs live (07-04) and it works offline and installed (07-05). What is left are the medium-sized pieces that separate a correct application from one that is a pleasure to use: loading cards only when they appear on screen, reacting to size changes, copying the board summary to the clipboard with one click, making the filters linkable and able to survive a reload, respecting Marta's preference for dark mode or Lucía's request for less animation, animating without stutter, and formatting dates and times properly instead of by hand —that readableDate function with its hand-written array of months is crying out for a rewrite. All of that comes from browser APIs that are already there, waiting: Essential Browser APIs.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
