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

  1. What a PWA is
  2. The three requirements
  3. The service worker: a proxy that lives outside the page
  4. Registration and scope
  5. The life cycle: install, waiting, activate, fetch
  6. Why it has no DOM and no localStorage
  7. The Cache API
  8. Precaching the app shell in install
  9. Cleaning up old versions in activate
  10. The network strategies
  11. Intercepting with fetch: the service worker's router
  12. Working offline: fallback and change queue
  13. The manifest and installation
  14. Updating without leaving anybody behind
  15. Debugging in DevTools
  16. Push notifications, briefly
  17. Nómada Tasks: the complete sw.js
  18. Common Mistakes and Tips
  19. Exercises
  20. Conclusion

  1. 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.

  1. 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.

  1. 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 fetch event.
  • It has no access to the DOM. There is no document, no window, 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.

  1. 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-Allowed header, 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.

  1. The life cycle: install, waiting, activate, fetch

The 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.

  1. Why it has no DOM and no localStorage

Inside 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 localStorage because 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);
}

  1. 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:

  • addAll is 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.
  • put does not check the status. It will happily store a 404 or a 500 response. Check response.ok first.
  • 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.

  1. Precaching the app shell in install

The 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.

  1. Cleaning up old versions in activate

Every 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 the localStorage.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.

  1. 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.

  1. Intercepting with fetch: the service worker's router

The 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 cannot await before deciding whether to respond; you call respondWith first 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 duplicated POST creates phantom data. And in 07-03 you already learned how expensive repeating a POST is.
  • Always return something. If the respondWith promise rejects, the browser shows a generic network error. It is better to respond with a fallback page or a fallback JSON.

  1. 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.

  1. 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.

  1. 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.

  1. 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
StorageClear 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:

  1. Tick Update on reload and Disable cache in DevTools.
  2. Bump the VERSION constant in sw.js on every change to the resources.
  3. 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');
}

  1. 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: true is 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.

  1. Nómada Tasks: the complete 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:

  1. Serve the site (npx serve in the project folder) and open it.
  2. DevTools → Application → Service Workers: it must say activated and running.
  3. Cache Storage: nomada-shell-v3 must be there with all the resources.
  4. Network → tick Offline.
  5. Press F5. The application starts, with the board from localStorage (07-01).
  6. Mark a task as done: it gets queued.
  7. 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.js in js/. 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. addAll is 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.js on 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 === true means 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 reload while you develop. It saves hours.
  • Tip: bump VERSION on 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

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved