Skip to content

Tutorial: Build Your First PWA

This tutorial builds Pocket Notes, a complete Progressive Web App with no framework and no build step. Notes live in IndexedDB, a service worker precaches the app shell so the app launches with no network, a web app manifest makes it installable, and a toast tells users when a new version is ready. Every file appears in full, and all of them were run together end to end in headless Chrome 153: installability check, offline launch, the update flow and a deployment behind a redirecting host. Each step also explains the platform behavior underneath, from the maskable icon safe zone to why a cached redirect breaks offline navigation, so you can apply the same patterns to your own site.

Key takeaways

  • A PWA is a website with a secure origin, a web app manifest and, for offline support, a service worker. Chromium no longer needs a service worker to consider a site installable, but nothing launches offline without one.
  • Keep user data in IndexedDB and resolve writes on the transaction's complete event. Keep the app shell in a versioned precache that install fills atomically and activate cleans up.
  • Serve navigations from the precache with ignoreSearch so start_url and shortcut query strings match. Never answer a navigation with a redirected response: the browser turns it into a network error.
  • Let updates wait, announce them with a toast, and call skipWaiting() only when the user agrees. Reload on controllerchange, but not on the first install's clients.claim().
  • Capture beforeinstallprompt synchronously at startup to drive a custom install button in Chromium browsers. Safari and Firefox never fire it, so show instructions there instead.
  • localhost is a secure context, so local development needs no certificates. In production, serve sw.js with Cache-Control: no-cache, and add the manifest MIME type yourself on nginx and Apache.

What you will build

Pocket Notes is small enough to read in one sitting and complete enough to ship. It has everything a production PWA needs and nothing else:

  • Notes CRUD. You can create, edit, delete and filter notes. Every note is an IndexedDB record, so data survives restarts and works offline.
  • Offline launch. The HTML, CSS, JavaScript, manifest and icons are precached at install time. Cold starts with no connection behave exactly like online starts.
  • An offline fallback page. Navigations to pages that were never cached get a styled offline.html instead of the browser's error page.
  • Installability everywhere. A custom Install app button appears in Chrome, Edge and Samsung Internet. iOS and iPadOS users get a one-time hint that explains Share > Add to Home Screen. Safari on macOS and Firefox for Android can install through their own menus.
  • An app shortcut. Long-pressing (Android) or right-clicking (desktop) the installed icon offers New note.
  • Safe updates. A new deployment installs in the background and waits. A toast offers to reload. Unsaved text survives that reload.
  • Multi-tab consistency. A BroadcastChannel keeps every open window's list in sync.

The architecture has three browser storage areas and one worker:

flowchart LR
    subgraph PAGE["Page: index.html"]
        APP["app.js: UI and notes CRUD"]
        DB["db.js: IndexedDB wrapper"]
        PWA["pwa.js: registration, updates, install UI"]
    end
    SW["sw.js: service worker"]
    IDB[("IndexedDB: pocket-notes")]
    CS[("Cache Storage: precache and runtime")]
    SS[("sessionStorage: unsaved draft")]
    NET["Static host"]
    APP --> DB --> IDB
    APP --> SS
    APP --> PWA
    PWA -- "register and postMessage" --> SW
    PAGE -- "every same-origin request" --> SW
    SW --> CS
    SW -- "cache miss" --> NET

The split between the two big stores is the most important design decision in any offline-capable app. Cache Storage holds code: files that change only when you deploy, keyed by URL, and replaced wholesale on each version. IndexedDB holds data: records the user creates, which must never be deleted by a deployment. The Core Building Blocks page explains where each browser primitive fits, and Offline-First Data & Sync covers syncing data to a server, which this tutorial deliberately leaves out.

Before you start

You need:

  • Node.js 20.9 or later for the dev tooling. sharp 0.35, which renders the icons, declares node >= 20.9.0 in its package metadata. If you only want to serve the finished files, Python 3 works too.
  • Chrome or Edge for DevTools. The PWA panels in Chromium DevTools are the most complete, and Chromium is the only engine that fires beforeinstallprompt.
  • Optionally, an Android phone (USB debugging) or an iPhone or iPad to try the real install flows.

You don't need a framework, a bundler, TypeScript or a certificate. Everything runs as native ES modules. The app uses crypto.randomUUID(), BroadcastChannel, the <template> element and replaceChildren(), which every current browser supports. The browser support table near the end lists the versions.

If you're new to the moving parts, skim What Is a PWA? first. If you want to know exactly which conditions each browser checks before offering installation, Installability Criteria is the companion to this page.

Step 1: Create the project structure

Create this layout. Everything under public/ is what you deploy, and nothing outside it ever reaches users:

Project layout
pocket-notes/
├── package.json              # dev tooling only: serve, sharp, puppeteer
├── icon-src/
│   ├── source.svg            # "any" icon artwork (rounded tile, transparent corners)
│   └── maskable.svg          # full-bleed artwork for maskable icons
├── tools/
│   ├── make-icons.mjs        # renders every PNG icon from icon-src/
│   ├── stamp-version.mjs     # writes a content hash into sw.js before a deploy
│   └── smoke-test.mjs        # automated installability and offline checks
└── public/                   # the deployable app: publish this directory
    ├── index.html
    ├── offline.html
    ├── styles.css
    ├── app.js
    ├── db.js
    ├── pwa.js
    ├── sw.js
    ├── manifest.webmanifest
    ├── serve.json            # local dev server configuration
    ├── _headers              # response headers for Netlify and Cloudflare Pages
    ├── icons/                # generated by tools/make-icons.mjs
    └── screenshots/          # wide.png (1280x800) and narrow.png (750x1334)

Two placement rules matter here.

sw.js sits at the root of the deployed directory. A service worker's maximum scope is the directory its script is served from. A worker at /js/sw.js can control /js/ and below, but never /index.html, unless the server sends a Service-Worker-Allowed header that widens it. Putting sw.js next to index.html gives it control of the whole app with no special headers. Registration & Scope walks through the scope algorithm.

Tooling stays outside public/. Hosting providers publish a directory. If that directory were the project root, node_modules/, the icon sources and your scripts would all become public URLs, and a /* cache rule would apply to them too.

Create the folders and package.json:

Terminal
mkdir -p pocket-notes/{icon-src,tools,public/icons,public/screenshots}
cd pocket-notes
package.json
{
  "name": "pocket-notes",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "serve public",
    "icons": "node tools/make-icons.mjs",
    "stamp": "node tools/stamp-version.mjs",
    "smoke": "node tools/smoke-test.mjs http://localhost:3000/"
  },
  "devDependencies": {
    "puppeteer": "^25.12.0",
    "serve": "^14.2.6",
    "sharp": "^0.35.4"
  }
}

Then install the dev dependencies:

Terminal
npm install

Each dependency has one job. serve is a static file server for local development, sharp renders SVG artwork to PNG icons, and puppeteer drives Chrome for the automated checks in Step 11. Puppeteer downloads its own copy of Chrome for Testing during installation. If you don't want that download, drop it from package.json and skip the automated check; nothing else depends on it. None of these packages ship to users.

Step 2: Write index.html and its head tags

The page is plain, semantic HTML. It works as a normal website before any of the PWA pieces exist, which is the point of progressive enhancement: the manifest and service worker add capabilities, but the app never depends on them to render.

public/index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <!-- viewport-fit=cover lets the layout extend under notches; CSS adds safe-area padding back -->
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>Pocket Notes</title>
  <meta name="description" content="Offline-first notes that live on your device.">

  <!-- Web app manifest: identity, icons, display mode, shortcuts -->
  <link rel="manifest" href="manifest.webmanifest">

  <!-- Browser UI color; the media queries give dark mode its own value -->
  <meta name="theme-color" content="#1f6f5c" media="(prefers-color-scheme: light)">
  <meta name="theme-color" content="#12332b" media="(prefers-color-scheme: dark)">
  <meta name="color-scheme" content="light dark">

  <!-- Icons: tab favicon (SVG + PNG fallback) and the iOS/iPadOS Home Screen icon -->
  <link rel="icon" href="icons/favicon.svg" type="image/svg+xml">
  <link rel="icon" href="icons/favicon-32.png" sizes="32x32" type="image/png">
  <link rel="apple-touch-icon" href="icons/apple-touch-icon.png">

  <link rel="stylesheet" href="styles.css">
  <!-- Module scripts are deferred: they run after the document is parsed -->
  <script type="module" src="app.js"></script>
</head>
<body>
  <header class="app-bar">
    <h1 class="app-title">Pocket Notes</h1>
    <span id="net-status" class="net-status" role="status" aria-live="polite"></span>
    <button type="button" id="install-button" class="btn btn-install" hidden>Install app</button>
  </header>

  <main class="layout">
    <form id="note-form" class="editor" autocomplete="off">
      <label for="note-text" class="visually-hidden">Note text</label>
      <textarea id="note-text" name="text" rows="4" maxlength="10000" required
                placeholder="Write a note… (Ctrl/⌘ + Enter to save)"></textarea>
      <div class="editor-actions">
        <button type="submit" id="save-button" class="btn btn-primary">Save note</button>
        <button type="button" id="cancel-edit" class="btn" hidden>Cancel</button>
      </div>
    </form>

    <section class="notes" aria-labelledby="notes-heading">
      <div class="list-header">
        <h2 id="notes-heading">Notes <span id="note-count" class="count"></span></h2>
        <input type="search" id="filter" class="filter" placeholder="Filter" aria-label="Filter notes">
      </div>
      <ul id="note-list" class="note-list"></ul>
      <p id="empty-state" class="empty" hidden>
        No notes yet. Notes are stored on this device and work offline.
      </p>
    </section>
  </main>

  <aside id="ios-install-hint" class="ios-hint" hidden>
    <p>Install Pocket Notes: tap <strong>Share</strong> (in Safari’s <strong>⋯</strong> menu), then <strong>Add to Home Screen</strong>.</p>
    <button type="button" id="ios-hint-dismiss" class="btn">Got it</button>
  </aside>

  <div id="update-toast" class="toast" role="alert" hidden>
    <span>A new version of Pocket Notes is ready.</span>
    <button type="button" id="update-reload" class="btn btn-primary">Reload</button>
    <button type="button" id="update-dismiss" class="btn">Later</button>
  </div>

  <template id="note-template">
    <li class="note">
      <p class="note-text"></p>
      <div class="note-meta">
        <time class="note-time"></time>
        <span class="note-actions">
          <button type="button" class="btn btn-small" data-action="edit">Edit</button>
          <button type="button" class="btn btn-small btn-danger" data-action="delete">Delete</button>
        </span>
      </div>
    </li>
  </template>

  <noscript><p class="empty">Pocket Notes needs JavaScript to store notes on your device.</p></noscript>
</body>
</html>

What every head tag does

Tag Purpose Details that bite
<meta charset="utf-8"> Declares the encoding Must appear within the first 1024 bytes of the document.
<meta name="viewport" … viewport-fit=cover> Mobile layout width, plus permission to draw into display cutouts Without viewport-fit=cover, iOS letterboxes the page away from the notch and env(safe-area-inset-*) stays 0. With it, you must add the insets back as padding (Step 3).
<title> Tab title, and the fallback app name Chromium on Android falls back to <title> when an installed page has no manifest name. Keep it identical to the manifest name.
<link rel="manifest"> Points to the web app manifest Link it from every page users might install from. The browser fetches it in CORS mode without cookies. Relative URLs inside the manifest resolve against the manifest's URL, not the page's.
<meta name="theme-color"> (twice, with media) Color of the browser UI and of an installed app's title bar Page-level values override the manifest theme_color, and the media attribute gives dark mode its own color. Chrome and Edge on desktop use the color only for installed apps. According to MDN's compatibility data, Safari 26 on macOS and iOS also uses it only for installed web apps.
<meta name="color-scheme"> Declares light and dark support up front Form controls, scrollbars and the default canvas color follow the user's scheme before CSS loads, which avoids a white flash in dark mode.
<link rel="icon"> (SVG, then PNG) Tab and bookmark icons Browsers that support SVG favicons use the first one. Others fall back to the 32 px PNG.
<link rel="apple-touch-icon"> Home Screen icon on iOS and iPadOS When this link exists, Safari ignores the manifest icons for the Home Screen. Use an opaque, full-bleed 180 × 180 PNG, because iOS applies its own rounded mask.
<link rel="stylesheet"> Styles Precached by the service worker, so the page renders styled offline.
<script type="module" src="app.js"> Loads the app Module scripts are deferred: they run after parsing, before DOMContentLoaded, in strict mode. That's why app.js can query the DOM immediately.
Apple's legacy meta tags, and why this page doesn't use them

Older tutorials add apple-mobile-web-app-capable, apple-mobile-web-app-title and apple-mobile-web-app-status-bar-style. The first one used to be the only way to make a Home Screen icon open without Safari's interface. Since iOS 11.3, a manifest with display: "standalone" does that, and since iOS and iPadOS 26 every site added to the Home Screen opens as a web app by default, whatever the markup says (see Installability Criteria). The title tag only duplicates the manifest's short_name. The status-bar tag still controls how an iOS web app draws behind the status bar. If you want the green app bar to extend under it, the safe-area padding in Step 3 already handles the geometry, but test the result on a device.

The body: structure that survives without JavaScript

A few details in the markup carry weight later:

  • hidden everywhere. The install button, the update toast and the iOS hint all start hidden. Script reveals them only when they apply. Step 3 makes sure CSS can never accidentally show them.
  • role="status" with aria-live="polite" on #net-status makes screen readers announce "Offline" and error messages without stealing focus. The toast uses role="alert", which interrupts, because an update prompt needs a decision.
  • A <template> for note items. Cloning a template is faster than building elements one by one, and it keeps the markup in HTML where designers can edit it.
  • maxlength="10000" and required are the first line of validation. IndexedDB would store a much larger string, but a notes app has no reason to.
  • <noscript> explains why nothing works with JavaScript disabled. Storage on the device needs script.

Step 3: Style the app for browser tabs and standalone windows

An installed PWA runs in two very different environments: a browser tab with an address bar, and a standalone window with no browser interface at all, sometimes drawn under a notch or a home indicator. The stylesheet handles both:

public/styles.css
/* ---------- Design tokens ---------- */
:root {
  color-scheme: light dark;
  --bg: #f7f4ed;
  --surface: #ffffff;
  --text: #1d2521;
  --muted: #5b6761;
  --accent: #1f6f5c;
  --accent-contrast: #ffffff;
  --danger: #b3261e;
  --border: #d9d4c7;
  --radius: 12px;
  --bar-height: 56px;
  /* env() values are 0 in a normal browser tab and non-zero under notches / home indicators */
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-right: env(safe-area-inset-right, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  --safe-left: env(safe-area-inset-left, 0px);
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #101815;
    --surface: #18221e;
    --text: #e6ece9;
    --muted: #9aa8a1;
    --accent: #12332b;
    --accent-contrast: #e6ece9;
    --danger: #ffb4ab;
    --border: #2c3a34;
  }
}

/* ---------- Base ---------- */
*, *::before, *::after { box-sizing: border-box; }

/* The hidden attribute must win over any display value set below */
[hidden] { display: none !important; }

html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }

body {
  margin: 0;
  min-height: 100dvh;
  font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  background: var(--bg);
  color: var(--text);
  padding-left: var(--safe-left);
  padding-right: var(--safe-right);
}

.visually-hidden {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ---------- App bar ---------- */
.app-bar {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: calc(var(--bar-height) + var(--safe-top));
  padding: var(--safe-top) 16px 0;
  background: var(--accent);
  color: var(--accent-contrast);
}
.app-title { flex: 1; margin: 0; font-size: 1.125rem; font-weight: 650; }
.net-status { font-size: 0.8125rem; opacity: 0.9; }

/* ---------- Buttons ---------- */
.btn {
  appearance: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;               /* comfortable touch target */
  padding: 0 16px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
}
.btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast); }
.btn-install { border-color: currentColor; background: transparent; color: inherit; min-height: 36px; }
.btn-small { min-height: 36px; padding: 0 12px; font-size: 0.875rem; }
.btn-danger { color: var(--danger); }
@media (prefers-color-scheme: dark) {
  .btn-primary { background: #5cc2a5; border-color: #5cc2a5; color: #0b1512; }
}

/* ---------- Layout ---------- */
.layout {
  max-width: 720px;
  margin: 0 auto;
  padding: 16px 16px calc(96px + var(--safe-bottom));
}

.editor {
  display: grid;
  gap: 12px;
  padding: 16px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.editor textarea {
  width: 100%;
  min-height: 6.5em;
  padding: 12px;
  resize: vertical;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg);
  color: var(--text);
  font: inherit;
}
.editor-actions { display: flex; gap: 8px; }

.list-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin: 24px 0 8px;
}
.list-header h2 { margin: 0; font-size: 1rem; }
.count { color: var(--muted); font-weight: 400; }
.filter {
  width: min(50%, 220px);
  min-height: 36px;
  padding: 0 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
}

.note-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.note {
  padding: 12px 16px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.note.is-editing { outline: 2px solid var(--accent); }
.note-text { margin: 0 0 8px; white-space: pre-wrap; overflow-wrap: anywhere; }
.note-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.note-time { color: var(--muted); font-size: 0.8125rem; }
.note-actions { display: flex; gap: 4px; }
.empty { color: var(--muted); text-align: center; padding: 32px 16px; }

/* ---------- Toast and iOS hint (fixed to the bottom, above the home indicator) ---------- */
.toast,
.ios-hint {
  position: fixed;
  left: calc(16px + var(--safe-left));
  right: calc(16px + var(--safe-right));
  bottom: calc(16px + var(--safe-bottom));
  z-index: 20;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
  max-width: 560px;
  margin: 0 auto;
  padding: 12px 16px;
  border-radius: var(--radius);
  background: var(--text);
  color: var(--bg);
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
}
.toast span, .ios-hint p { flex: 1 1 200px; margin: 0; }

/* ---------- Installed (standalone) mode tweaks ---------- */
@media (display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui) {
  /* Already installed: never offer installation again */
  .btn-install { display: none !important; }
  /* App chrome should not be selectable like document text */
  .app-bar { -webkit-user-select: none; user-select: none; }
  /* No pull-to-refresh / scroll chaining at the document edge */
  html, body { overscroll-behavior-y: none; }
}

Safe areas: viewport-fit=cover and env()

Phones with notches, rounded corners and gesture bars report the unsafe regions of the screen through four environment variables: safe-area-inset-top, -right, -bottom and -left. They're non-zero only when the page is allowed to draw under those regions, which is what viewport-fit=cover in the viewport tag requests. The stylesheet copies them into custom properties once (with a 0px fallback) and then uses them in three places:

  1. The app bar grows by --safe-top and pads its content down by the same amount. In a standalone iOS app or an Android app drawn edge to edge, the green bar extends behind the status area while the title stays readable.
  2. The body pads left and right, which matters in landscape on notched phones.
  3. The fixed toast and iOS hint sit 16px above --safe-bottom, so the home indicator never covers the Reload button.

env() has worked in every major engine for years (Chrome 69, Firefox 65 and Safari 11.1, according to MDN's compatibility data). In an ordinary desktop tab all four values are 0, so the same CSS is correct everywhere.

Styling the installed app with the display-mode media query

The display-mode media feature matches the display mode the page is actually running in: browser in a tab, and standalone, fullscreen or minimal-ui when installed with those modes. Chromium also supports window-controls-overlay, and newer engines add picture-in-picture. The query in the stylesheet does three things when installed:

  • Hides the install button with !important, because offering to install an installed app is confusing. The script also never reveals it there, since beforeinstallprompt doesn't fire for installed apps, but CSS makes the guarantee unconditional.
  • Disables text selection on the app bar, so a long-press on the title doesn't start a selection the way it would on a document.
  • Sets overscroll-behavior-y: none on the root, which disables pull-to-refresh and the rubber-band bounce at the document edge. Native apps don't refresh when you overscroll, and an accidental reload would throw away unsaved input.

The Display Modes page covers the fallback chain between modes and the matching JavaScript API, matchMedia('(display-mode: standalone)'), which pwa.js uses in Step 9.

Three smaller details

  • [hidden] { display: none !important; } fixes a classic bug. The user-agent stylesheet implements hidden as display: none, but any author rule that sets display, such as .toast { display: flex }, beats it. The toast would then show on page load.
  • min-height: 100dvh uses the dynamic viewport unit, which tracks the visible height as mobile browser toolbars collapse and expand. 100vh would be taller than the visible area in a mobile tab. dvh is supported in Chrome 108, Firefox 101 and Safari 15.4 and later.
  • 44 px touch targets on buttons follow common platform guidance for comfortable tapping. The Accessibility page explains the target-size criteria.

Step 4: Store notes in IndexedDB (db.js)

Notes need storage that is asynchronous, transactional, survives restarts and holds structured objects. IndexedDB is the only web storage API that does all of that. localStorage is synchronous (every read blocks the main thread), stores only strings, and isn't available in workers.

The raw IndexedDB API is event-based and verbose, so db.js wraps it in four promise-returning functions. It has no dependencies. In a larger app you might use a small wrapper library such as idb, but the raw version shows what actually happens:

public/db.js
// db.js — a small promise wrapper around IndexedDB for the "notes" object store.
const DB_NAME = 'pocket-notes';
const DB_VERSION = 1;
const STORE = 'notes';

let dbPromise = null;

/** Opens (and, on first run, creates) the database. The connection is reused. */
function openDatabase() {
  if (dbPromise) return dbPromise;

  dbPromise = new Promise((resolve, reject) => {
    if (!('indexedDB' in globalThis)) {
      reject(new Error('IndexedDB is not available in this browser.'));
      return;
    }

    const request = indexedDB.open(DB_NAME, DB_VERSION);

    // Runs only when the on-disk version is lower than DB_VERSION.
    // Add one `if (oldVersion < N)` block per schema version; never edit old blocks.
    request.onupgradeneeded = (event) => {
      const db = request.result;
      if (event.oldVersion < 1) {
        const store = db.createObjectStore(STORE, { keyPath: 'id' });
        store.createIndex('updatedAt', 'updatedAt');
      }
    };

    request.onsuccess = () => {
      const db = request.result;
      // Another tab (or a new deploy) wants a higher version: close so its upgrade can proceed.
      db.onversionchange = () => {
        db.close();
        dbPromise = null;
      };
      // The browser closed the connection (e.g. the user cleared site data).
      db.onclose = () => {
        dbPromise = null;
      };
      resolve(db);
    };

    request.onerror = () => {
      dbPromise = null;
      reject(request.error);
    };

    // An older connection in another tab has not closed yet; the open stays pending.
    request.onblocked = () => {
      console.warn('[db] Upgrade blocked: close other Pocket Notes tabs.');
    };
  });

  return dbPromise;
}

/**
 * Runs `callback(store)` in a transaction and resolves once the transaction commits.
 * The callback must issue its requests synchronously. If it returns an IDBRequest,
 * that request's result is the resolved value; any other return value is passed through.
 */
async function withStore(mode, callback) {
  const db = await openDatabase();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, mode);
    const returned = callback(tx.objectStore(STORE));
    let result = returned;

    if (returned instanceof IDBRequest) {
      returned.onsuccess = () => {
        result = returned.result;
      };
    }

    // Resolve on "complete", not on request success: only then is the write durable.
    tx.oncomplete = () => resolve(result);
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error ?? new DOMException('Transaction aborted', 'AbortError'));
  });
}

/** All notes, newest first (walks the updatedAt index backwards). */
export function getAllNotes() {
  return withStore('readonly', (store) => {
    const notes = [];
    const cursorRequest = store.index('updatedAt').openCursor(null, 'prev');
    cursorRequest.onsuccess = () => {
      const cursor = cursorRequest.result;
      if (cursor) {
        notes.push(cursor.value);
        cursor.continue();
      }
    };
    return notes; // fully populated by the time the transaction completes
  });
}

export function getNote(id) {
  return withStore('readonly', (store) => store.get(id));
}

/** Inserts or replaces a note; resolves with its key. */
export function putNote(note) {
  return withStore('readwrite', (store) => store.put(note));
}

export function deleteNote(id) {
  return withStore('readwrite', (store) => store.delete(id));
}

Each note is a plain object: { id, text, createdAt, updatedAt }. The store uses id as its key (keyPath: 'id'), and an index on updatedAt gives a newest-first listing without sorting in JavaScript.

Why the promise resolves on complete, not on success

A request's success event only means that operation succeeded inside a transaction that can still fail. The transaction commits after its last request, and the commit itself can fail, for example with a QuotaExceededError when the origin runs out of space. That failure aborts the whole transaction and rolls back every write in it. withStore() therefore captures the request's result on success but resolves the promise only on the transaction's complete event. When putNote() resolves, the note is committed.

The same design explains the rule in the comment: the callback must issue its requests synchronously. A transaction commits automatically when control returns to the event loop with no pending requests. If you await anything unrelated inside the callback (a fetch(), a timer), the transaction has already committed by the time your code resumes, and the next request throws TransactionInactiveError. getAllNotes() stays within the rule by continuing the cursor from its own success callback, which keeps the transaction alive until the cursor finishes.

Schema versioning with onupgradeneeded

indexedDB.open(name, version) compares the requested version with the one on disk. When the requested version is higher, or the database doesn't exist, it fires upgradeneeded in a special versionchange transaction. That's the only place where you can create or delete object stores and indexes. The if (event.oldVersion < 1) pattern scales: when version 2 adds a tags index, you add an if (event.oldVersion < 2) block below the first one. A user upgrading from version 0, 1 or 2 runs exactly the blocks they need, in order. Never edit an old block, because users who already ran it will never run it again.

versionchange, blocked and close

Schema upgrades collide with other open tabs. If tab A has version 1 open and a newly deployed tab B opens version 2, the browser fires versionchange on A's connection. db.js responds by closing that connection and clearing the cached promise, so A's next call reopens the database at the new version. If a connection doesn't close, B's open request fires blocked and waits. onblocked logs a warning here; a production app might show "Close other tabs to finish updating". The close handler covers the rarer case where the browser closes the connection itself, for example when the user clears site data.

The IndexedDB page covers indexes, key ranges, cursors, durability hints and performance in depth.

Step 5: Write the UI logic (app.js)

app.js is the only module the HTML loads directly. It renders the list, handles the form and wires everything else together:

public/app.js
// app.js — UI logic: render, create, edit, delete and filter notes.
import { getAllNotes, getNote, putNote, deleteNote } from './db.js';
import { initPwa } from './pwa.js';

const form = document.querySelector('#note-form');
const textarea = document.querySelector('#note-text');
const saveButton = document.querySelector('#save-button');
const cancelButton = document.querySelector('#cancel-edit');
const list = document.querySelector('#note-list');
const template = document.querySelector('#note-template');
const emptyState = document.querySelector('#empty-state');
const countLabel = document.querySelector('#note-count');
const filterInput = document.querySelector('#filter');
const netStatus = document.querySelector('#net-status');

const DRAFT_KEY = 'pocket-notes:draft';
const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });

let notes = [];          // in-memory copy of the store, newest first
let editingId = null;    // id of the note being edited, or null when creating

// Tell other open tabs/windows to reload their list after a write.
const channel = 'BroadcastChannel' in self ? new BroadcastChannel('pocket-notes') : null;
channel?.addEventListener('message', (event) => {
  if (event.data === 'notes-changed') refresh();
});

// ---------- Rendering ----------

function render() {
  const query = filterInput.value.trim().toLowerCase();
  const visible = query ? notes.filter((n) => n.text.toLowerCase().includes(query)) : notes;

  // Build off-DOM, then swap in one operation.
  const fragment = document.createDocumentFragment();
  for (const note of visible) {
    const item = template.content.firstElementChild.cloneNode(true);
    item.dataset.id = note.id;
    item.classList.toggle('is-editing', note.id === editingId);
    item.querySelector('.note-text').textContent = note.text; // textContent: never parse user input as HTML
    const time = item.querySelector('.note-time');
    time.dateTime = new Date(note.updatedAt).toISOString();
    time.textContent = dateFormat.format(note.updatedAt);
    fragment.append(item);
  }
  list.replaceChildren(fragment);

  countLabel.textContent = notes.length ? `(${notes.length})` : '';
  emptyState.hidden = notes.length > 0;
}

async function refresh() {
  try {
    notes = await getAllNotes();
    render();
  } catch (error) {
    showError('Could not load notes', error);
  }
}

// ---------- Editing ----------

function startEditing(note) {
  editingId = note.id;
  textarea.value = note.text;
  saveButton.textContent = 'Update note';
  cancelButton.hidden = false;
  textarea.focus();
  render();
}

function stopEditing() {
  editingId = null;
  form.reset();
  saveButton.textContent = 'Save note';
  cancelButton.hidden = true;
  clearDraft();
  render();
}

async function saveCurrentNote() {
  const text = textarea.value.trim();
  if (!text) return;

  const now = Date.now();
  try {
    if (editingId) {
      const existing = await getNote(editingId);
      // The note may have been deleted in another tab meanwhile: recreate it.
      await putNote({ ...(existing ?? { id: editingId, createdAt: now }), text, updatedAt: now });
    } else {
      // crypto.randomUUID() requires a secure context, which a PWA always has.
      await putNote({ id: crypto.randomUUID(), text, createdAt: now, updatedAt: now });
    }
    stopEditing();
    await refresh();
    channel?.postMessage('notes-changed');
    requestPersistentStorage();
  } catch (error) {
    showError('Could not save the note', error);
  }
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  saveCurrentNote();
});

// Ctrl+Enter / Cmd+Enter saves from inside the textarea.
textarea.addEventListener('keydown', (event) => {
  if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
    event.preventDefault();
    saveCurrentNote();
  }
});

cancelButton.addEventListener('click', stopEditing);
filterInput.addEventListener('input', render);

// One delegated listener handles Edit/Delete for every note.
list.addEventListener('click', async (event) => {
  const button = event.target.closest('button[data-action]');
  if (!button) return;
  const id = button.closest('.note').dataset.id;
  const note = notes.find((n) => n.id === id);
  if (!note) return;

  if (button.dataset.action === 'edit') {
    startEditing(note);
  } else if (button.dataset.action === 'delete') {
    if (!confirm('Delete this note?')) return;
    try {
      await deleteNote(id);
      if (editingId === id) stopEditing();
      await refresh();
      channel?.postMessage('notes-changed');
    } catch (error) {
      showError('Could not delete the note', error);
    }
  }
});

// ---------- Draft autosave (survives the reload after an app update) ----------

function saveDraft() {
  try {
    sessionStorage.setItem(DRAFT_KEY, JSON.stringify({ text: textarea.value, editingId }));
  } catch { /* storage can be unavailable (e.g. blocked); drafts are best-effort */ }
}

function clearDraft() {
  try { sessionStorage.removeItem(DRAFT_KEY); } catch { /* ignore */ }
}

function restoreDraft() {
  try {
    const draft = JSON.parse(sessionStorage.getItem(DRAFT_KEY) ?? 'null');
    if (draft?.text) {
      textarea.value = draft.text;
      if (draft.editingId) {
        editingId = draft.editingId;
        saveButton.textContent = 'Update note';
        cancelButton.hidden = false;
      }
    }
  } catch { /* ignore malformed drafts */ }
}

textarea.addEventListener('input', saveDraft);

// ---------- Status, errors, storage ----------

function updateNetworkStatus() {
  // navigator.onLine === false is reliable; true only means "some network exists".
  netStatus.textContent = navigator.onLine ? '' : 'Offline';
}
window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);

function showError(message, error) {
  console.error(`[app] ${message}:`, error);
  netStatus.textContent = `${message}.`;
}

let persistenceRequested = false;
async function requestPersistentStorage() {
  // Ask once, after the user has created data worth protecting from eviction.
  if (persistenceRequested || !navigator.storage?.persist) return;
  persistenceRequested = true;
  try {
    const persisted = (await navigator.storage.persisted()) || (await navigator.storage.persist());
    console.info(`[app] persistent storage: ${persisted ? 'granted' : 'not granted'}`);
  } catch (error) {
    console.warn('[app] storage.persist() failed:', error);
  }
}

// ---------- Launch handling ----------

function handleLaunchParams() {
  // The "New note" app shortcut launches ./?action=new&source=shortcut
  const url = new URL(location.href);
  if (url.searchParams.get('action') !== 'new') return;

  // Consume the parameter: location.reload() after an update keeps the URL, and
  // re-running the action would wipe the draft that restoreDraft() just brought back.
  url.searchParams.delete('action');
  history.replaceState(history.state, '', url);

  if (!textarea.value) stopEditing(); // never discard unsaved text
  textarea.focus();
}

// ---------- Boot ----------

// First, synchronously: beforeinstallprompt can fire right after the load event,
// so its listener must be attached before this module does any async work.
initPwa();
updateNetworkStatus();
restoreDraft();
refresh().then(handleLaunchParams);

app.js imports initPwa() from pwa.js, which you write in Step 9. A module graph fails as a whole if one import 404s, so create a stub now to try the app right away:

public/pwa.js (temporary stub, replaced in Step 9)
// app.js imports initPwa(), so this module must exist before Step 9.
export function initPwa() {}

Start the dev server with npm start (or npx serve public) and open http://localhost:3000. You can add, edit, filter and delete notes, and they survive a reload. The app doesn't work offline yet, and the browser doesn't offer to install it.

Rendering without a framework

render() rebuilds the list from the in-memory notes array on every change. It clones the <template> for each note, fills it with textContent (never innerHTML, so a note containing <img onerror=…> is displayed, not executed), collects the items in a DocumentFragment, and swaps them in with one replaceChildren() call. For a few hundred notes, that's comfortably fast. For thousands you'd add virtualization, which Runtime Performance discusses.

Edit and Delete use event delegation: one click listener on the <ul> finds the nearest button[data-action], so re-rendering never needs to re-attach listeners.

IDs, cross-tab sync and drafts

  • crypto.randomUUID() generates collision-free IDs on the device, with no server round trip. It's available only in secure contexts, which a PWA always is.
  • BroadcastChannel('pocket-notes') connects every same-origin window, tab and installed app window. After each write, the writer posts notes-changed and the other contexts re-read IndexedDB. A BroadcastChannel never delivers a message to the object that posted it, so the writer doesn't refresh twice.
  • The draft in sessionStorage is saved on every keystroke. sessionStorage is per tab, synchronous and survives a reload of that tab, which is exactly the lifetime of the "new version available, reload?" flow in Step 9. It's gone when the tab closes, which is also right for an unsaved draft.

Asking for persistent storage

By default, browser storage is best-effort: under storage pressure, the browser may evict an origin's data without asking. navigator.storage.persist() asks for the persistent bucket mode, which is exempt from automatic eviction. app.js requests it once, after the first note is saved, because that's when there is something worth protecting and when the request makes sense to a user.

What happens next depends on the browser. According to MDN, Firefox shows a permission prompt, while Chromium-based browsers and Safari decide automatically, based on how the user has interacted with the site, and never prompt. In headless Chrome during testing, the request came back not granted; an installed app in a normal profile is a much stronger signal. Safari has a separate rule that matters even more: script-writable storage for a site with no user interaction in the last seven days of browser use can be deleted, unless the site was added to the Home Screen or the Dock. On iOS, installation is the storage-durability story. Storage Quotas & Persistence has the full per-browser quota and eviction rules.

Launch parameters and boot order

When the app starts from the New note shortcut, the URL is ./?action=new&source=shortcut. handleLaunchParams() reads action=new and focuses the editor. It then consumes the parameter with history.replaceState(). That matters because location.reload(), which the update flow in Step 9 uses, reloads the current URL: without the cleanup, a user who launched from the shortcut, typed a note and accepted an update would get the action a second time, and stopEditing() would clear the draft that restoreDraft() had just restored. For the same reason, the function only resets the editor when it is empty. The source parameters stay in the URL; they're for analytics, so you can tell launches from the home screen, a shortcut and a browser tab apart.

The last four lines matter more than they look. initPwa() runs first and synchronously attaches the beforeinstallprompt listener. Chromium can dispatch that event almost immediately after the load event: less than 10 ms later in repeated tests of this app on localhost. If the listener were attached after an await, it could miss the event, and the install button would never appear.

Step 6: Generate the icons, including maskable ones

An installable app needs more icons than a website: launcher icons at several densities, a separate maskable variant that platforms can crop into circles and squircles, an Apple touch icon, a favicon and a shortcut icon. Drawing them by hand invites mistakes, so this project keeps two vector sources and renders everything else from them.

Two sources: any and maskable

The any artwork is what most people picture as an app icon: a rounded tile with transparent corners. Browsers use it as-is in install dialogs, the Windows taskbar, the macOS Dock and ChromeOS shelf:

icon-src/source.svg
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
  <!-- "any" icon: a rounded tile with transparent corners -->
  <rect width="512" height="512" rx="112" fill="#1f6f5c"/>
  <g id="glyph">
    <path d="M152 112h160l72 72v200a24 24 0 0 1-24 24H152a24 24 0 0 1-24-24V136a24 24 0 0 1 24-24z" fill="#f7f4ed"/>
    <path d="M312 112v48a24 24 0 0 0 24 24h48z" fill="#c9dcd5"/>
    <rect x="172" y="228" width="168" height="20" rx="10" fill="#1f6f5c"/>
    <rect x="172" y="280" width="168" height="20" rx="10" fill="#1f6f5c" opacity=".7"/>
    <rect x="172" y="332" width="112" height="20" rx="10" fill="#1f6f5c" opacity=".45"/>
  </g>
</svg>

The maskable artwork fills the whole square with the background color and shrinks the glyph by 10% so it fits in the safe zone:

icon-src/maskable.svg
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
  <!-- Maskable icon: full-bleed background, artwork inside the 80% safe zone -->
  <rect width="512" height="512" fill="#1f6f5c"/>
  <g transform="translate(256 260) scale(0.9) translate(-256 -260)">
    <path d="M152 112h160l72 72v200a24 24 0 0 1-24 24H152a24 24 0 0 1-24-24V136a24 24 0 0 1 24-24z" fill="#f7f4ed"/>
    <path d="M312 112v48a24 24 0 0 0 24 24h48z" fill="#c9dcd5"/>
    <rect x="172" y="228" width="168" height="20" rx="10" fill="#1f6f5c"/>
    <rect x="172" y="280" width="168" height="20" rx="10" fill="#1f6f5c" opacity=".7"/>
    <rect x="172" y="332" width="112" height="20" rx="10" fill="#1f6f5c" opacity=".45"/>
  </g>
</svg>

The maskable safe zone, with the math

Android launchers and some other surfaces apply their own mask shape to icons marked purpose: "maskable": a circle on one device, a rounded square or a teardrop on another. The manifest specification defines the safe zone, the area guaranteed to survive any mask, as "a circle with center point in the center of the icon and with a radius of ⅖ (40%) of the icon size". Everything outside it may be cropped, and the user agent may also scale the icon.

For the 512 × 512 canvas, the safe zone is a circle of radius 204.8 px around (256, 256). The glyph's bounding box in source.svg runs from (128, 112) to (384, 408). After maskable.svg scales it by 0.9 around (256, 260), the box becomes (140.8, 126.8) to (371.2, 393.2). The farthest corners are 115.2 px across and 137.2 px down from the center, a distance of √(115.2² + 137.2²) ≈ 179.2 px. That's about 25 px inside the 204.8 px limit, so no mask can clip the page. The corner of the page glyph is the part to watch; round logos can be larger than square ones.

Keep the two variants as separate icon entries. A single image marked "purpose": "any maskable" can't be right in both roles: artwork padded for masking looks too small when used unmasked, and a tight any icon gets its corners cut off when masked. Chromium also requires at least one any icon for installability. A manifest with only maskable icons fails the check (see Installability Criteria).

Rendering the PNGs with sharp

tools/make-icons.mjs
// tools/make-icons.mjs — rasterize the two source SVGs into every PNG the app needs.
// Usage: npm run icons   (requires the sharp dev dependency)
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import sharp from 'sharp';

const SRC = new URL('../icon-src/', import.meta.url);
const OUT = new URL('../public/icons/', import.meta.url);

// [source SVG, output file, size in px, flatten onto an opaque background?]
const JOBS = [
  ['source.svg', 'favicon-32.png', 32, false],
  ['source.svg', 'icon-192.png', 192, false],
  ['source.svg', 'icon-512.png', 512, false],
  ['maskable.svg', 'maskable-192.png', 192, true],
  ['maskable.svg', 'maskable-512.png', 512, true],
  // iOS applies its own rounded mask and can render transparent pixels black: use full-bleed art.
  ['maskable.svg', 'apple-touch-icon.png', 180, true],
  ['maskable.svg', 'shortcut-new-96.png', 96, true],
];

await mkdir(OUT, { recursive: true });

for (const [source, output, size, opaque] of JOBS) {
  const svg = await readFile(new URL(source, SRC));
  // The SVGs are drawn on a 512x512 canvas, so every PNG is a downscale, never an upscale.
  let image = sharp(svg).resize(size, size);
  if (opaque) image = image.flatten({ background: '#1f6f5c' });
  await writeFile(new URL(output, OUT), await image.png({ compressionLevel: 9 }).toBuffer());
  console.log(`public/icons/${output}  ${size}x${size}`);
}

// Modern browsers can use the vector artwork directly as the tab icon.
await copyFile(new URL('source.svg', SRC), new URL('favicon.svg', OUT));
console.log('public/icons/favicon.svg  (copy of icon-src/source.svg)');

Run it once, and again whenever the artwork changes:

Terminal
npm run icons
Output
public/icons/favicon-32.png  32x32
public/icons/icon-192.png  192x192
public/icons/icon-512.png  512x512
public/icons/maskable-192.png  192x192
public/icons/maskable-512.png  512x512
public/icons/apple-touch-icon.png  180x180
public/icons/shortcut-new-96.png  96x96
public/icons/favicon.svg  (copy of icon-src/source.svg)
File Size Purpose Used by
icon-192.png 192 × 192 any Install dialogs, Android fallbacks, Firefox for Android's 192 px requirement
icon-512.png 512 × 512 any Desktop app icons, WebAPK generation, splash screens
maskable-192.png, maskable-512.png 192, 512 maskable Android launcher icons (Chrome prefers a maskable icon when one exists)
apple-touch-icon.png 180 × 180 — iOS and iPadOS Home Screen
favicon.svg, favicon-32.png vector, 32 × 32 — Browser tabs and bookmarks
shortcut-new-96.png 96 × 96 — The New note app shortcut

The maskable and Apple icons are flattened onto the brand color, which removes the alpha channel entirely. iOS applies its own rounded mask to touch icons and can render transparent pixels as black, and a maskable icon with transparent edges shows whatever the launcher puts behind it.

Other ways to produce icons
  • Maskable.app previews an icon under every common mask shape and has an editor for adding padding and a background. Use it to check existing artwork.
  • The PWABuilder Image Generator takes one image and produces the icon sets for Android, iOS and Windows. See PWABuilder.
  • pwa-asset-generator is a command-line tool that renders icons, favicons and iOS splash screens with a headless browser, and can update your manifest and HTML with the results.
  • In Chrome DevTools, Application > Manifest > Icons has a Show only the minimum safe area for maskable icons checkbox that crops your maskable icons to the safe zone.

The Icons & Maskable Icons page covers the full size matrix, monochrome icons and per-platform quirks.

Screenshots for the richer install dialog

The manifest in the next step also lists two screenshots. Chrome shows them, with the manifest description, in a richer install dialog: a bottom sheet on Android (since Chrome 94) and a larger dialog on desktop (since Chrome 108), according to Chrome's announcement. Chromium only accepts screenshots between 320 and 3840 pixels on each side whose long side is at most 2.3 times the short side, as the Rich Install UI page details.

Capture them from the running app once it has some notes in it. In Chrome DevTools, open the device toolbar (Ctrl+Shift+M or Cmd+Shift+M), set a responsive size of 1280 × 800 at device pixel ratio 1, open the Command Menu (Ctrl+Shift+P or Cmd+Shift+P) and run Capture screenshot. Save it as public/screenshots/wide.png. Repeat at 375 × 667 with a device pixel ratio of 2 to get a 750 × 1334 image for narrow.png. Both have ratios well under 2.3 (1.6 and 1.78).

Step 7: Write the web app manifest

The manifest is a JSON file that tells the browser how the installed app should look and behave: its identity, name, icons, start page, window style and colors.

public/manifest.webmanifest
{
  "id": "/",
  "name": "Pocket Notes",
  "short_name": "Notes",
  "description": "Offline-first notes that live on your device. Works without a connection and installs like an app.",
  "lang": "en",
  "dir": "ltr",
  "start_url": "./?source=pwa",
  "scope": "./",
  "display": "standalone",
  "background_color": "#f7f4ed",
  "theme_color": "#1f6f5c",
  "categories": ["productivity", "utilities"],
  "icons": [
    { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
    { "src": "icons/maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
    { "src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "screenshots": [
    {
      "src": "screenshots/wide.png",
      "sizes": "1280x800",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Pocket Notes on a desktop: the editor above a list of saved notes"
    },
    {
      "src": "screenshots/narrow.png",
      "sizes": "750x1334",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Pocket Notes on a phone: writing a note while offline"
    }
  ],
  "shortcuts": [
    {
      "name": "New note",
      "short_name": "New",
      "description": "Start writing a new note",
      "url": "./?action=new&source=shortcut",
      "icons": [{ "src": "icons/shortcut-new-96.png", "sizes": "96x96", "type": "image/png" }]
    }
  ]
}

JSON allows no comments and no trailing commas. A single stray comma makes the whole manifest fail to parse, and Chromium reports manifest-parsing-or-network-error rather than pointing at the line. Validate the file with python3 -m json.tool public/manifest.webmanifest or your editor before debugging anything else.

Member by member

Member Value here What it does and why this value
id "/" The app's permanent identity. Browsers use it to decide whether an installed app and a manifest are the same app. The spec parses it against the origin of start_url, so "/" becomes https://your-host/. Without id, the identity is start_url, query string included. DevTools reported a recommended ID of /?source=pwa for this app in testing, which would tie the app's identity to an analytics parameter. Set id on day one and never change it.
name "Pocket Notes" Full name for install dialogs, the app switcher and OS app lists.
short_name "Notes" Label under the launcher icon, where space is tight.
description one sentence Shown in the richer install dialog and by some app catalogs.
lang, dir "en", "ltr" Language and direction of the text members.
start_url "./?source=pwa" The page that opens on launch. It resolves against the manifest's URL, to /?source=pwa. It must be same-origin (Chromium ignores a cross-origin value) and inside scope; when it isn't, browsers discard scope and fall back to the directory of start_url. The query string marks launches from the installed app in your analytics, and the service worker must ignore it when matching the cache (Step 8).
scope "./" The set of URLs that belong to the app. Navigations outside it leave the app window: desktop Chromium shows the URL in a toolbar, and Android opens an in-app browser tab.
display "standalone" Own window with no browser interface. Browsers that don't support a mode fall back through fullscreen → standalone → minimal-ui → browser.
background_color "#f7f4ed" Fills the window before the stylesheet loads, and Chrome for Android uses it for the launch splash screen. It matches --bg in the CSS, so there's no color jump.
theme_color "#1f6f5c" Default title bar and status bar color. The page's theme-color meta tags override it at runtime, including for dark mode.
categories ["productivity", "utilities"] A hint for app catalogs. It has no effect on installability.
icons four PNGs 192 and 512 px in both any and maskable, each with explicit sizes and type. Chromium picks icons by declared size and type without downloading them all, so wrong sizes values lead to blurry or rejected icons.
screenshots two PNGs One wide and one narrow capture with an accessible label each, for the richer install dialog.
shortcuts one entry New note, opening ./?action=new&source=shortcut. Shortcut URLs must be inside scope.

Support for these members varies. According to MDN's compatibility data, shortcuts works in Chrome and Edge (96 on desktop, 84 on Android) and Safari 17.4 on macOS, but not on iOS. Safari on iOS reads id (16.4), name, start_url, scope and display, uses theme_color from iOS 15, and ignores background_color. The full matrix is in the Members Reference and the Manifest Cheat Sheet. App Identity & Updates explains how browsers apply manifest changes to apps that are already installed.

The .webmanifest extension is the one the specification registers, with the MIME type application/manifest+json. Chromium doesn't reject other JSON types, but serve the right one anyway: Step 13 shows how, including for the servers whose default MIME tables don't know the extension.

If you reload the app now, Chrome and Edge may already show the install icon in the address bar. Chromium no longer requires a service worker for that. Without one, though, an installed Pocket Notes would show Chrome's generic offline page as soon as the network drops. The next step fixes that.

Step 8: Write the service worker

The service worker is a script that runs in its own thread, outside any page, and acts as a programmable proxy between the app and the network. Pocket Notes' worker is about 150 lines of plain JavaScript:

public/sw.js
/* sw.js — Pocket Notes service worker (classic script, scope "./").
 *
 * Bump VERSION whenever any file in APP_SHELL changes. The byte change in this
 * file is what makes the browser install the new worker and re-download the shell.
 */
const VERSION = 'v1.0.0';
const CACHE_PREFIX = 'pocket-notes-';
const PRECACHE = `${CACHE_PREFIX}precache-${VERSION}`;
const RUNTIME = `${CACHE_PREFIX}runtime`; // unversioned: survives app updates
const RUNTIME_MAX_ENTRIES = 50;

// Everything the app needs to start with no network. Paths resolve against this file's URL.
const APP_SHELL = [
  './',
  './index.html',
  './offline.html',
  './styles.css',
  './app.js',
  './db.js',
  './pwa.js',
  './manifest.webmanifest',
  './icons/favicon.svg',
  './icons/favicon-32.png',
  './icons/icon-192.png',
  './icons/icon-512.png',
  './icons/maskable-192.png',
  './icons/maskable-512.png',
  './icons/apple-touch-icon.png',
];

// ---------------------------------------------------------------------------
// install: download the whole app shell into a new, versioned cache
// ---------------------------------------------------------------------------
self.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(PRECACHE);
      // cache: 'reload' bypasses the HTTP cache so a stale copy is never precached.
      // addAll() is atomic: if any request fails, nothing is stored and install fails.
      await cache.addAll(APP_SHELL.map((url) => new Request(url, { cache: 'reload' })));
    })(),
  );
  // No skipWaiting() here: an update waits until the user accepts the "new version" toast.
});

// ---------------------------------------------------------------------------
// activate: delete caches from older versions, then control open pages
// ---------------------------------------------------------------------------
self.addEventListener('activate', (event) => {
  event.waitUntil(
    (async () => {
      const keep = new Set([PRECACHE, RUNTIME]);
      const names = await caches.keys();
      await Promise.all(
        names
          .filter((name) => name.startsWith(CACHE_PREFIX) && !keep.has(name))
          .map((name) => caches.delete(name)),
      );
      // Take control of pages that loaded before this worker existed (the first visit).
      await self.clients.claim();
    })(),
  );
});

// ---------------------------------------------------------------------------
// message: the page asks the waiting worker to activate
// ---------------------------------------------------------------------------
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

// ---------------------------------------------------------------------------
// fetch: route requests
// ---------------------------------------------------------------------------
self.addEventListener('fetch', (event) => {
  const { request } = event;

  // Only handle same-origin GET requests; everything else goes to the network untouched.
  if (request.method !== 'GET') return;
  const url = new URL(request.url);
  if (url.origin !== self.location.origin) return;

  if (request.mode === 'navigate') {
    event.respondWith(handleNavigation(request));
  } else {
    event.respondWith(handleAsset(event));
  }
});

/** Page loads: app shell from the precache, other pages network-first, offline page last. */
async function handleNavigation(request) {
  // ignoreSearch: the start_url (./?source=pwa) and shortcut URLs (./?action=new)
  // must all map to the precached "./" entry.
  const cached = await caches.match(request, { cacheName: PRECACHE, ignoreSearch: true });
  if (cached) return stripRedirect(cached);

  try {
    return await fetch(request);
  } catch {
    const fallback = await caches.match('./offline.html', { cacheName: PRECACHE });
    return fallback ? stripRedirect(fallback) : Response.error();
  }
}

/** Subresources: precache first, then stale-while-revalidate for everything else. */
async function handleAsset(event) {
  const { request } = event;

  const precached = await caches.match(request, { cacheName: PRECACHE });
  if (precached) return precached;

  const cache = await caches.open(RUNTIME);
  const cached = await cache.match(request);

  const fromNetwork = fetch(request).then((response) => {
    // Only cache complete, successful same-origin responses (never 206 partials or errors).
    if (response.status === 200) {
      const copy = response.clone();
      event.waitUntil(cache.put(request, copy).then(() => trimCache(cache, RUNTIME_MAX_ENTRIES)));
    }
    return response;
  });

  if (cached) {
    // Serve the cached copy now; refresh it in the background.
    event.waitUntil(fromNetwork.catch(() => {}));
    return cached;
  }
  return fromNetwork; // offline + not cached: rejects, and the request fails as it would without a SW
}

/**
 * Navigations use redirect mode "manual". Answering one with a response that was
 * itself the result of a redirect makes the navigation fail, so copy such responses
 * into a fresh, non-redirected Response.
 */
async function stripRedirect(response) {
  if (!response.redirected) return response;
  const body = await response.blob();
  return new Response(body, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  });
}

/** Deletes the oldest entries (cache.keys() returns insertion order) beyond maxEntries. */
async function trimCache(cache, maxEntries) {
  const keys = await cache.keys();
  const excess = keys.length - maxEntries;
  if (excess > 0) {
    await Promise.all(keys.slice(0, excess).map((key) => cache.delete(key)));
  }
}

The rest of this step explains each part. The worker is a classic script, loaded without type: 'module', so it runs in every engine that supports service workers. It uses no importScripts().

install: precache the app shell atomically

The install event fires once per new version of sw.js. The handler opens a cache whose name includes the version and calls cache.addAll() with every file the app needs to start.

  • addAll() is all-or-nothing. It fetches every URL, and if any response isn't OK (a 404, a 500 or a network failure), it stores nothing and rejects. Because that promise is passed to event.waitUntil(), the rejection makes installation fail: the new worker becomes redundant and the current version stays in charge. The browser retries at the next update check. A typo in APP_SHELL therefore blocks deployment of new versions until you fix it, which is far better than shipping a half-cached shell.
  • cache: 'reload' bypasses the HTTP cache for these requests. This matters more than it looks. GitHub Pages, for example, serves every file with Cache-Control: max-age=600. Without reload, a worker installed right after a deploy could precache a ten-minute-old app.js from the HTTP cache next to a new index.html, and freeze that mismatch until the next version.
  • There's no skipWaiting(). A new version installs in the background and then waits until the user agrees to switch. Step 9 wires up that agreement.

How the browser decides that a new version exists

You never tell the browser that a deployment happened. It finds out itself. On every navigation to a page in scope, and on other occasions described in Step 9, it re-downloads sw.js and compares it byte for byte with the installed copy (including any scripts pulled in with importScripts()). Only a difference starts the install of a new version. That's why the VERSION constant exists: changing styles.css alone doesn't change sw.js, so the browser would never re-run install and users would keep the old stylesheet forever. Bumping VERSION changes sw.js and gives the new precache a new name. Step 12 automates the bump.

activate: delete old caches, then claim clients

activate fires when the new version takes over. It deletes every cache whose name starts with pocket-notes- except the current precache and the runtime cache.

  • Filter by prefix. Cache Storage is shared by the whole origin. Other scripts on the same origin, or a future second app, may own caches that this worker must not delete.
  • The runtime cache is unversioned on purpose. It holds files that aren't part of the shell, and throwing them away on every deploy would only cost users bandwidth.
  • clients.claim() makes the new worker control pages that are already open. On the very first visit, the page loaded before any worker existed, so without claim() it would stay uncontrolled until the next navigation. With it, the first visit is offline-ready as soon as installation finishes.

fetch: route every request

Every request from a controlled page, including navigations, passes through the fetch handler. The handler ignores what it shouldn't touch: anything that isn't GET (the app makes no other requests, and caches can't store them) and anything cross-origin. For those, it returns without calling respondWith(), and the browser handles the request as if no worker existed. The remaining requests take one of these paths:

flowchart TD
    R["fetch event"] --> G{"Same-origin GET?"}
    G -- no --> PASS["Not handled: normal network request"]
    G -- yes --> M{"request.mode is navigate?"}
    M -- yes --> NC{"In precache, ignoring the query string?"}
    NC -- yes --> SHELL["Serve the cached page"]
    NC -- no --> NF["Fetch from the network"]
    NF -- "network error" --> OFF["Serve offline.html from the precache"]
    M -- no --> PC{"In precache?"}
    PC -- yes --> ASSET["Serve the precached file"]
    PC -- no --> RC{"In runtime cache?"}
    RC -- yes --> SWR["Serve the cached copy and refresh it in the background"]
    RC -- no --> NET["Fetch from the network and cache a 200 response"]

Navigations are cache-first for the shell. caches.match(request, { cacheName: PRECACHE, ignoreSearch: true }) finds ./ for /, /?source=pwa and /?action=new&source=shortcut alike. Without ignoreSearch, the start_url would miss the cache and the installed app wouldn't start offline, even though the page it needs is cached. The shell renders from disk, in milliseconds, whether the network is fast, slow or gone. This is the App Shell Model. It works because the shell only changes when you deploy, and a deploy always comes with a new worker.

Navigations to pages that aren't in the precache go to the network, and fall back to offline.html when that fails. A 404 from the server isn't a failure here: fetch() resolves with the 404 response, and the user sees your server's error page, which is correct.

Other requests use the precache, then stale-while-revalidate. A precached file is served directly. Anything else, such as the screenshots or the shortcut icon, is served from the runtime cache if present, while a background fetch() refreshes the copy for next time. On a miss, the request goes to the network and a successful response is cached. The Caching Strategies page compares this with cache-first, network-first and the other strategies.

Four details in handleAsset() are easy to get wrong:

  1. Only 200 responses are cached. That excludes error responses and 206 Partial Content responses to range requests, which cache.put() rejects with a TypeError. (fetch() follows redirects, so a redirected asset arrives here as the final 200.)
  2. The response is cloned before caching. A Response body is a stream that can be read once. One copy goes to the page, the clone goes to the cache.
  3. event.waitUntil() is called asynchronously, after respondWith(). The spec allows that as long as the event still has unsettled lifetime promises, and the pending respondWith() promise counts as one. Without waitUntil(), the browser could terminate the worker as soon as the response is delivered, before the background cache write finishes.
  4. The runtime cache is trimmed to 50 entries. cache.keys() returns entries in insertion order, and the spec's put() removes a matching entry before appending the new one. The first keys are therefore the least recently written, and because stale-while-revalidate rewrites an entry on each use, trimming from the front approximates least-recently-used eviction. Precaching & Runtime Caching covers size- and age-based expiration in more depth.

The offline fallback page

offline.html is precached with the shell and served for any navigation that fails. That last part shapes how it's written: the worker serves it under the URL that was requested. The address bar keeps showing, say, /archive/2026/trip.html, and relative URLs in the page resolve against that path. A plain <link rel="stylesheet" href="styles.css"> would then request /archive/2026/styles.css, miss the cache and leave the page unstyled; in testing, that's exactly what happened at a nested URL before the page was made self-contained. So the fallback page carries its own styles and computes its home link from the service worker's script URL, which is always the app's root:

public/offline.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>Offline · Pocket Notes</title>
  <meta name="theme-color" content="#1f6f5c">
  <meta name="color-scheme" content="light dark">
  <!--
    sw.js serves this page for ANY navigation that fails offline, such as /archive/2026/x.html.
    Relative URLs would resolve against that URL and miss the cache, so the page is
    self-contained: inline styles, and a home link computed from the service worker's URL.
  -->
  <style>
    :root {
      color-scheme: light dark;
      --bg: #f7f4ed; --surface: #ffffff; --text: #1d2521; --border: #d9d4c7;
      --button: #1f6f5c; --button-text: #ffffff;
    }
    @media (prefers-color-scheme: dark) {
      :root {
        --bg: #101815; --surface: #18221e; --text: #e6ece9; --border: #2c3a34;
        --button: #5cc2a5; --button-text: #0b1512;
      }
    }
    body {
      box-sizing: border-box; margin: 0; min-height: 100dvh; padding: 24px;
      display: grid; place-items: center;
      font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
      background: var(--bg); color: var(--text);
    }
    main {
      max-width: 420px; padding: 24px; text-align: center;
      background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
    }
    a {
      display: inline-block; padding: 10px 20px; border-radius: 999px;
      background: var(--button); color: var(--button-text); font-weight: 600; text-decoration: none;
    }
  </style>
</head>
<body>
  <main>
    <h1>You're offline</h1>
    <p>This page hasn't been saved for offline use. Your notes are stored on this device and are still available.</p>
    <p><a id="home" href="./">Open my notes</a></p>
  </main>
  <script>
    // The app lives next to sw.js, so link there whatever URL this fallback was served for.
    const worker = navigator.serviceWorker && navigator.serviceWorker.controller;
    if (worker) document.getElementById('home').href = new URL('./', worker.scriptURL).href;
  </script>
</body>
</html>

Keep fallback pages this way: no external stylesheets, scripts, fonts or images unless you reference them with URLs that can't be misresolved and precache them too. If your site uses a Content Security Policy, the inline <style> and <script> need a nonce or hash; see Content Security Policy. Offline UX & Fallbacks covers richer fallbacks, such as listing the cached pages a user can open.

Why stripRedirect() exists

Navigation requests use redirect mode manual. The Fetch standard says that when a service worker answers such a request with a response whose URL list has more than one entry, which is what a response that was redirected has, the browser must treat it as a network error. That rule prevents a worker from silently hiding a redirect from the address bar.

Precaching can easily produce redirected responses. Many static hosts redirect .html URLs to extensionless ones. Cloudflare Pages documents that /contact.html redirects to /contact and /about/index.html to /about/, and the serve package does the same by default with its cleanUrls option. cache.addAll() follows the redirect and stores the final response, which has redirected === true. Serving that response for a navigation fails.

This isn't hypothetical. With serve's default cleanUrls behavior (/offline.html answered with 301 Location: /offline), the tutorial app passed every offline check. With stripRedirect() removed and nothing else changed, the offline fallback failed with net::ERR_FAILED. The function copies the body, status and headers into a fresh Response, which has a single-entry URL list and is safe to use for navigations.

What this worker deliberately doesn't do

  • No cross-origin caching. Opaque responses from other origins can't be inspected, so the worker can't tell a success from an error page. Browsers also pad their size in quota calculations: Chromium adds a pseudo-random amount between 0 and about 14 MiB per opaque response, about 7 MiB on average. This app loads nothing cross-origin.
  • No navigation preload. It speeds up network-first navigations by starting the request while the worker boots. Cache-first navigations don't wait for the network, so there's nothing to speed up. See Navigation Preload if you switch strategies.
  • No skipWaiting() in install. Taking over immediately would let a page that loaded the old app.js start fetching assets from the new cache. Mixed versions are the source of the hardest update bugs. The Pitfalls & Anti-Patterns page lists this and other traps.
  • No module syntax. Module service workers (register(url, { type: 'module' })) let you use import. MDN's compatibility data lists them in Chrome 91 and Safari 15, and the Firefox 147 release notes added Firefox. A classic script keeps this tutorial working on older engines, and a single file doesn't need imports.

Step 9: Register the worker, announce updates and add an install button (pwa.js)

Replace the stub from Step 5 with the real module. It has three jobs: register the service worker, turn a waiting update into a toast, and manage the install UI.

public/pwa.js
// pwa.js — service worker registration, update prompt, and install UI.
const SW_URL = './sw.js';
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; // at most one manual update check per hour
const IOS_HINT_KEY = 'pocket-notes:ios-hint-dismissed';

export function initPwa() {
  registerServiceWorker();
  setUpInstallButton();
  maybeShowIosInstallHint();
}

// ---------------------------------------------------------------------------
// Service worker registration and updates
// ---------------------------------------------------------------------------

async function registerServiceWorker() {
  if (!('serviceWorker' in navigator)) return; // progressive enhancement: the app still works online

  // When a new worker takes control (after the user accepts an update), reload once so
  // the page runs the same app shell version as the worker. On a first visit, the new
  // worker's clients.claim() also fires controllerchange; that one must not reload.
  // Attach the listener before register() so no controllerchange event is missed.
  let hasController = Boolean(navigator.serviceWorker.controller);
  let reloading = false;
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    if (!hasController) {
      hasController = true; // first install just claimed this page: nothing to refresh
      return;
    }
    if (reloading) return;
    reloading = true;
    window.location.reload();
  });

  // Register after the load event so installing the worker (which downloads the
  // whole app shell) does not compete with the first render for bandwidth.
  if (document.readyState !== 'complete') {
    await new Promise((resolve) => window.addEventListener('load', resolve, { once: true }));
  }

  let registration;
  try {
    registration = await navigator.serviceWorker.register(SW_URL, {
      scope: './',
      updateViaCache: 'none', // never satisfy update checks (sw.js or importScripts) from the HTTP cache
    });
  } catch (error) {
    console.error('[pwa] Service worker registration failed:', error);
    return;
  }

  // A worker that reaches "installed" while the page already has a controller is an
  // update waiting behind the current version. On a first install there is no
  // controller yet, so no toast is shown.
  const trackInstalling = (worker) => {
    worker.addEventListener('statechange', () => {
      if (worker.state === 'installed' && navigator.serviceWorker.controller) {
        showUpdateToast(worker);
      }
    });
  };

  // Case 1: an update was downloaded earlier (for example during a previous visit) and is waiting.
  if (registration.waiting && navigator.serviceWorker.controller) {
    showUpdateToast(registration.waiting);
  }
  // Case 2: the update check triggered by this navigation is still installing.
  if (registration.installing) {
    trackInstalling(registration.installing);
  }
  // Case 3: an update is found later while this page is open.
  registration.addEventListener('updatefound', () => {
    if (registration.installing) trackInstalling(registration.installing);
  });

  // Installed apps can stay open for days. Browsers check for updates on navigation
  // (and on some events), so also check when the app returns to the foreground.
  let lastCheck = Date.now();
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState !== 'visible') return;
    if (Date.now() - lastCheck < UPDATE_CHECK_INTERVAL_MS) return;
    lastCheck = Date.now();
    registration.update().catch((error) => console.warn('[pwa] Update check failed:', error));
  });
}

function showUpdateToast(worker) {
  const toast = document.querySelector('#update-toast');
  const reloadButton = document.querySelector('#update-reload');
  const dismissButton = document.querySelector('#update-dismiss');

  reloadButton.disabled = false;
  reloadButton.onclick = () => {
    reloadButton.disabled = true;
    // sw.js listens for this message and calls self.skipWaiting().
    worker.postMessage({ type: 'SKIP_WAITING' });
  };
  dismissButton.onclick = () => {
    // The update stays waiting and activates once every tab of the app is closed.
    toast.hidden = true;
  };
  toast.hidden = false;
}

// ---------------------------------------------------------------------------
// Install UI
// ---------------------------------------------------------------------------

function isRunningInstalled() {
  return (
    ['standalone', 'fullscreen', 'minimal-ui', 'window-controls-overlay'].some(
      (mode) => window.matchMedia(`(display-mode: ${mode})`).matches,
    ) || navigator.standalone === true // legacy iOS/iPadOS property
  );
}

function setUpInstallButton() {
  const installButton = document.querySelector('#install-button');
  let deferredPrompt = null;

  // Chromium browsers only. Fires when the page meets the install criteria and the
  // app is not installed yet. Fires again after the user dismisses the dialog.
  window.addEventListener('beforeinstallprompt', (event) => {
    event.preventDefault(); // suppress the automatic mini-infobar on Android; keep the event
    deferredPrompt = event;
    installButton.hidden = false;
  });

  installButton.addEventListener('click', async () => {
    if (!deferredPrompt) return;
    const promptEvent = deferredPrompt;
    deferredPrompt = null;        // prompt() works once per event
    installButton.hidden = true;
    try {
      await promptEvent.prompt(); // needs transient user activation: call it inside the click handler
      const { outcome } = await promptEvent.userChoice;
      console.info(`[pwa] Install prompt outcome: ${outcome}`);
    } catch (error) {
      console.warn('[pwa] Install prompt failed:', error);
    }
  });

  // Fires for every successful install, including ones started from the browser menu.
  window.addEventListener('appinstalled', () => {
    deferredPrompt = null;
    installButton.hidden = true;
    console.info('[pwa] App installed');
  });
}

function isIosOrIpadOs() {
  const ua = navigator.userAgent;
  // iPadOS 13+ reports a desktop Mac user agent; touch support gives it away.
  return /iPad|iPhone|iPod/.test(ua) || (ua.includes('Macintosh') && navigator.maxTouchPoints > 1);
}

function maybeShowIosInstallHint() {
  // No beforeinstallprompt on iOS/iPadOS: installation is always a manual
  // Share > Add to Home Screen, so explain it once.
  if (!isIosOrIpadOs() || isRunningInstalled()) return;

  let dismissed = false;
  try {
    dismissed = localStorage.getItem(IOS_HINT_KEY) === '1';
  } catch { /* storage blocked: show the hint */ }
  if (dismissed) return;

  const hint = document.querySelector('#ios-install-hint');
  document.querySelector('#ios-hint-dismiss').addEventListener('click', () => {
    hint.hidden = true;
    try { localStorage.setItem(IOS_HINT_KEY, '1'); } catch { /* ignore */ }
  }, { once: true });
  hint.hidden = false;
}

Registering the service worker

'serviceWorker' in navigator is false in browsers without support and in insecure contexts, where the property doesn't exist at all. The function returns early and the app keeps working online. That check, not a user-agent test, is what makes the service worker an enhancement.

Registration waits for the load event. On a first visit, installing the worker downloads the whole app shell a second time (with cache: 'reload'), and that shouldn't compete with the first render for bandwidth. On later visits the page is already controlled and the delay costs nothing.

The options passed to register():

  • scope: './' resolves against the page URL to the app's root, which is also the default, because the scope defaults to the script's directory. Writing it out documents the intent, and it can never exceed the maximum scope (Step 1).
  • updateViaCache: 'none' controls whether update checks may use the HTTP cache. The values are 'imports' (the default), 'all' and 'none'. Per the specification, the update request for the top-level sw.js gets cache mode no-cache, meaning it is always revalidated with the server, unless the mode is 'all'. 'none' extends the same treatment to scripts loaded with importScripts(). This worker has no imports, so the option protects the future, not the present. MDN lists support in Chrome 68, Firefox 57 and Safari 11.1.

register() rejects when something is wrong with the script: a TypeError for a 404 or a script that throws during its first evaluation, and a SecurityError when the response has a non-JavaScript MIME type or the requested scope is outside the maximum scope. The script request is also made with redirect mode error, so a redirect on sw.js makes registration fail. If your host adds trailing slashes or rewrites .js URLs, exclude sw.js. Calling register() on every page load is cheap: when a registration with the same script URL and options exists, the call just resolves with it.

When the browser checks for updates

According to the Service Workers specification, the browser runs an update check:

  • after every navigation to a page in the worker's scope;
  • for functional events (push, sync and so on) and for subresource requests, when the registration is stale, meaning its last update check was more than 86,400 seconds (24 hours) ago;
  • when register() is called with a different script URL;
  • when your code calls registration.update().

An installed app that sits in the dock for a week may never navigate, so pwa.js also calls update() whenever the window becomes visible again, at most once an hour. update() rejects when offline, so its error is caught and logged.

The update flow, from deploy to reload

sequenceDiagram
    participant U as User
    participant P as Page
    participant B as Browser
    participant O as Worker v1.0.0
    participant N as Worker v1.0.1
    U->>P: Opens the app
    O-->>P: Serves the shell from precache v1.0.0
    B->>B: Update check finds a different sw.js
    B->>N: Starts install
    N->>N: Precaches the shell into precache v1.0.1
    N-->>P: statechange to installed while a controller exists
    P->>U: Shows the new version toast
    U->>P: Clicks Reload
    P->>N: postMessage SKIP_WAITING
    N->>N: skipWaiting, then activate deletes precache v1.0.0 and claims clients
    N-->>P: controllerchange
    P->>P: location.reload()
    N-->>P: Serves the shell from precache v1.0.1

registerServiceWorker() covers the three moments at which an update can be noticed:

  1. An update is already waiting when the page loads (registration.waiting), because it was downloaded during an earlier visit and the user chose Later. The toast appears right away.
  2. The update check triggered by this navigation is still installing (registration.installing). The code watches its statechange.
  3. An update is found later, for example by the visibilitychange check. updatefound fires, and the code watches the new installing worker.

In each case, the toast appears only when the new worker reaches installed and the page already has a controller. On a first visit there's no controller, so the first install never shows an "update" toast. When the user clicks Reload, the page posts { type: 'SKIP_WAITING' } to the waiting worker, and sw.js calls self.skipWaiting(). The worker activates, deletes the old precache and claims every open window. Each window then receives controllerchange and reloads into the new version. The draft from Step 5 is restored after the reload.

Why the reload is guarded

controllerchange fires in two different situations, and only one of them should reload:

  • A first install. The page loaded with no worker. The new worker's clients.claim() gives the page a controller, which fires controllerchange. The page is already running the current code, so a reload would only flash the screen.
  • An accepted update. The controller changes from the old version to the new one. The page is running old code and must reload.

The hasController flag distinguishes them. It starts as "is this page controlled right now?", and the first controllerchange on an uncontrolled page just flips it. The listener is attached before register() so that no event is missed, which matters in one sequence that is easy to overlook: a first visit, followed by an update in the same page session. That page became controlled through claim(), so it needs its next controllerchange to reload. In testing, that sequence showed the toast, reloaded on click, and left only the new precache behind. The reloading flag prevents a reload loop if several controllerchange events arrive.

Why reloading the page doesn't activate a waiting worker

A waiting worker activates only when no window still uses the old version, or when it calls skipWaiting(). Reloading the app's only tab doesn't get there: the old document remains a client of the old worker while the reload's request is made, so the old worker keeps control and the new one keeps waiting. Testing confirmed each step of this. After Later and a reload, the toast reappeared, the worker was still installed, and both precaches existed side by side. After the last tab was closed and the app was reopened, the new worker was active and only its precache remained. That's the purpose of the toast: without skipWaiting(), an installed app that the user never fully closes could stay on an old version for weeks. The Lifecycle page explains the states in depth, and Updating Service Workers compares this prompt-to-reload pattern with the alternatives.

The install button and beforeinstallprompt

beforeinstallprompt is a non-standard event that only Chromium-based browsers fire: Chrome and Edge (MDN lists Chrome 44 for the event interface and 76 for the promise-returning prompt()) and Samsung Internet. It fires when the page meets the browser's criteria and the app isn't installed. The handler:

  1. Calls preventDefault(), which stops Chrome on Android from showing its own install message (the "mini-infobar") and keeps the event usable later. It doesn't remove the install icon from the desktop address bar. Chrome then logs an informational console message, "Banner not shown: beforeinstallpromptevent.preventDefault() called. The page must call beforeinstallpromptevent.prompt() to show the banner.", which is expected.
  2. Stores the event and reveals the button.
  3. Calls prompt() inside the click handler. prompt() requires transient user activation. Called from a timer or after an unrelated await, it rejects with NotAllowedError. It works once per event, which is why the code clears deferredPrompt before calling it.
  4. Reads userChoice, which resolves with { outcome: 'accepted' | 'dismissed', platform }. After a dismissal, Chromium can dispatch a fresh beforeinstallprompt, and the button comes back.
  5. Listens for appinstalled, which fires for every successful installation, including those started from the browser's own menu or address-bar icon. That's the reliable place to hide install UI and record an analytics event.

The listener must be attached early. In headless Chrome 153 during testing, the event fired on the first page load, a few milliseconds after load, with no user interaction at all, despite the engagement heuristic that Google's documentation describes. In an incognito browser context it never fired, because Chromium doesn't promote installation in off-the-record profiles. Installability Criteria explains what Chromium actually checks, and Install Prompts & Custom UI covers placement, timing and analytics for install UI.

Instructions for iOS and iPadOS

Safari never fires beforeinstallprompt, and no API can trigger Add to Home Screen. The only option is to explain it. maybeShowIosInstallHint() shows a one-time hint on iPhone and iPad:

  • Detection is a heuristic. No feature detection can tell whether a browser offers Add to Home Screen, so the code checks the user agent. iPadOS 13 and later reports a desktop Mac user agent by default; a Mac user agent combined with navigator.maxTouchPoints > 1 identifies an iPad.
  • It never shows inside the installed app. isRunningInstalled() checks the display-mode media queries and the legacy navigator.standalone property that Safari exposes on iOS and iPadOS (and, since Safari 17, in macOS Dock web apps).
  • Dismissal is remembered in localStorage, wrapped in try because storage access can throw (for example when it's blocked).

The hint's wording matches the iOS 26 flow: tap ⋯, then Share, then Add to Home Screen, keep Open as Web App switched on, and tap Add. Since iOS and iPadOS 26, every site added to the Home Screen opens as a web app by default. Installation also unlocks features on iOS, because Web Push and the Badging API are only available to Home Screen web apps.

Other browsers without beforeinstallprompt have their own menus: File > Add to Dock in Safari on macOS, the install item in Firefox for Android's menu, and taskbar web apps in Firefox on Windows. You can extend the hint for them the same way; Installation by Platform has the exact paths.

Step 10: Serve the app locally

Service workers only register in secure contexts. For development, you don't need a certificate, because the browser treats localhost as secure.

Terminal
npm start
# equivalent to: npx serve public
# INFO  Accepting connections at http://localhost:3000

serve reads serve.json from the directory it serves, so the file lives in public/:

public/serve.json
{
  "cleanUrls": false,
  "rewrites": [{ "source": "/", "destination": "/index.html" }],
  "headers": [
    {
      "source": "sw.js",
      "headers": [{ "key": "Cache-Control", "value": "no-cache" }]
    },
    {
      "source": "manifest.webmanifest",
      "headers": [
        { "key": "Content-Type", "value": "application/manifest+json" },
        { "key": "Cache-Control", "value": "no-cache" }
      ]
    }
  ]
}
Terminal
python3 -m http.server 3000 --directory public
# Serving HTTP on :: port 3000 (http://[::]:3000/) ...

Python's built-in server needs no configuration for this app. Its MIME table maps .webmanifest to application/manifest+json (it has since Python 3.8), and sw.js is served as JavaScript. It can't set custom headers, which is fine locally: the browser revalidates sw.js on every update check anyway.

Three settings in serve.json deserve an explanation, because each one was found by testing:

  • "cleanUrls": false. By default, serve redirects /index.html to /index (301 Moved Permanently). Precaching ./index.html would then store a redirected response. stripRedirect() in the worker copes with that, but local URLs should stay literal.
  • The rewrite from / to /index.html. With cleanUrls off, serve no longer maps a directory to its index.html. Without the rewrite, / returns a directory listing, and cache.addAll() would happily precache that listing as your app shell.
  • The headers mirror what production uses (Step 13): Cache-Control: no-cache for sw.js and the manifest, and the manifest's MIME type.

Don't use serve --single (or any "SPA fallback" setting) for a multi-page app like this one. It answers every unknown URL with index.html and a 200 status, so a typo in APP_SHELL gets precached as HTML instead of failing the install, and the offline fallback never runs for pages that don't exist.

Why localhost works without HTTPS

The Secure Contexts specification treats some origins as potentially trustworthy even over plain HTTP: loopback addresses (127.0.0.0/8 and ::1), localhost, and hosts ending in .localhost. Pages from those origins get every secure-context API, including service workers, crypto.randomUUID() and the storage APIs.

Your LAN address is not one of them. If you open http://192.168.1.20:3000 on a phone, navigator.serviceWorker is undefined and crypto.randomUUID is missing, so pwa.js does nothing and saving a note fails with "Could not save the note". You have three options for testing on a real device:

  • Android: connect the phone over USB with USB debugging enabled, open chrome://inspect in desktop Chrome, and add a port-forwarding rule from device port 3000 to localhost:3000. On the phone, http://localhost:3000 now reaches your computer and is a secure context, so the full install flow works.
  • Any device: deploy a preview to an HTTPS host (Step 13). This is the only practical option for iOS and iPadOS, where you inspect the result with Safari's Web Inspector (Settings > Apps > Safari > Advanced > Web Inspector on the device, then the Develop menu on a Mac).
  • Chromium only, for quick checks: add the LAN origin to chrome://flags/#unsafely-treat-insecure-origin-as-secure. Never leave it enabled for everyday browsing.

Step 11: Verify everything in DevTools

Open http://localhost:3000 in Chrome or Edge, add a note or two, and open DevTools (F12, Ctrl+Shift+I or Cmd+Option+I). Most of what you need is in the Application panel. Browser DevTools is the complete reference; this is the checklist for this app.

Application > Manifest

  • No errors or warnings at the top. A member the parser drops (an invalid color, an unknown display value) appears here and nowhere else.
  • Identity: the computed App ID is http://localhost:3000/, from "id": "/".
  • Presentation: start URL /?source=pwa, scope /, display standalone, and both colors.
  • Icons: all four icons render. Tick Show only the minimum safe area for maskable icons and check that the page glyph is untouched.
  • Shortcuts and Screenshots: one shortcut and two screenshots.
  • Installability: this section appears only when something fails, with the reason (for example "Manifest does not contain a suitable icon"). If you see it, the failure table lists every message and its fix.

Application > Service workers

  • Status: the worker shows as activated and running, with sw.js as its source. On later versions, a second entry appears as waiting to activate, with a skipWaiting link that does what the toast's Reload button does.
  • Offline makes DevTools emulate a network outage. Use it for the offline test below.
  • Bypass for network sends requests straight to the network, skipping the fetch handler. It's useful while you edit CSS, but remember to untick it.
  • Update on reload forces an update on every navigation. Don't leave it on while testing the update toast. In Chromium's source (service_worker_controllee_request_handler.cc), this mode installs a new worker even if sw.js hasn't changed and marks it to skip waiting, so every reload activates a new version immediately and your waiting-state UI never appears.
  • Update runs one update check, and Unregister removes the registration. Push and Sync send test events, which this app doesn't handle.

Application > Storage

  • Cache storage lists pocket-notes-precache-v1.0.0 with 15 entries, one per APP_SHELL URL, and pocket-notes-runtime once a non-shell file has been fetched.
  • IndexedDB shows the pocket-notes database, the notes store with its updatedAt index, and your notes as objects.
  • Storage (the overview) shows usage and quota, and Clear site data resets everything: registration, caches and IndexedDB. Use it to replay the first-visit experience.

The offline test

  1. Tick Offline in the Service workers pane, or choose Offline in the Network panel's throttling menu.
  2. Reload. The app appears instantly, with your notes. In the Network panel, responses from the worker show (ServiceWorker) in the Size column.
  3. Open /?source=pwa and /?action=new. Both load from the precache thanks to ignoreSearch, and the second focuses the editor.
  4. Open /does-not-exist.html and /some/deep/page.html. Both show the styled offline page, and its Open my notes link points to /.
  5. Add a note while offline. It's saved, because IndexedDB doesn't need the network.
  6. Untick Offline.

Finally, install the app from the Install app button or the address-bar icon. It opens in its own window, the install button is gone (display-mode: standalone), and the console shows [pwa] App installed. chrome://web-app-internals lists the installed app with its manifest data, and chrome://serviceworker-internals lists every registration in the profile.

Automate the checks

Manual checks catch problems once. A script catches them on every change. tools/smoke-test.mjs uses Puppeteer and the Chrome DevTools Protocol to run the same checks in a fresh, temporary browser profile:

tools/smoke-test.mjs
// tools/smoke-test.mjs — automates the DevTools checks from this tutorial.
// Usage: start the server (npm start), then: node tools/smoke-test.mjs http://localhost:3000/
import puppeteer from 'puppeteer';

const appUrl = new URL(process.argv[2] ?? 'http://localhost:3000/');
const failures = [];
const check = (ok, label) => {
  console.log(`${ok ? 'PASS' : 'FAIL'}  ${label}`);
  if (!ok) failures.push(label);
};

// A fresh, temporary profile every run: no leftover service worker or caches.
const browser = await puppeteer.launch();

try {
  const page = await browser.newPage();
  await page.goto(appUrl.href, { waitUntil: 'load' });

  // 1. Installability: the same check as DevTools > Application > Manifest.
  const cdp = await page.createCDPSession();
  const { installabilityErrors } = await cdp.send('Page.getInstallabilityErrors');
  const errorIds = installabilityErrors.map((error) => error.errorId);
  check(errorIds.length === 0, `installable${errorIds.length ? ` (${errorIds.join(', ')})` : ''}`);

  // 2. The service worker installs, activates and claims this first page load.
  const controller = await page
    .waitForFunction(() => navigator.serviceWorker.controller?.scriptURL, { timeout: 15_000 })
    .then((handle) => handle.jsonValue())
    .catch(() => null);
  check(Boolean(controller), `page is controlled by ${controller ?? 'no service worker'}`);

  // 3. Exactly one versioned precache exists and it holds the app shell.
  const precaches = await page.evaluate(async () => {
    const result = {};
    for (const name of await caches.keys()) {
      if (name.includes('-precache-')) result[name] = (await (await caches.open(name)).keys()).length;
    }
    return result;
  });
  const entries = Object.values(precaches);
  check(entries.length === 1 && entries[0] > 0, `precache ${JSON.stringify(precaches)}`);

  // 4. Offline behavior. Emulate "no network" for the page *and* the service worker:
  //    page.setOfflineMode() alone does not affect the worker's own fetch() calls.
  const swTarget = await browser.waitForTarget((target) => target.type() === 'service_worker');
  const swSession = await swTarget.createCDPSession();
  const offline = { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 };
  await swSession.send('Network.enable');
  await swSession.send('Network.emulateNetworkConditions', offline);
  await page.setOfflineMode(true);

  await page.reload({ waitUntil: 'load' });
  check((await page.title()) === 'Pocket Notes', 'the app reloads offline');

  await page.goto(new URL('./?source=pwa', appUrl).href, { waitUntil: 'load' });
  check((await page.title()) === 'Pocket Notes', 'start_url opens offline');

  await page.goto(new URL('./no-such-page.html', appUrl).href, { waitUntil: 'load' });
  check((await page.title()).startsWith('Offline'), 'unknown pages show offline.html');
} catch (error) {
  check(false, `unexpected error: ${error.message}`);
} finally {
  await browser.close();
}

process.exitCode = failures.length ? 1 : 0;

With the dev server running in another terminal:

Terminal
npm run smoke
Output
PASS  installable
PASS  page is controlled by http://localhost:3000/sw.js
PASS  precache {"pocket-notes-precache-v1.0.0":15}
PASS  the app reloads offline
PASS  start_url opens offline
PASS  unknown pages show offline.html

One detail in the script is easy to get wrong. Puppeteer's page.setOfflineMode(true) emulates an outage for the page's requests only. The service worker is a separate target, and its own fetch() calls still reach the network. In testing, with only the page offline, navigating to a missing page returned the server's 404 through the worker instead of the offline fallback. The script therefore also attaches to the service worker target and sends Network.emulateNetworkConditions there. Page.getInstallabilityErrors is the protocol method behind the DevTools Installability section. It's marked experimental in the protocol, so pin your Puppeteer version in CI. Automated Testing covers Playwright equivalents and testing update flows.

Step 12: Ship an update

Make a visible change, such as a new accent color in styles.css. Then change VERSION in sw.js to 'v1.0.1' and reload the page that's still open. In testing, this is what happened:

  1. The navigation triggered an update check. sw.js differed by the version string, so the browser installed the new worker.
  2. The new worker downloaded the shell into pocket-notes-precache-v1.0.1. While it waited, both precaches existed.
  3. The toast appeared. Text typed into the editor at this point was still there after the next step.
  4. Reload posted SKIP_WAITING. The new worker activated, deleted pocket-notes-precache-v1.0.0 and claimed the page. controllerchange reloaded it.
  5. After the reload, the page ran the new stylesheet and the draft was restored from sessionStorage.

The weak point comes before all of this: remembering to change VERSION. Forget it, and the browser sees an identical sw.js, installs nothing, and keeps serving the old shell from the precache indefinitely, because navigations are cache-first. Automate the bump instead. tools/stamp-version.mjs hashes every file in APP_SHELL and writes the hash into sw.js:

tools/stamp-version.mjs
// tools/stamp-version.mjs — set VERSION in public/sw.js to a hash of the app shell files.
// Run before every deploy (npm run stamp): any change to a precached file then changes
// sw.js by at least one byte, which is what makes browsers install the new version.
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';

const PUBLIC = new URL('../public/', import.meta.url);
const swUrl = new URL('sw.js', PUBLIC);
const sw = await readFile(swUrl, 'utf8');

// Read the APP_SHELL list from sw.js itself, so the two can never drift apart.
const listSource = sw.match(/const APP_SHELL = \[([\s\S]*?)\];/)?.[1];
if (!listSource) throw new Error('APP_SHELL array not found in public/sw.js');
const files = [...listSource.matchAll(/'\.\/([^']*)'/g)]
  .map(([, path]) => path || 'index.html') // './' is served by index.html
  .filter((path, index, all) => all.indexOf(path) === index)
  .sort();

const hash = createHash('sha256');
for (const file of files) {
  // A missing file throws here, before a broken precache list reaches users.
  hash.update(file).update('\0').update(await readFile(new URL(file, PUBLIC))).update('\0');
}
const version = `v-${hash.digest('hex').slice(0, 12)}`;

const updated = sw.replace(/const VERSION = '[^']*';/, `const VERSION = '${version}';`);
if (updated === sw) {
  console.log(`sw.js already at ${version} (${files.length} files hashed)`);
} else {
  await writeFile(swUrl, updated);
  console.log(`sw.js VERSION set to ${version} (${files.length} files hashed)`);
}
Terminal
npm run stamp
# sw.js VERSION set to v-3f72a125fb30 (14 files hashed)

It reports 14 files, not 15, because ./ and ./index.html are the same file. Run it as the last step before every deploy; running it twice without changes leaves sw.js untouched. If a file listed in APP_SHELL doesn't exist, readFile() throws and the script exits with an error, before a broken precache list ever reaches users. This is the same idea Workbox uses in its precache manifest, where each entry gets its own revision hash so that only changed files are downloaded again. Workbox Fundamentals and Precaching & Runtime Caching cover that approach, which becomes worthwhile once your shell has more than a handful of files.

Step 13: Deploy to a static host

Any static host with HTTPS works. The directory to publish is public/. Two response headers need attention:

File Header Why
sw.js Cache-Control: no-cache Browsers already revalidate sw.js on update checks, but CDNs and proxies between the browser and your origin may serve a cached copy to that revalidation. no-cache makes every cache ask the origin.
manifest.webmanifest Content-Type: application/manifest+json The registered MIME type. nginx's and Apache's default mime.types files have no entry for .webmanifest, so they fall back to application/octet-stream.
manifest.webmanifest Cache-Control: no-cache Browsers re-read the manifest to update installed apps. A long-cached manifest delays icon and name changes.

Also make sure that sw.js is never redirected (registration fails on a redirect, as described in Step 9) and that HTTPS is enforced for the whole origin.

Both read a _headers file from the publish directory. Set the publish (or build output) directory to public and deploy. No build command is needed.

public/_headers
# _headers — read by Netlify and Cloudflare Pages from the publish directory.
# Format: a URL path on its own line, then indented "Name: value" headers.
# Every rule whose path matches is applied, so set each header in only one matching rule.

/*
  X-Content-Type-Options: nosniff

/sw.js
  Cache-Control: no-cache

/manifest.webmanifest
  Content-Type: application/manifest+json
  Cache-Control: no-cache

Every rule whose path matches a request is applied. Cloudflare's documentation says that when a header is set twice, the values are joined with a comma, which is why each header appears in only one matching rule. Cloudflare Pages also redirects .html URLs to extensionless ones, which stripRedirect() in the worker already handles.

firebase.json
{
  "hosting": {
    "public": "public",
    "ignore": ["firebase.json", "**/.*", "serve.json", "_headers"],
    "headers": [
      {
        "source": "/sw.js",
        "headers": [{ "key": "Cache-Control", "value": "no-cache" }]
      },
      {
        "source": "/manifest.webmanifest",
        "headers": [
          { "key": "Content-Type", "value": "application/manifest+json" },
          { "key": "Cache-Control", "value": "no-cache" }
        ]
      }
    ]
  }
}

Deploy with firebase deploy --only hosting. Firebase matches source as a glob against the URL path.

vercel.json
{
  "headers": [
    {
      "source": "/sw.js",
      "headers": [{ "key": "Cache-Control", "value": "no-cache" }]
    },
    {
      "source": "/manifest.webmanifest",
      "headers": [
        { "key": "Content-Type", "value": "application/manifest+json" },
        { "key": "Cache-Control", "value": "no-cache" }
      ]
    }
  ]
}

Put vercel.json in the project root and set the project's output directory to public.

GitHub Pages can't set custom headers. It serves every file with Cache-Control: max-age=600 and already sends application/manifest+json for .webmanifest files (both checked with curl against live Pages sites). The ten-minute cache is why the worker precaches with cache: 'reload'.

Branch-based publishing only supports the repository root or a /docs folder, so deploy public/ with a workflow. In the repository settings, set Pages > Source to GitHub Actions:

.github/workflows/pages.yml
name: Deploy to GitHub Pages
on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  pages: write     # deploy to Pages
  id-token: write  # verify the deployment's origin

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/configure-pages@v6
      - uses: actions/upload-pages-artifact@v5
        with:
          path: public
      - id: deployment
        uses: actions/deploy-pages@v5

A project site is served from a subdirectory such as /pocket-notes/. Read Deploying to a subdirectory before you publish.

/etc/nginx/conf.d/pocket-notes.conf (inside the server block)
root /var/www/pocket-notes/public;

location = /sw.js {
    add_header Cache-Control "no-cache" always;
}

location = /manifest.webmanifest {
    # nginx's mime.types has no .webmanifest entry: set the type for this file only.
    types { }
    default_type application/manifest+json;
    add_header Cache-Control "no-cache" always;
}

Don't add a types { … } block with only the manifest type at http or server level: a types block replaces the inherited MIME map instead of extending it, and every other file would be served as application/octet-stream. Also note that add_header in a location block stops that location from inheriting add_header directives from the server level, so repeat any security headers you set there.

public/.htaccess
# Apache's mime.types has no .webmanifest entry.
AddType application/manifest+json .webmanifest

# Requires mod_headers.
<Files "sw.js">
  Header set Cache-Control "no-cache"
</Files>
<Files "manifest.webmanifest">
  Header set Cache-Control "no-cache"
</Files>

.htaccess files only take effect when the directory's AllowOverride setting permits them (FileInfo covers these directives). If you manage the server configuration, put the same directives in the virtual host instead.

Check the deployed headers

Terminal
curl -sI https://notes.example.com/sw.js | grep -iE '^(HTTP|content-type|cache-control|location)'
curl -sI https://notes.example.com/manifest.webmanifest | grep -iE '^(HTTP|content-type|cache-control)'

Expect 200, a JavaScript content type and cache-control: no-cache for sw.js, with no location header, and application/manifest+json for the manifest. Then run the smoke test against the deployed URL: node tools/smoke-test.mjs https://notes.example.com/.

Deploying to a subdirectory

Every URL in this project is relative (./sw.js, ./?source=pwa, icons/icon-192.png), so the app works unchanged under a path such as https://you.github.io/pocket-notes/. The worker's scope becomes /pocket-notes/, and so do start_url and scope, because they resolve against the manifest's URL.

The exception is id. The specification parses id against the origin of start_url, not against the manifest URL. "id": "/" therefore means the origin root, and so does "id": "./". In testing under /pocket-notes/, both produced the app ID http://localhost:4191/. Two apps deployed under different paths of one origin with either value would claim the same identity. Change it before the first deployment:

public/manifest.webmanifest (excerpt, for a /pocket-notes/ deployment)
"id": "/pocket-notes/",

That produced the app ID http://localhost:4191/pocket-notes/. Set it once and don't change it later: a different id is a different app to the browser, and users would have to reinstall.

Browser support

Support data as of September 2026. The versions are the first releases that support each feature, from MDN's browser compatibility data (also on caniuse.com).

Feature used by Pocket Notes Chrome and Edge Firefox Safari (macOS) Safari (iOS/iPadOS) Samsung Internet
Service workers and Cache Storage (later of the two) ✅ 43 ✅ 44 ✅ 11.1 ✅ 11.3 ✅ 4.0
IndexedDB ✅ 24 ✅ 16 ✅ 8 ✅ 8 ✅ 1.5
updateViaCache ✅ 68 ✅ 57 ✅ 11.1 ✅ 11.3 ✅ 10.0
BroadcastChannel ✅ 54 ✅ 38 ✅ 15.4 ✅ 15.4 ✅ 6.0
crypto.randomUUID() ✅ 92 ✅ 95 ✅ 15.4 ✅ 15.4 ✅ 16.0
navigator.storage.persist() ✅ 55 ✅ 57 (prompts) ✅ 15.2 ✅ 15.2 ✅ 6.0
display-mode media query ✅ 42 ✅ 47 ✅ 13 ✅ 12.2 ✅ 4.0
env(safe-area-inset-*) ✅ 69 ✅ 65 ✅ 11.1 ✅ 11.3 ✅ 10.0
dvh units ✅ 108 ✅ 101 ✅ 15.4 ✅ 15.4 ✅ 21.0
Manifest-based installation ✅ ⚠️ Android only1 ✅ 17 (Add to Dock) ✅ (Add to Home Screen) ✅
Manifest shortcuts ✅ 96 (Android 84) ❌ ✅ 17.4 ❌ ✅ 14.0
beforeinstallprompt / appinstalled ✅ ❌ ❌ ❌ ✅

The Chrome and Edge column lists Chrome versions. Edge supports every feature in it from version 79, its first Chromium-based release, or from the same version number as Chrome for later features.

Every feature in the table is optional at runtime. Without service worker support, the app is an online notes page. Without BroadcastChannel, other tabs update on their next reload. Without beforeinstallprompt, the install button never appears.

Common pitfalls

Symptom Cause Fix
The worker never installs; DevTools shows it as redundant cache.addAll() failed because an APP_SHELL URL returns 404 or 500 Open each URL. npm run stamp also fails on a missing file.
Registration fails with SecurityError sw.js served with a non-JavaScript MIME type, or the requested scope is above the script's directory Fix the server's MIME type; keep sw.js at the app root.
Registration fails on the live site only sw.js is redirected, for example by a trailing-slash or host-canonicalization rule that also matches the script URL Serve sw.js directly with a 200; register it with the final URL.
Changes never reach users VERSION wasn't changed, so sw.js is byte-identical and no update installs Run npm run stamp before every deploy.
The installed app doesn't start offline Navigation matching without ignoreSearch, so /?source=pwa misses the cached ./ Use ignoreSearch: true for navigations (or precache the exact start_url).
Offline fallback fails with net::ERR_FAILED The precached offline.html is a redirected response (clean-URL hosts) Copy redirected responses with stripRedirect() before using them for navigations.
Offline page is unstyled at nested URLs Relative URLs resolve against the requested URL, not offline.html Make the fallback self-contained (inline CSS, computed home link).
The page reloads on the first visit controllerchange from clients.claim() treated as an update Reload only if the page was controlled before (hasController).
The update toast never appears during development DevTools Update on reload is on, so new workers skip waiting Untick it when you test the update flow.
The install button never appears beforeinstallprompt listener attached too late, criteria not met, app already installed, or a non-Chromium browser Attach the listener synchronously at startup; check Application > Manifest > Installability.
prompt() rejects with NotAllowedError Called outside a user gesture Call it directly in the click handler.
Service workers don't register on a phone Testing over http://192.168.x.x, which isn't a secure context Use chrome://inspect port forwarding or an HTTPS preview deployment.
All notes vanish on iPhone after a week away Safari's seven-day cap on script-writable storage for sites used in the browser Install to the Home Screen, which exempts the app; sync important data to a server.

Pitfalls & Anti-Patterns covers the service worker traps in general, and the FAQ answers the most common installation questions.

The final file listing

These are the complete source files, each shown in full in the step that introduces it:

pocket-notes/
pocket-notes/
├── package.json                 Step 1
├── icon-src/
│   ├── maskable.svg             Step 6
│   └── source.svg               Step 6
├── tools/
│   ├── make-icons.mjs           Step 6
│   ├── smoke-test.mjs           Step 11
│   └── stamp-version.mjs        Step 12
└── public/
    ├── _headers                 Step 13
    ├── app.js                   Step 5
    ├── db.js                    Step 4
    ├── index.html               Step 2
    ├── manifest.webmanifest     Step 7
    ├── offline.html             Step 8
    ├── pwa.js                   Step 9
    ├── serve.json               Step 10
    ├── styles.css               Step 3
    ├── sw.js                    Step 8 (VERSION stamped in Step 12)
    ├── icons/                   generated in Step 6
    │   ├── apple-touch-icon.png     180 x 180
    │   ├── favicon-32.png            32 x 32
    │   ├── favicon.svg
    │   ├── icon-192.png             192 x 192, purpose any
    │   ├── icon-512.png             512 x 512, purpose any
    │   ├── maskable-192.png         192 x 192, purpose maskable
    │   ├── maskable-512.png         512 x 512, purpose maskable
    │   └── shortcut-new-96.png       96 x 96
    └── screenshots/             captured in Step 6
        ├── narrow.png               750 x 1334
        └── wide.png                1280 x 800
File Lines Role
package.json 17 Dev dependencies and npm scripts
icon-src/source.svg 11 Artwork for any icons and the SVG favicon
icon-src/maskable.svg 11 Full-bleed artwork for maskable and Apple icons
tools/make-icons.mjs 34 Renders every PNG icon
tools/stamp-version.mjs 32 Writes a content hash into VERSION
tools/smoke-test.mjs 66 Automated installability and offline checks
public/index.html 83 App page, head tags, templates
public/styles.css 191 Layout, dark mode, safe areas, standalone tweaks
public/db.js 110 IndexedDB wrapper
public/app.js 221 UI, notes CRUD, drafts, launch handling
public/pwa.js 174 Registration, update toast, install UI
public/sw.js 156 Precache, routing, offline fallback, cleanup
public/offline.html 54 Self-contained offline fallback page
public/manifest.webmanifest 45 Identity, icons, display, screenshots, shortcuts
public/serve.json 17 Local server settings and headers
public/_headers 13 Production headers (Netlify, Cloudflare Pages)
Total 1235

Next steps: where to go after your first PWA

Pocket Notes is a complete foundation, and each missing feature is a page on this site:

Further reading

On this site

External references


  1. Firefox for Android installs manifest-based apps as home-screen shortcuts. Firefox on Windows can pin any site to the taskbar as a web app since Firefox 143, but doesn't use installability criteria or fire install events. See Installability Criteria. ↩