0%
Add-On · PWA
AI Clarity · PWA Add-On

Personalise your PWA add-on

The 9 segments — manifest, service worker, update trap, cache strategies, installability, iOS caveats, offline UX — identical either way. Personalised retunes every example and tutor reply to your role and sector.

or

EverythingThreads · ICO: C1896585 · Privacy Policy

Segment 1 of 9 · PWA Add-On
By the end of this segment you will be able toExplain what a Progressive Web App is, when it beats a native app and a plain website, and what your BUILD tool will look like by the end of this add-on.
Explain what a PWA is in one sentence, articulate when a PWA beats a native app and when it doesn't, describe the two files that turn your existing BUILD tool into an installable app, and understand what's different about the iOS and Android installation experience. This segment is the mental-model segment. Everything else depends on these four things landing.

What Is a PWA

⏱ ~15 min• The mental model

Your BUILD tool is a website. Users type the URL, they use the tool, they close the tab. That's fine — lots of useful tools work that way. But websites have a distribution ceiling: users have to remember the URL, they have to have Wi-Fi to use it, and there's no home-screen icon that makes the tool part of their routine.

A Progressive Web App is the same website with two files added: a manifest.json that tells the browser how the tool should look when installed, and a service-worker.js that caches the tool so it works offline. Add those two files and your live URL becomes something a user can install on their phone's home screen, their laptop's dock, or their Windows start menu — with an icon, a splash screen, and offline access. Same code. No app store. No review process. Same git push workflow you already have.

What you'll have at the end of 9 segments: your BUILD tool installed as an app on your phone. Same tool, now reachable from your home screen. Works on the subway when your phone has no signal. Updates automatically when you push new code. And the code for it is 50-80 lines across two new files.
Your Stack — One Codebase, Three Surfaces
BUILD Tool
Live website
PWA
Building now · 2 new files
Installed
On every device

The lit box is the unlock. No backend changes. No new server. No new framework. Your existing BUILD tool — the one with the deployed Worker and the live Netlify URL — stays exactly as it is. This add-on is purely front-end additions to make the existing tool installable. When you're done, the same URL users visit today becomes installable from every modern browser on every modern device.

The one hard requirement: your BUILD tool must be on HTTPS. Netlify gives you HTTPS automatically, Cloudflare Pages does too, GitHub Pages does too. If your tool is running on a plain HTTP URL anywhere other than localhost, service workers won't register and nothing in this add-on will work. If you followed BUILD, you're already on HTTPS and this is a non-issue.
BUILD prerequisite checklist — verify before continuing
  • ✓ Website live at an HTTPS URL — try loading it in a fresh incognito window; it loads.
  • ✓ Worker deployed — your BUILD Cloudflare Worker is at its own URL (your-worker.your-account.workers.dev or a custom domain).
  • env.ANTHROPIC_API_KEY set via wrangler secret put — from BUILD Seg 11. The Worker's Claude calls work when you test the site.
  • ✓ Model string claude-sonnet-4-6 in your Worker's fetch body — this add-on doesn't change the Worker but we'll reference the model later.
  • ✓ CORS configured — if your Worker is on a different origin from your website (common when using *.workers.dev), the Worker must respond with Access-Control-Allow-Origin headers matching your site's origin. If BUILD's interface works from the deployed website today, this is already set up correctly; don't touch it.

If any line above fails, return to BUILD Seg 11 before continuing. Every subsequent segment assumes these are true.

PWA vs Native vs Website — the decision that shapes the add-on

These three shapes look similar at a high level. The differences determine which one fits your project. The Chrome Extension add-on had a similar comparison — here's the one that matters for this add-on.

Website (BUILD)
URL in a tab
Accessed through a browser. No install. No offline. Discovery via SEO and shared links.
Ship time: seconds (git push). Distribution cost: zero.
PWA (this add-on)
Installable website
Same URL. Users can install to their home screen. Works offline. Push notifications on most platforms. No app store.
Ship time: seconds. Distribution cost: zero. 2 new files.
Native
App Store app
Separate codebase per platform (Swift/Kotlin or React Native). App Store approval. Deep device access.
Ship time: days-weeks. Apple $99/yr. Google Play $25 one-off.

The decision rule. Need Bluetooth, NFC, background audio mixing, or tight OS integration? Build native. Don't need any of that and want one codebase across every device with the same URL-based update model as your website? Build a PWA. Hybrid cases (want the PWA benefits now, plan for native later) are fine — you can always add native later without losing the PWA.

The iOS reality. Apple supports PWAs, but less enthusiastically than Google does. iOS doesn't show an install prompt — users have to tap Share → Add to Home Screen manually. Storage limits are tighter. Background Sync isn't supported. We'll cover the specific iOS gotchas in Segment 7, but the short version: your PWA will work on iOS, just with more caveats and a worse install UX than Chrome on Android.
What ships at the end of 9 segments

Concrete output from the add-on, so you know exactly what you're working toward:

A manifest.json file
30 lines of JSON that describes your app: name, icon, theme colour, how it launches. Segment 2.
A sw.js file (service worker)
~80 lines of JS that handles caching, offline behaviour, and updates. Built up across Segments 3-5.
A passing installability check
Chrome's install prompt fires on your tool's URL. Segment 6.
Your BUILD tool installed on your own devices
Icon on your phone's home screen and your laptop's dock. Offline indicator appears when you lose connection. Segments 7-8.
A README + portfolio one-liner
Your project's README now documents the PWA, with install instructions for users. Segment 9.

No frameworks. No build step beyond what you already have. No new servers. The PWA is purely two static files added to your existing deployed project.

Segment 2 of 9 · PWA Add-On
By the end of this segment you will be able toWrite a complete, valid manifest.json for your tool, generate all required icon sizes, and link it correctly from your HTML.
Write a complete, valid manifest.json for your tool with every required field filled in honestly, generate all required icon sizes (192, 512, apple-touch-icon, and optionally maskable variants), link the manifest correctly from your HTML, and verify it loads without errors in Chrome DevTools' Application tab. After this segment the browser knows what your app is called and how it should look when installed — everything after builds on this.

manifest.json · The Identity File

⏱ ~25 min• First new file

The manifest is a small JSON file. It tells the operating system — whatever OS your user is on — how your app should behave when installed. Name. Short name. Icon at various sizes. Theme colour for the status bar. How the app should launch (standalone window? fullscreen? minimal UI?). What URL it should open at.

This segment writes the manifest, generates the icons, and links it from your existing HTML. Nothing functional happens yet (that's the service worker in Segment 3) — but after this segment, if a user visited your tool in Chrome and you manually triggered the install via the menu, the install dialog would pop up with your name and icon. You're halfway to installable.

Important framing. The manifest is declarative — you're describing what you want, not telling the browser how to do anything. No logic. No events. No callbacks. Just a JSON object with carefully chosen values. This makes it simple to write and easy to get wrong — the browser silently ignores fields it doesn't recognise, so typos produce no errors, just missing features.
Step 1: Write the manifest

Create a file called manifest.json at the root of your project (same directory as index.html). Here's every field you actually need, with inline comments explaining what each does. Edit the values for your tool — the field names must stay exactly as they are.

manifest.json — annotated, every field explained
{
  "name": "My AI Tool",
  // Full name. Shown on splash screen, in install dialogs. Max 45 chars useful.

  "short_name": "AI Tool",
  // Shown under the icon on the home screen. Max 12 chars before truncation on iOS.

  "description": "AI-assisted [whatever your tool does]",
  // One sentence. Shown in install prompts on some platforms.

  "start_url": "/",
  // Where the app opens when launched from the home screen. "/" is usually right.
  // If your tool lives at /app, use "/app". Must be within the scope.

  "scope": "/",
  // Which URLs the app considers "its own". "/" = everything on your domain.

  "display": "standalone",
  // standalone = own window, no URL bar. Most app-like. Also: fullscreen, minimal-ui, browser.

  "background_color": "#0b0b0c",
  // Splash screen colour shown briefly during app launch. Match your main background.

  "theme_color": "#ff6a1f",
  // Colour of the address bar / system UI when the app is open. Your accent colour.

  "orientation": "any",
  // Lock to portrait/landscape? "any" = respects device rotation. Usually right.

  "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/icon-512-maskable.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
      // Used when Android wants to force a circular/squircle shape. Center your logo with padding.
    }
  ]
}

The minimum viable manifest is six fields: name, short_name, start_url, display, theme_color, icons. Everything else is either optional or derived. Chrome's install criteria check the manifest for these exact fields; if any are missing, the install prompt never fires and the Lighthouse installability check fails.

Trailing-comma trap. JSON doesn't allow trailing commas. A trailing comma after the last field, or after the last icon in the array, produces a silent parse failure — the browser acts as if the manifest doesn't exist. If nothing in this add-on works and you can't figure out why, open the manifest in VS Code and look at the syntax highlighting. A stray red curly bracket is the problem 60% of the time.
Step 2: Generate the icon set

The manifest references three icon files. You need to produce them, which means you need one square source image at least 512×512 pixels — your logo, a wordmark, a symbol, anything that works small. Then you generate the other sizes from it. Don't do this manually unless you enjoy exporting 6 sizes from Figma one at a time.

Upload your 512×512 source. It generates every size for every platform (Chrome, iOS, Windows tiles), plus a manifest snippet. Free. Been around for years. The standard tool.
Tool option 2: pwa-asset-generator (npx)
Command-line tool you can run locally. npx pwa-asset-generator logo.svg ./icons generates everything including iOS splash screens. Good for CI pipelines.
Tool option 3: Direct Claude
For a simple geometric/text logo, ask Claude to generate the SVG, then convert to PNG at each size. See the prompt template below.
Direct Claude — icon set from scratch, no design tool
# If you don't have a logo yet, this gets you a usable one in 5 minutes:

I need a PWA icon for my tool called "<YOUR_TOOL_NAME>". It should be:

- A simple geometric shape or wordmark — no photorealism, no gradients
- Recognisable at 16×16 pixels (the smallest size browsers use)
- High contrast (my theme colour is <#YOUR_HEX> on a <light/dark> background)
- Work as both a regular icon AND a "maskable" icon (meaning the important
  content fits inside a circle with 20% padding from the edges)

Give me:
(1) An SVG I can save as icon.svg — scalable, hand-editable
(2) A one-line description of why you made the design choices you did
(3) One suggestion for a variant I could try if I don't like the first

Plain SVG, no external fonts, no embedded images. Use only SVG primitives
(circle, rect, path, text). Keep total SVG under 2 KB.

Then convert the SVG to PNG at the sizes you need. Use realfavicongenerator.net or any online SVG-to-PNG tool. Drop the generated icon-192.png, icon-512.png, and icon-512-maskable.png into an icons/ folder at your project root. Paths in the manifest should match.

The maskable icon is the one most people skip. Android 12+ applies its own shape mask to PWA icons — circular, squircle, teardrop. If you only provide a "regular" icon, Android crops your logo to the mask and often cuts off the edges. The maskable variant explicitly tells Android "here's my logo safely inside 80% of the canvas; mask the outer 20% however you want." Without it, your icon looks cut-off on half of Android devices.
Step 3: Link the manifest + add iOS meta tags

Your existing index.html needs two additions: a <link rel="manifest"> tag pointing at the JSON file, and a handful of iOS-specific meta tags that Apple hasn't added manifest support for. Add these inside the <head>:

index.html — head additions
<!-- Core PWA manifest link -->
<link rel="manifest" href="/manifest.json">

<!-- Theme colour for browsers that read it from meta (some still do) -->
<meta name="theme-color" content="#ff6a1f">

<!-- iOS-specific: Safari doesn't read manifest.json for home screen icons -->
<link rel="apple-touch-icon" href="/icons/icon-192.png">

<!-- iOS-specific: tell Safari this is a web app that can run standalone -->
<meta name="apple-mobile-web-app-capable" content="yes">

<!-- iOS-specific: status bar style when launched from home screen -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">

<!-- iOS-specific: title shown under the icon on iOS home screen -->
<meta name="apple-mobile-web-app-title" content="AI Tool">

<!-- Viewport: essential for mobile. You probably already have this. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

The apple-touch-icon is critical. iOS Safari ignores your manifest's icon array entirely. If you don't have an apple-touch-icon link tag, iOS generates a placeholder icon from a screenshot of your page — usually illegible and always ugly. Every PWA that works on iOS has this tag; every PWA that ships with "why does my iPhone icon look terrible" forgot it.

SHARP M2 The Missing ContextAsk Claude "make my site a PWA" and it'll give you the manifest and skip the iOS meta tags.
Default PWA tutorials skip the apple-touch-icon and friends because they're iOS-specific additions that aren't technically part of the PWA spec. Claude does the same by default. Users install the PWA on iPhone, see a garbled placeholder icon, and assume the PWA is broken. It's not — it's just missing 6 meta tags Apple never adopted into the shared spec. When directing Claude, explicitly name the platforms: "Give me the manifest.json AND the iOS-specific meta tags AND the Windows tile meta tags." Specificity prevents the omission.
Checkpoint — Manifest Loads Cleanly
Deploy your change (git add . && git commit -m "add PWA manifest" && git push). Wait for Netlify to finish. Open your deployed site, open DevTools → Application tab → Manifest. Does it show your manifest fields without errors, and all three icon previews render?
First new file landed. The browser now knows what your app is called, how it should look when installed, and which icon to use. Nothing visible has changed for users — the install prompt won't fire yet because you need the service worker too (Segment 3). But the foundation is in place.
▲ VERIFICATION LAYER · Manifest Sanity
Common ways AI gets THIS wrong:
  • Generates manifest with a trailing comma on the last icon entry — silent parse failure, browser acts as if manifest doesn't exist
  • Writes "display": "standalone" but forgets "start_url" — install prompt never fires, Lighthouse fails installability
  • Uses relative paths like icons/icon.png instead of /icons/icon.png — breaks on subpaths
  • Omits the maskable icon — Android crops your logo ugly on most devices
The 30-second check: DevTools → Application → Manifest shows all fields. All 3 icon previews render. No red "errors and warnings" section. View-source shows the link+apple-touch-icon tags are in the deployed HTML.
Segment 3 of 9 · PWA Add-On
By the end of this segment you will be able toExplain the service worker lifecycle, write a minimal sw.js that caches the app shell and serves it offline, register it from your page, and verify it's controlling the page.
Explain the service worker lifecycle (install → waiting → activate → fetch) in plain English, write a minimal sw.js that caches your app shell on install and serves from cache on fetch, register it from your main page, and verify in DevTools that it's controlling the page. After this segment your tool loads without a network connection — as long as the user has visited it once.

Service Worker Basics

⏱ ~35 min• Second new file

A service worker is a JavaScript file that runs separately from your page, in its own thread, in the background. It can intercept every network request the page makes and decide whether to serve it from cache, from the network, or both. That's the entire magic of PWAs — the service worker sits between your page and the network and makes decisions about what to return.

This segment writes the minimum viable service worker: it caches your app shell (HTML, CSS, JS, core assets) during installation, and serves them from cache whenever the page requests them. That alone makes your tool load instantly on repeat visits and work offline after the first visit. Everything in Segments 4 and 5 is about getting more sophisticated than this — but this baseline is already useful.

Mental model. Think of the service worker as a programmable proxy that lives on the user's device. When your page asks for style.css, the request goes to the service worker first. The SW decides: do I have this cached? Is the cache still fresh? Should I go to the network anyway? Then it returns something — cache or network — to the page. The page never knows which it was.
If you already did the Chrome Extension add-on

You already know the service-worker mental model from Chrome Ext Seg 8. What transfers: the lifecycle (install → waiting → activate → fetch), the "terminates when idle" behaviour, the "state lives in storage, not variables" discipline. What doesn't transfer: the APIs. Chrome Ext SWs talk to extension contexts via chrome.runtime.sendMessage; PWA SWs talk to page contexts via self.clients and postMessage. Same dance, different partners. If you hit something surprising in this segment, the chances are the concept is the same as Chrome Ext's and only the method name has changed.

The service-worker lifecycle — three events you care about
① install
Fires once, when the SW is registering or when a new version of the SW script is found. This is where you pre-cache your app shell — the files that never change between your deploys (logo, fonts) and the files that change with every deploy (HTML, CSS, JS).
② activate
Fires after install succeeds. This is where you clean up old caches from previous versions of the SW. (For the first version ever, there's nothing to clean — but you'll be glad to have the cleanup scaffold already written when Segment 4 talks about updates.)
③ fetch
Fires on every single HTTP request the page makes — HTML loads, CSS loads, image loads, API calls, everything. This is the 90% of your service worker — what you do here determines how your PWA behaves when the user is offline, on slow wifi, or on a flaky subway train.

The lifecycle between deploys is where most PWA developers get stuck. When you push new code, a new version of sw.js is detected. The new SW installs (downloads, pre-caches new files). Then it waits — the old SW is still controlling the page. Only after every tab using the old SW is closed does the new SW take over. This is the "update trap" Segment 4 is dedicated to fixing; for now, just know the lifecycle exists.

The weird one: service workers persist between tabs and page reloads. Close the tab, reopen it an hour later — the SW is still running in the background, still controlling your pages. This is a feature (makes PWAs fast) but it's also why SW changes don't appear immediately without the right cache-busting. Segment 4 covers this thoroughly.
Step 1: Write the minimum viable service worker

Create a file called sw.js at the root of your project (same directory as index.html and manifest.json). Here's the baseline — cache-on-install, serve-from-cache-with-network-fallback on fetch. Under 40 lines total:

sw.js — minimum viable service worker
// Cache version — bump this string when you deploy changes
const CACHE_NAME = 'ai-tool-v1';

// App shell: files that together can render the tool without a network
const APP_SHELL = [
  '/',
  '/index.html',
  '/style.css',
  '/app.js',
  '/icons/icon-192.png',
  '/icons/icon-512.png'
];

// ① Install — pre-cache the app shell
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(APP_SHELL))
  );
});

// ② Activate — clean up old caches (will matter in Seg 4)
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.filter((key) => key !== CACHE_NAME)
            .map((key) => caches.delete(key))
      )
    )
  );
});

// ③ Fetch — serve from cache, fall back to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((cached) => cached || fetch(event.request))
  );
});

Read the three handlers in order. Install caches your files. Activate removes old caches. Fetch intercepts every request and tries cache first, network fallback. That's the whole service worker. The rest of the add-on adds sophistication to each handler — but this baseline works and is a real PWA.

The APP_SHELL paths must match your actual project. If your main JS is at /js/main.js, update the array. If you're using a build tool that hashes filenames (app.a1b2c3.js), this naive approach won't work — you'll need to either turn off hashing, or generate the SW at build time with the current hashed names. For plain static sites (BUILD's default setup), the paths are literal and this works as written.
Step 2: Register the service worker from your page

The SW file exists now, but nothing has told the browser to install it. Add this to your main JavaScript file (or a <script> tag at the bottom of your HTML). It runs once on page load — checks whether the browser supports SWs, then registers sw.js:

app.js — SW registration
// Register the service worker (run once on page load)
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then((reg) => {
        console.log('SW registered with scope:', reg.scope);
      })
      .catch((err) => {
        console.error('SW registration failed:', err);
      });
  });
}

Three things worth understanding here:

  • The if ('serviceWorker' in navigator) check guards against ancient browsers that don't support SWs. This is vanishingly rare in 2026 but zero cost to include.
  • Register inside load — don't register immediately. You don't want the SW competing with your page's critical rendering path for bandwidth.
  • The /sw.js path matters. The SW can only control URLs at or below its own path. /sw.js controls everything on your domain; /app/sw.js would only control /app/*. Keep it at root unless you have a specific reason.
▸ Scope tricks for more complex setupsWhen you deploy multiple PWAs on subpaths of the same domain, SW scope gets interesting.
If you have yourdomain.com/tool-1/ and yourdomain.com/tool-2/ as two separate PWAs, each needs its own SW at its own path: /tool-1/sw.js and /tool-2/sw.js. The default scope is the SW's own directory. You can widen scope with the Service-Worker-Allowed header, but the default is almost always right. If this is your first PWA, this detail doesn't apply — your SW stays at root and controls everything.
Step 3: The HTTPS requirement (and the localhost exception)

Service workers only register on HTTPS URLs. This is a security requirement, not a browser bug — if a man-in-the-middle attacker could install a malicious SW on your site, they could intercept every user's traffic forever. So HTTPS is non-negotiable.

The rule:
  • Production: must be on HTTPS. Any live URL: yoursite.netlify.app, yoursite.pages.dev, your own domain with SSL. Netlify / Cloudflare Pages / Vercel / GitHub Pages all provide HTTPS automatically.
  • Development: http://localhost and http://127.0.0.1 are exempted. You can test PWAs over plain HTTP locally without faking a certificate. Any other hostname (including 192.168.x.x on your LAN) requires HTTPS.
  • Testing on phones: you can't test your PWA on a phone via your laptop's local IP address over HTTP — the phone isn't on localhost. Deploy a preview, or use ngrok / Cloudflare Tunnel to create an HTTPS tunnel to your localhost.

The implication: to iterate on your PWA efficiently, run your-build-tool dev locally at localhost:XXXX and test in desktop Chrome. The PWA behaves identically there to how it will behave on a real phone — same SW lifecycle, same cache API, same manifest. Only test on a real phone when you're ready to verify iOS/Android specifics (Segment 7).

One infamous gotcha: file:// URLs don't count. If you open index.html directly from your filesystem (double-clicking the file) and then try to register a service worker, it fails silently. The URL bar shows file:///Users/you/project/index.html — not a protocol SWs support. Always serve through something: npx serve, python -m http.server, your real dev server. Never develop against raw file://.
Checkpoint — Service Worker Controls the Page
Deploy. Open your live site in Chrome. Open DevTools → Application → Service Workers. Does it show sw.js with status "activated and is running"? And does the console show "SW registered with scope: ..."?
Second new file landed. Your app now has a service worker pre-caching the shell and serving from cache. Open DevTools → Application → Cache Storage — you'll see a cache called ai-tool-v1 containing every file from your APP_SHELL array. Turn off your Wi-Fi, hit refresh — the page still loads. That's offline capability in 40 lines of JS.
Segment 4 of 9 · PWA Add-On
By the end of this segment you will be able toExplain why PWAs don't update by default, implement cache versioning properly, and force users onto new code within one page load — the single highest-ROI segment in this add-on.
Explain in plain English why a PWA you pushed 3 days ago is still showing the old code to users today, implement cache-name versioning that cleans up old caches correctly, use skipWaiting and clients.claim to force fast updates, and know when to use those patterns vs. letting the default waiting behaviour play out. This segment is the single highest-ROI segment in the add-on — the update trap is why most hobby PWAs get abandoned within a month.

The Update Trap

⏱ ~30 min• The #1 PWA abandonment point

You push new code. You refresh your PWA. You see the old version. You refresh again. Still old. You clear your browser cache — finally, the new version. You conclude PWAs are broken and go back to plain websites. You're not alone — this is the most common reason hobby PWAs get abandoned, and it's almost entirely about one specific gotcha in the service worker lifecycle.

The cause isn't a bug. It's deliberate browser behaviour that makes sense for stable user experience (nobody wants their app to change under them mid-session) but makes sense for developers only once you understand it. This segment explains the behaviour, then adds ~15 lines of code that make updates work the way you'd naively expect.

Read this before you start debugging. If you're already in the "my PWA won't update" pit, the fastest escape is: DevTools → Application → Service Workers → Unregister. Then hard-refresh. That nukes the SW and forces a clean install. Fine for dev; terrible for production. The rest of this segment is about getting the production behaviour right so you never need to tell users "unregister my service worker in DevTools."
Blast radius — borrowed from the Automation add-on. A service worker update is the highest-blast-radius deploy in web development. A bad server deploy you can roll back with one command. A bad SW deploy rolls out to every user who opens your PWA in the next ~30 minutes, and then sits in their browser until each user's device next visits your site and downloads a corrected version. There is no "rollback" — only "ship a fix and wait for it to propagate." Before every SW change, ask the Automation Seg 9 question: "if this runs incorrectly for 10,000 users simultaneously, what breaks?" For cache-strategy changes the answer is usually "nothing serious — they get stale content for a day." For fetch-intercept changes that modify POST bodies or auth headers, the answer can be catastrophic. Treat SW changes with the same discipline Automation reserves for cron jobs.
Why the default update behaviour is slow

Here's what actually happens when you git push a change to your PWA:

T+0s: User opens the PWA
Old SW serves every request from cache. Page loads fast. Old code runs.
T+1s: Browser checks sw.js in background
Fetches /sw.js over the network (not from cache — this specific file bypasses the cache). Compares byte-for-byte with the currently-installed SW. If different, marks the new version as "installing".
T+2s: New SW installs, enters "waiting"
New SW's install event fires — it pre-caches the new app shell. Then it waits. The old SW is still controlling the page. The new SW is downloaded, cached, but idle.
T+?: User closes every tab using the old SW
Only when every tab is closed (and, for PWAs installed to home screen, the app is force-quit) does the new SW take over. This can be days for users who keep tabs open or leave the PWA running in the background.
Eventually: new SW activates
User opens a fresh session. New SW takes over. New code runs. Cache version is bumped to the new one.

Why this default exists: Google decided "don't change the app under the user mid-session" is safer than "always run the latest code." For an email app or a document editor, changing the code under the user could break their in-progress work. For most tools, the waiting period is fine. For some, it's unacceptable.

Step 1: Cache versioning — the foundation

The first fix, which you already half-did in Segment 3: give every deploy a different cache name. When you ship new code, change 'ai-tool-v1' to 'ai-tool-v2'. The new SW creates a fresh cache, the activate handler deletes the old v1 cache, and you're not serving stale files from a cache that doesn't match the current code.

sw.js — cache version bump (manual version)
// Bump this string every time you deploy non-trivial changes
const CACHE_NAME = 'ai-tool-v2';  // ← was v1

const APP_SHELL = [
  '/',
  '/index.html',
  '/style.css',
  '/app.js',
  '/icons/icon-192.png',
  '/icons/icon-512.png'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.filter((key) => key !== CACHE_NAME)
            .map((key) => caches.delete(key))
      )
    )
  );
});

The cache name is a string — the actual value doesn't matter, only that it's different. Some developers use semver (ai-tool-v1.2.3), some use commit hashes (ai-tool-a1b2c3d), some use build timestamps (ai-tool-20260416). All work. The commit-hash approach is best if you're happy to generate the SW at build time; the manual-bump approach works fine for small projects.

Direct Claude — automatic cache versioning from git commit
# When you're tired of remembering to bump the version manually,
# paste this into Claude:

I have a static PWA deployed via Netlify. My sw.js has a hardcoded
CACHE_NAME constant that I have to bump manually on every deploy, which
I keep forgetting.

Write me a pre-deploy script (plain Node.js, no dependencies) that:

1. Reads the current git commit hash (short form, 7 chars)
2. Reads my sw.js file
3. Replaces the CACHE_NAME value with `ai-tool-<commit_hash>`
4. Writes the updated sw.js back to disk

Then show me:
(a) The Node.js script
(b) How to wire it into Netlify's build command in netlify.toml so it
    runs before the deploy but uses the real git HEAD, not Netlify's
    post-checkout state
(c) What happens on a deploy of the SAME commit (script should be idempotent)
Don't version-bump cosmetic changes. Every cache bump forces every returning user to re-download the entire app shell. For a CSS tweak that affects one colour, this is overkill. Rough rule: bump on anything that changes app.js or the HTML structure. Don't bump on a favicon update.
Step 2: skipWaiting + clients.claim — force faster updates

Cache versioning fixes stale files but doesn't fix the timing. A new SW still sits in "waiting" until every tab using the old one is closed. For most tools that's fine. For tools where you want updates to land as soon as possible, add these two calls:

sw.js — aggressive update behaviour
const CACHE_NAME = 'ai-tool-v2';
const APP_SHELL = [/* ... same as before ... */];

self.addEventListener('install', (event) => {
  self.skipWaiting();  // ← tell the new SW to become active immediately
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.filter((key) => key !== CACHE_NAME)
            .map((key) => caches.delete(key))
      )
    ).then(() => self.clients.claim())  // ← take control of open pages
  );
});

What each one does:

  • self.skipWaiting() in install — don't wait for old tabs to close. Activate as soon as installation completes.
  • self.clients.claim() in activate — take control of already-open tabs, not just new ones. Without this, even after skipWaiting, existing tabs keep using the old SW until navigated.

Together, they collapse the update timeline from "whenever the user closes the tab" to "on the next page load after the deploy." That's the behaviour most developers actually want. The trade-off: if a user has unsaved work in a tab and you push an update, the next action they take runs the new code. For a text-input form, this is usually fine. For a half-filled shopping cart, it can be dangerous.

The skipWaiting footgun. If your old app sent a message to a handler that exists in the old code, and the new code removed that handler — skipWaiting means the next click in the user's open tab fails, because the SW just changed under them. For the kind of tool BUILD teaches (stateless, per-request), this rarely bites. For anything stateful, think carefully about whether you want this behaviour or the default.
Step 3: The polite alternative — show an update banner

The most production-grade pattern isn't skipWaiting — it's letting the new SW wait, detecting that it's waiting from your page, and asking the user "an update is available, reload to apply?" That keeps the user in control while making updates fast.

app.js — detect waiting SW and prompt user
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').then((reg) => {
    
    // Check for updates every time the user focuses the page
    window.addEventListener('focus', () => reg.update());
    
    // Listen for new SW installing
    reg.addEventListener('updatefound', () => {
      const newWorker = reg.installing;
      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // New SW installed and waiting; old one still controlling
          showUpdateBanner();
        }
      });
    });
  });
}

function showUpdateBanner() {
  const banner = document.createElement('div');
  banner.style.cssText = 'position:fixed;top:0;left:0;right:0;padding:12px;background:#ff6a1f;color:white;text-align:center;font-family:system-ui;z-index:9999';
  banner.innerHTML = 'A new version is available. <button onclick="location.reload()" style="margin-left:12px;padding:4px 12px">Reload</button>';
  document.body.appendChild(banner);
}

Now you get the best of both worlds: users see updates within seconds of a deploy (because the page calls reg.update() on focus), but they control when to apply them. No work lost, no surprise reloads, clear communication that there's something new.

The update-banner pattern matches how most professional PWAs behave. Gmail shows you a "Reload for new version" banner. Google Docs does the same. Twitter/X does too. You're not building a hack — you're matching the production standard.
Checkpoint — Updates Land Correctly
Make a visible change to your app (change a heading, swap a colour). Bump CACHE_NAME to v3. Deploy. Reload the live site. Does the update appear within one reload (if using skipWaiting) or prompt the user via banner (if using the polite pattern)? And does DevTools → Cache Storage show only the new cache, with the v2 cache deleted?
You've solved the single biggest failure mode for production PWAs. From here on, your deploy workflow is: make change → bump CACHE_NAME → push → users get the update within one page load. This is the point at which your PWA starts behaving like users expect from a modern app.
▲ VERIFICATION LAYER · The Update Cycle
Common ways AI gets THIS wrong:
  • Writes cache versioning without the activate-handler cleanup — old caches accumulate forever, eventually hitting storage quota
  • Uses skipWaiting() without cache versioning — new SW serves old cached files indefinitely
  • Forgets that sw.js itself is browser-cached by default; Netlify / Cloudflare HTTP caching interferes with SW updates
The 30-second check: DevTools → Application → Cache Storage shows only the current CACHE_NAME. Service Workers tab shows "activated" not "waiting". Network tab shows sw.js with a 200 response not 304 after deploy.
Segment 5 of 9 · PWA Add-On
By the end of this segment you will be able toExplain the five standard cache strategies, pick the right one for each resource type in your tool, and implement route-based strategy selection in your service worker.
Explain the five standard cache strategies (cache-first, network-first, stale-while-revalidate, cache-only, network-only), pick the right one for each resource type (app shell / AI responses / images / analytics), and implement route-based strategy selection in your fetch handler. This is the segment where your PWA stops being "a website that works offline" and starts being "a website that makes smart decisions about freshness." This is also where Claude gets things wrong by default.

Cache Strategies

⏱ ~40 min• The judgment call

Your service worker in Segment 3 used one strategy for every request: cache-first-with-network-fallback. That's fine as a starting point, but it's wrong for most things. It's right for your HTML and CSS (cache them forever until you bump the version). It's wrong for API calls to Claude (you'd serve users last week's AI response). It's wrong for images that might update. It's wrong for analytics (never cache those).

This segment teaches the five standard strategies, shows when to use each, and applies them to different route patterns in your tool. Once you have this mental model, you can look at any PWA problem and reason about what the service worker should do.

The honest framing. Cache strategy is where PWA correctness actually lives. A PWA with wrong strategies serves stale data confidently — worse than a website that just fetches fresh every time. Getting this right is the difference between "useful app" and "app users silently distrust."
The five strategies, each in one sentence
① Cache-first
Check cache. If hit, return immediately. If miss, fetch network, put in cache, return. Best for things that rarely change — logo, fonts, app shell when versioned. Fastest; works offline; can serve stale if you forget to version.
② Network-first
Try network. On success, update cache and return. On failure (timeout or offline), return cached copy. Best for content that changes frequently — API responses, news feeds, anything where stale is worse than slow.
③ Stale-while-revalidate (SWR)
Return cache immediately. In parallel, fetch network and update cache for next time. Best middle ground — fast first paint (cache), fresh on second visit. Great for images, avatars, non-critical dynamic content.
④ Cache-only
Return from cache. If not cached, fail. Rare in practice — useful for known-offline-first assets that must match the current SW version exactly. App shell occasionally.
⑤ Network-only
Skip cache entirely, go to network. Must-have for: analytics endpoints, auth, login, anything where cached responses would be a bug. Effectively "service worker stays out of the way."

The four most common are ①②③⑤. Cache-only is a curiosity for specific setups. You'll use cache-first for your shell, network-first for your AI API, stale-while-revalidate for images, and network-only for analytics.

Decision framework — which strategy for which request

Pick the strategy by asking three questions about the resource:

  1. Does the user care more about speed, or freshness?
    Speed wins → cache-first. Freshness wins → network-first. Both matter → stale-while-revalidate.
  2. What's the cost of serving a stale version?
    Harmless (a logo) → cache-first. Confusing (yesterday's AI output) → network-first. Dangerous (stale auth state) → network-only.
  3. Can this resource change without a deploy?
    No (CSS, app.js — changes with your version bump) → cache-first + version naming. Yes (user-generated content, AI responses) → network-first or SWR.

Applied to your tool:

/index.html, /style.css, /app.jscache-first (app shell, versioned)
/icons/*, /fonts/*cache-first (rarely changes)
/api/* or https://your-worker.dev/*network-first (AI responses must be fresh)
/images/user-upload-*stale-while-revalidate (fast + eventually fresh)
Analytics beacons → network-only (or just don't intercept them at all)
Direct Claude — pick strategy for new routes
# When your tool adds a new feature, use this to decide the strategy:

I'm adding a new feature to my PWA. The feature makes HTTP requests to:
<LIST_URLS_OR_ROUTE_PATTERNS>

For each one, tell me:
(1) Which cache strategy (cache-first / network-first / SWR / network-only)
(2) Why — based on freshness needs, speed needs, and the cost of a stale
    response to the user
(3) What's the worst-case failure mode if I pick wrong (e.g., "user sees
    yesterday's data and is confused", "analytics events lost", etc.)

Apply the decision framework from a production-PWA perspective — err on
the side of network-first for anything user-facing that changes, because
a stale-response bug is harder to diagnose than a slow-request bug.
Code: cache-first (the app shell pattern)

This is what Segment 3's service worker already does for every request. Here's the same pattern, extracted into a named function so you can call it for specific routes:

sw.js — cache-first helper
async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;

  const response = await fetch(request);
  // Put successful responses in the cache for next time
  if (response.ok) {
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    // .clone() because response streams can only be read once
  }
  return response;
}
The response.clone() thing is subtle but critical. A Response object has a body stream that can only be read once. If you return response and also cache.put(request, response), one of them consumes the stream and the other gets an empty body. The fix: response.clone() for the one you put in cache. Without this, either your page sees empty responses, or your cache gets empty entries, depending on the order.
Code: network-first (for AI API calls)

The pattern for requests where freshness is critical but you want a fallback when offline. Used for your AI calls — you want the latest Claude response, but if the user is on the subway you'd rather show yesterday's response than a network error:

sw.js — network-first with timeout
async function networkFirst(request, timeoutMs = 3000) {
  try {
    // Race the network against a timeout
    const networkResponse = await Promise.race([
      fetch(request),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error('timeout')), timeoutMs)
      )
    ]);
    // Success — update cache and return
    if (networkResponse.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (err) {
    // Network failed — fall back to cache
    const cached = await caches.match(request);
    if (cached) return cached;
    // No cache either — return an honest offline response
    return new Response(JSON.stringify({ error: 'offline' }), {
      status: 503,
      headers: { 'Content-Type': 'application/json' }
    });
  }
}

The 3-second timeout is the key detail. Without it, fetch() in the SW doesn't time out until the OS does — which can be 30+ seconds on a bad mobile connection. Your user sits staring at a spinner. With a 3s timeout, you fall back to cached-or-offline within 3 seconds and show the user something useful. Adjust the timeout based on your API's usual response time; Claude responses typically take 2-8 seconds, so 3s might force cached fallback too eagerly — 8-10s is probably a better floor for AI-heavy routes.

SHARP M1 The Confident ShipDefault PWA tutorials apply cache-first everywhere. Your AI responses will come back stale and you'll wonder why Claude "keeps repeating itself."
The most common PWA bug for AI-powered apps: someone copies a tutorial that uses cache-first for every request, slaps it on their app, and ships. The AI endpoint returns a response once; that response is now cached forever (or until the cache version bumps). Every subsequent request returns that exact same cached response. The user thinks the AI is broken; the developer thinks the AI is non-deterministic. The fix is route-based strategy selection — apply cache-first to static assets and network-first to anything dynamic, never cache-first everywhere. Claude gets this wrong by default when you ask for "a service worker that caches my site" without specifying what should and shouldn't be cached.
Code: route-based fetch handler (pulling it together)

Now replace your fetch handler with one that picks the right strategy based on the URL pattern. This is the final service-worker structure — the rest of the add-on only tweaks this:

sw.js — route-based strategy selection
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // Analytics — skip SW entirely
  if (url.pathname.startsWith('/analytics') ||
      url.hostname.includes('plausible.io')) {
    return;
  }

  // AI API calls — network-first with 8s timeout
  if (url.hostname.includes('your-worker.dev') ||
      url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request, 8000));
    return;
  }

  // Images — stale-while-revalidate
  if (event.request.destination === 'image') {
    event.respondWith(staleWhileRevalidate(event.request));
    return;
  }

  // Everything else — cache-first (app shell, CSS, JS, fonts)
  event.respondWith(cacheFirst(event.request));
});

async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);
  const fetchPromise = fetch(request).then((response) => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  });
  return cached || fetchPromise;
}

Read it top-to-bottom; it's a routing table. Each if block is a rule. The first match wins. Order matters — put the most specific rules (analytics, API) before the catch-all (cache-first). Add new routes by adding new if blocks. Remove routes by deleting them. This structure scales to tools with 20 different request types without becoming unmaintainable.

Never intercept cross-origin requests you don't need to. Analytics providers often depend on specific headers, redirects, or connection behaviour that your SW's fetch wrapper might break. Defaulting to return (don't call respondWith) means the browser handles the request normally, as if the SW weren't there. Use this for any request you don't have a strong reason to intercept.
Checkpoint — Strategies Are Correct Per Route
Make an AI request through your tool. Open DevTools → Network tab → right-click → "Disable cache", disable. Make the same AI request again. Does it hit the network every time (network-first working)? Now load a CSS file — does it come from the service worker cache (200 with "(ServiceWorker)" label, cache-first working)?
Your PWA now makes informed caching decisions. AI responses stay fresh, app shell loads instantly from cache, images are fast-then-fresh. This is the configuration that turns "website with offline fallback" into "app that behaves sensibly across all network conditions."
Segment 6 of 9 · PWA Add-On
By the end of this segment you will be able toPass Chrome's installability criteria, handle the beforeinstallprompt event, show a custom install button, and debug when the install prompt won't appear.
Understand what makes a PWA installable by Chrome's current criteria, handle the beforeinstallprompt event, show a custom install button that respects user engagement signals, and debug the specific reasons an install prompt fails to appear. After this segment, your PWA is installable from the browser with a real "Install" button.

Installability & Auditing

⏱ ~25 min• Make it installable

You've done the work — manifest, service worker, cache strategies. In Chrome and Edge, that's enough for the browser to recognise your site as installable. The omnibox shows a small "install" icon; click it, confirm, and your site becomes an app with a home-screen icon. This segment makes that automatic install prompt more prominent — instead of a tiny icon, your users get a button in your UI asking if they want to install.

Note on the Lighthouse PWA audit: Chrome deprecated the dedicated "PWA" category in Lighthouse's audit view. The underlying checks still exist — they've moved to the "Installable" section of the Lighthouse report, and to a new "Installability" developer tool in Chrome DevTools → Application. The criteria haven't changed; the UI has. When this segment says "Lighthouse PWA audit," it means "the installability checks, wherever they live in your version of Chrome."
The installability checklist — what Chrome actually verifies

Chrome considers a site "installable" only if all of these are true. If one fails, no install prompt, no install icon. Cross these off one by one:

☐ Served over HTTPS — localhost exempt. If you're on HTTP anywhere else, install prompt never fires.
☐ Has a valid linked manifest.json<link rel="manifest"> in HTML, JSON parses, required fields present.
☐ Manifest contains the required fieldsname (or short_name), start_url, display (must be standalone, fullscreen, or minimal-ui), icons with at least 192×192 and 512×512.
☐ A service worker is registered and has a fetch handler — merely registered isn't enough; the fetch handler must be present even if it's a pass-through.
☐ SW scope covers the start_url — if start_url is / but your SW is registered at /app/sw.js, its scope is /app/, and the start_url is outside it. Install fails.
☐ The user has interacted with your site — at least one click or tap. Prevents spammy sites from auto-installing on visit.

DevTools → Application → Manifest shows any failures in the "Installability" section at the top. This is the single best debugging tool for install problems — it tells you exactly which criterion is missing, in plain English.

Custom install button — beforeinstallprompt

When Chrome detects your site is installable, it fires a beforeinstallprompt event on the window. You capture this event, stash it, and show your own "Install" button. When the user clicks, you call the stashed event's prompt method — that's what triggers the install dialog. You get the standard install UX but under your control:

app.js — capture event and show custom button
let deferredPrompt = null;

window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent the mini-infobar from appearing (Chrome's default)
  e.preventDefault();
  deferredPrompt = e;
  // Show your own install button (hidden by default in CSS)
  document.getElementById('install-btn').style.display = 'block';
});

document.getElementById('install-btn').addEventListener('click', async () => {
  if (!deferredPrompt) return;

  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  console.log(`User ${outcome} the install prompt`);
  
  deferredPrompt = null;
  document.getElementById('install-btn').style.display = 'none';
});

// Fired after a successful install — useful for analytics or UI updates
window.addEventListener('appinstalled', () => {
  console.log('PWA was installed');
  deferredPrompt = null;
});

One subtle thing: beforeinstallprompt fires at unpredictable times — Chrome decides when your site has "sufficient engagement." You can't call prompt() proactively; you can only capture the event when Chrome volunteers it, and use it in response to a user click. This is a deliberate anti-abuse measure. Your install button won't even appear until Chrome thinks the user has engaged enough.

iOS doesn't fire this event. Safari on iOS has no beforeinstallprompt. The user must manually tap Share → Add to Home Screen. Your install button should still exist — but tap behaviour on iOS needs to show instructions instead of calling prompt(). Segment 7 covers the iOS-specific install UX.
When the install prompt won't appear — diagnostic order

"My install button doesn't show / my install prompt won't appear" is the most common PWA-specific bug report. It's usually one of a small number of things. Check these in order — the first failure is almost always the cause:

  1. Is the site already installed? Install prompt never fires twice. DevTools → Application → Manifest → "This app is installed." If yes, uninstall (Chrome menu → Uninstall) and reload.
  2. Is DevTools → Application → Manifest showing any red errors? Manifest parse errors, missing icon sizes, missing fields — any of these block the install prompt.
  3. Is the SW registered AND has a fetch handler? DevTools → Application → Service Workers. Must show "activated and is running" — not "redundant" or "waiting."
  4. Is your site actually on HTTPS? Check the URL bar. Some people spend 20 minutes debugging before noticing they forgot the deploy.
  5. Have you interacted with the page? Open in a fresh incognito tab. Click something. Wait ~30 seconds. Chrome needs both an interaction and some engagement time.
  6. Did you already dismiss an install prompt? Once dismissed, Chrome waits ~3 months before offering again. Try a different browser profile or fully reset site data.
Direct Claude — diagnose a non-firing install prompt
# Use this when the install prompt won't fire and the diagnostic order
# above didn't find the issue:

My PWA install prompt isn't appearing. I've verified:
- HTTPS ✓ (deployed to <your URL>)
- Manifest at /manifest.json with no parse errors
- SW registered and shows "activated and is running" in DevTools
- Site is not already installed
- I've clicked around on the page
- Tried a fresh Incognito window

The console shows: <PASTE CONSOLE OUTPUT IF ANY>
DevTools Application → Manifest shows: <PASTE ANY WARNINGS>
DevTools Application → Service Workers shows: <status>

Paste my manifest.json: <PASTE IT>
Paste my SW registration code: <PASTE IT>
Paste my SW fetch handler: <PASTE IT>

Diagnose what's preventing install. Be specific — which line, which
file, what's wrong. Also tell me what to check in DevTools that I
haven't already checked.
Checkpoint — Install Prompt Fires
Open your live site in Chrome in a fresh incognito window. Click around for 30 seconds. Does either: (a) the URL bar show a small install icon, OR (b) your custom install button appear? Click it — does the install dialog appear with your app name and icon?
Your PWA is now genuinely installable on Chrome, Edge, Chrome-on-Android, and every Chromium-based browser. Users can add it to their desktop, their phone home screen, their Windows start menu. Your tool now lives outside the browser — while still being nothing more than a website.
Segment 7 of 9 · PWA Add-On
By the end of this segment you will be able toState precisely what iOS does and doesn't support for PWAs in 2026, build the manual-install UX iOS requires, and set realistic expectations.
State precisely what iOS Safari supports for PWAs in 2026 (push notifications on 16.4+, Badging API, Screen Wake Lock, standalone display), what it doesn't (automatic install prompt, background sync, Web Bluetooth/NFC, reliable persistent storage), the EU-specific regression in iOS 17.4, and build the custom "Add to Home Screen" UX that Safari users need because there's no beforeinstallprompt event on iOS. No lies, no wishful thinking. Real expectations.

iOS Caveats · The Honest Tour

⏱ ~20 min• The segment nobody else writes clearly

Most PWA tutorials say "works on iOS!" and move on. That's a half-truth. PWAs run on iOS — your installed app opens, your service worker caches, your offline experience works. But iOS Safari is the only mainstream browser that doesn't support automatic install prompts, and Apple ships a deliberately restricted subset of the web platform for strategic reasons. Knowing exactly what's restricted means you can either work around it gracefully or decide iOS is a deal-breaker for your tool — either way, informed.

This segment walks the real 2026 state of PWAs on iOS. Nothing about what might arrive with iOS 27. Just what's shippable right now, on iPhones in users' pockets today.

The EU-specific caveat. In March 2024, Apple removed Home Screen web app support for iOS users in the EU in response to the Digital Markets Act. As of April 2026 this is partially reversed — EU users can install PWAs again under certain conditions, but the behaviour has been less stable than non-EU iOS. If your user base is EU-heavy, test on an actual EU-region iPhone before you ship.
What works on iOS in 2026 · the "yes" column

Since iOS 16.4 (March 2023) Apple has been slowly closing the PWA feature gap. Several things that were blocking in 2021-2022 now work. Build the PWA knowing these are reliable.

Installation via Add-to-Home-Screen
Manual only. User taps Share icon → Add to Home Screen. PWA installs, opens standalone. Works reliably since iOS 11. Icon comes from the apple-touch-icon link in your HTML.
Service workers + offline caching
Fully supported. Your cache-first / network-first / SWR strategies from Seg 5 all work. Storage quota on iOS 17+ is up to 60% of free disk, which is significant (no longer the 50 MB hard cap of earlier iOS versions).
Push notifications (iOS 16.4+)
Web Push works on iOS 16.4 and later, but only for installed PWAs — not for sites you just visit in Safari. Safari 18.4 added Declarative Web Push, which is simpler to implement and doesn't require a service worker for delivery.
Badging API, Screen Wake Lock, standalone display
Icon badge counts (iOS 16.4+), preventing screen dim during use (Safari 18.4+), and the standalone display mode (no URL bar when launched from home screen) — all supported.

Net effect of the "works" column: a well-built PWA on iOS gives users a genuinely app-like experience for most use cases. Installed apps, offline support, push notifications, icon badges. That's what users care about.

What doesn't work on iOS · the "no" column

The gaps you should plan around. None of these are recent changes; Apple has held firm on most of them for years. Build knowing they won't change before you ship.

Automatic install prompts (beforeinstallprompt)
Never fires on iOS. You cannot programmatically show an install dialog. The user must tap Share → Add to Home Screen themselves. Slide 38 covers the custom UX pattern to teach them how.
Background Sync / Periodic Background Sync
Not supported. Your service worker cannot wake up to sync queued data when the device comes back online. Your app has to do its own "when user opens the app next, check for pending sync items" logic — Seg 8 shows the pattern.
Web Bluetooth, Web NFC, WebUSB, Web Serial, Web MIDI
Blocked since 2020. If your tool needs to talk to Bluetooth peripherals, NFC tags, or USB devices, iOS is a non-starter — build native or use a native wrapper like Capacitor.
Chrome/Edge/Firefox on iOS are secretly Safari
Apple's App Store rules force all iOS browsers to use WebKit under the hood. Chrome on iOS behaves identically to Safari for PWA purposes — including "no install prompt." A user who prefers Chrome on iPhone cannot install your PWA unless they open it in Safari first.
App Store distribution
No equivalent to Google's Trusted Web Activity. You cannot publish a PWA to the iOS App Store. Android PWAs can ship to Google Play via TWA wrappers (PWABuilder is the easiest route); iOS users have to find you via the web.
The uncomfortable truth: Apple's incentives here aren't mysterious. PWAs skip the App Store's 15-30% cut. Apple has told regulators that security and privacy concerns justify the restrictions. Whatever you think of the reasoning, the capabilities are real and unlikely to change soon. If your product economics absolutely require iOS parity, weigh the cost of a native iOS app versus the cost of an audience that sometimes sees fewer features on iPhone.
The custom "Add to Home Screen" UX for iOS users

Since beforeinstallprompt never fires on iOS, you need a different install UX for those users. The pattern: detect iOS + Safari + not-already-installed, show a small banner or button that teaches them the Share-then-Add-to-Home-Screen flow. Show it once. Remember the dismissal.

ios-install-prompt.js · the detection + banner pattern
// Detect iOS Safari (and not standalone / not already installed)
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
                     window.navigator.standalone === true;  // Safari-specific
const dismissed = localStorage.getItem('ios-install-dismissed');

if (isIOS && !isStandalone && !dismissed) {
  showIOSInstallBanner();
}

function showIOSInstallBanner() {
  const banner = document.createElement('div');
  banner.className = 'ios-install-banner';
  banner.innerHTML = `
    <div>Install this app — tap
      <svg viewBox="0 0 24 24" style="width:18px;vertical-align:middle" fill="currentColor">
        <path d="M12 2L8 6h3v7h2V6h3l-4-4zm-7 14v4a2 2 0 002 2h10a2 2 0 002-2v-4h-2v4H7v-4H5z"/>
      </svg>
      then Add to Home Screen
    </div>
    <button class="close">&times;</button>
  `;
  document.body.appendChild(banner);

  banner.querySelector('.close').addEventListener('click', () => {
    localStorage.setItem('ios-install-dismissed', Date.now());
    banner.remove();
  });
}

Three things this pattern gets right: (1) only shows on iOS Safari, not on Android or desktop where the browser's own prompt handles it; (2) respects dismissal via localStorage; (3) uses the actual Share-icon SVG so the user can visually match your banner to the real button in their Safari toolbar.

The conversion rate on manual-install banners is low. Maybe 3-5% of iOS visitors who see the banner actually complete the install. Android's native prompt converts 10x better. This is one of the real consequences of Apple's restrictions — iOS PWAs get fewer installs than equivalent Android ones, independent of your design quality. Factor it into your expectations, not your self-assessment.
Feature-detect, don't user-agent-sniff

A common mistake when building iOS-aware code: detect iOS via user-agent, then assume iOS means "PWA features X, Y, Z are unavailable" forever. But Apple does improve things — iOS 16.4 added push, Safari 18.4 added Wake Lock, iOS 26 changed default Home Screen behaviour. Hard-coded assumptions rot.

Pattern: check the feature, not the platform. Before calling a capability, test whether it exists on window or navigator. Fall back gracefully if absent.

Feature-detect pattern
// ❌ BAD — will break when Apple ships a new capability
if (isIOS) {
  hidePushNotificationsUI();  // "iOS can't do push" — wrong since 16.4
}

// ✅ GOOD — works regardless of platform changes
if (!('Notification' in window)) {
  hidePushNotificationsUI();
} else if (Notification.permission === 'denied') {
  showReEnableHint();
}

// ✅ GOOD — for Wake Lock
if ('wakeLock' in navigator) {
  enableWakeLockButton();
}
Direct Claude for the feature-detection audit. When you're not sure which of your iOS-aware branches is now stale, paste your browser-detection code into Claude with this prompt: "Audit this code for hard-coded iOS assumptions that may be out of date. For each, tell me (1) what capability it gates, (2) whether iOS Safari now supports it as of 2026, (3) a feature-detection rewrite." Claude's training cutoff may lag by a year or so, so cross-check any claims against webkit.org/status — the canonical "what Safari can do" reference.
Checkpoint — iOS Reality Check
If you have an iPhone: open your deployed site in Safari, use it for 30 seconds, tap Share → Add to Home Screen. Launch from the home screen icon. Does it open standalone (no URL bar)? If you don't have an iPhone, you can skip — but you should still be able to explain the three biggest iOS differences from memory.
You now have the honest iOS picture: it works for most PWA use cases, has hard gaps (install prompt, BackgroundSync, Bluetooth/NFC), and the user has to do the install step manually. You've teaching-banner code to help them. Your PWA is genuinely cross-platform — just with realistic expectations for each platform.
▲ VERIFICATION LAYER · iOS Parity Honesty
Common ways AI gets THIS wrong:
  • Writes "PWAs work the same on all platforms" — not true; iOS has specific gaps AI training may under-represent
  • Suggests using beforeinstallprompt on iOS — the event never fires; wasted code
  • Claims BackgroundSync works everywhere — fully unsupported on iOS, app must handle sync on open
The 30-second check: Tested on a real iPhone (yours or a friend's). Install flow documented. Custom iOS banner shows and can be dismissed. iOS-specific features gated by feature detection, not user-agent.
Segment 8 of 9 · PWA Add-On
By the end of this segment you will be able toStore user data offline in IndexedDB, show a clear offline indicator, queue failed writes, and sync them when the network returns.
Store user data in IndexedDB so it survives offline, show a clear offline indicator in your UI so users know when they've lost connection, queue failed API writes in a pending list, and sync them when the network returns — both automatically (Android BackgroundSync) and reliably (iOS-compatible on-next-open pattern). By the end, your PWA degrades gracefully when offline instead of silently failing.

Offline-First UX · Survive the Network

⏱ ~30 min• The real payoff of being a PWA

Service worker caching (Seg 5) gets you offline reads — cached HTML/CSS/JS/icons load without a network. That's not the full story. The full story is offline writes: a user on a train opens your AI tool, types a query, hits submit, and gets a meaningful response — not a generic "network failed" error. Their input is saved locally; when the train hits a tunnel's end and reconnects, the queued request fires and the response arrives.

That experience separates PWAs from "websites with a service worker." It's also where most PWAs stop, because it requires more than just caching — it needs local storage (IndexedDB), a sync queue, and a clear UI for the user about what's happening. This segment covers the minimum viable version.

Step 1: IndexedDB via idb — the sane wrapper

Raw IndexedDB is notoriously unergonomic — callback-based, verbose, easy to misuse. Nobody writes it directly in 2026. The idb library (Jake Archibald's 2 KB wrapper) wraps it in a modern Promise-based API. Use idb; skip raw IDB.

index.html · load idb from CDN
<script type="module">
  import { openDB } from 'https://cdn.jsdelivr.net/npm/idb@8/+esm';
  window.openDB = openDB;
</script>
db.js · open a database + read/write helpers
const DB_NAME = 'ai-tool-db';
const DB_VERSION = 1;

async function getDB() {
  return openDB(DB_NAME, DB_VERSION, {
    upgrade(db) {
      // Object stores — think "tables" in SQL terms
      if (!db.objectStoreNames.contains('history')) {
        db.createObjectStore('history', { keyPath: 'id', autoIncrement: true });
      }
      if (!db.objectStoreNames.contains('pending-sync')) {
        db.createObjectStore('pending-sync', { keyPath: 'id', autoIncrement: true });
      }
    }
  });
}

export async function saveHistory(entry) {
  const db = await getDB();
  return db.add('history', { ...entry, timestamp: Date.now() });
}

export async function getHistory() {
  const db = await getDB();
  return db.getAll('history');
}

export async function queuePendingSync(request) {
  const db = await getDB();
  return db.add('pending-sync', { ...request, queuedAt: Date.now() });
}

Why IndexedDB and not localStorage? localStorage is synchronous (blocks the main thread), capped at ~5-10 MB, string-only, and gets wiped during aggressive browser cache cleanup. IndexedDB is async, holds gigabytes, stores structured objects, and is treated as persistent by the browser. For anything beyond a few preferences, IndexedDB is the right answer.

Storage APIs across the three add-ons · decision table

If you've done all three add-ons you've now touched five different storage APIs. Here's when each is right:

API Where Size limit Synced? Best for
KV (Automation) Server edge (Cloudflare) Unlimited (paid) Global auto Server-side state, cross-client data
chrome.storage.local (Chrome Ext) User's Chrome (this device) 10 MB No Extension per-device history, cached data
chrome.storage.sync (Chrome Ext) User's Chrome (all devices) 100 KB / 8 KB per item Yes (Google account) Extension user preferences, small settings
IndexedDB (PWA) User's browser Up to 60% of disk No PWA user data, offline queues, larger structured data
localStorage (PWA, sparingly) User's browser ~5-10 MB No Quick key-value prefs (install-dismissed flags, etc.)

The key mental split: KV for "shared across all users / all devices" (server-side). Everything else is "this one user's browser on this one device" (client-side). The client-side options differ mostly in size and whether Google syncs them across that user's Chrome sign-ins.

Step 2: The offline indicator UI

When a user's network drops, they need to know immediately — not when their next click fails. Two browser events make this trivial: online and offline fire on the window whenever connection status changes. Render an indicator based on navigator.onLine.

offline-indicator.js · minimum viable pattern
const indicator = document.createElement('div');
indicator.id = 'offline-indicator';
indicator.textContent = '● Offline — changes will sync when connection returns';
document.body.appendChild(indicator);

function updateIndicator() {
  if (navigator.onLine) {
    indicator.classList.remove('show');
    syncPending();  // we just came back online — flush the queue
  } else {
    indicator.classList.add('show');
  }
}

updateIndicator();  // initial render
window.addEventListener('online', updateIndicator);
window.addEventListener('offline', updateIndicator);

Pair with one short block of CSS — a slide-down banner from the top of the viewport, red background, hidden by default, animates in via the .show class. Ten lines of CSS, not worth its own code block; your BUILD-style inline styles will do.

navigator.onLine lies sometimes. It reports whether the device thinks it has any network connection (Wi-Fi associated, mobile data on) — not whether your server is reachable. A user on a hotel Wi-Fi that's stuck at the captive-portal login page reads as "online" but can't reach anything. Belt-and-braces: also treat a fetch() that fails with a network error as "probably offline right now" even if navigator.onLine is true.
Step 3: Queue failed writes, flush on reconnect

The payoff pattern. When a user submits something and the fetch fails because they're offline, don't surface the error — instead queue the request in IndexedDB and return a "saved, will sync" response. When online fires, flush the queue.

app.js · wrapping fetch with offline queueing
import { queuePendingSync } from './db.js';

async function submitAnalysis(text) {
  const request = {
    url: '/api/analyse',
    method: 'POST',
    body: JSON.stringify({ text })
  };

  if (!navigator.onLine) {
    await queuePendingSync(request);
    return { queued: true, message: 'Saved — will analyse when connection returns.' };
  }

  try {
    const res = await fetch(request.url, {
      method: request.method,
      headers: { 'Content-Type': 'application/json' },
      body: request.body
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  } catch (err) {
    // Network error — queue for retry
    await queuePendingSync(request);
    return { queued: true, message: 'Offline — queued.' };
  }
}

export async function syncPending() {
  const db = await getDB();
  const pending = await db.getAll('pending-sync');
  for (const item of pending) {
    try {
      await fetch(item.url, {
        method: item.method,
        headers: { 'Content-Type': 'application/json' },
        body: item.body
      });
      await db.delete('pending-sync', item.id);  // success — remove from queue
    } catch {
      // Still offline, leave for next reconnect
      return;
    }
  }
}
Direct Claude to sanity-check the idempotency. Queued requests get retried when the user reconnects — but some endpoints aren't safe to retry (anything that charges money, sends an email, posts publicly). Paste your queue-flushing logic into Claude with: "Which endpoints in this queue-sync code must be idempotent to be retry-safe, and which of my current endpoints don't meet that bar? For any that don't, suggest a request-id header + server-side dedupe pattern." Idempotency is easy to get wrong by default and Claude's checklist-style review catches most of it.

Add exponential backoff — borrowed from the Automation add-on. The sync above tries each queued request once and gives up on failure. That's fine for "network came back, everything works" but it's wrong for "network came back but the server is briefly overloaded from every client reconnecting at once." The Automation add-on's withRetry pattern fixes it — three attempts with 1s → 2s → 4s delays between each:

retry.js — same pattern from Automation Seg 7
export async function withRetry(fn, { tries = 3, baseMs = 1000 } = {}) {
  let lastErr;
  for (let attempt = 1; attempt <= tries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      if (attempt === tries) break;
      await new Promise(r => setTimeout(r, baseMs * Math.pow(2, attempt - 1)));
    }
  }
  throw lastErr;
}

// In syncPending, wrap each fetch:
for (const item of pending) {
  try {
    await withRetry(() => fetch(item.url, {
      method: item.method,
      headers: { 'Content-Type': 'application/json' },
      body: item.body
    }));
    await db.delete('pending-sync', item.id);
  } catch {
    // All 3 attempts failed — leave in queue for next reconnect
    return;
  }
}

The difference matters most the minute after a network outage ends: hundreds of queued requests from your user base hit the server at once, some fraction fail, and without backoff those are lost until the next reconnect. With backoff, the retries spread across the next ~7 seconds and mostly succeed on attempt 2 or 3. One helper function, significantly better reliability.

BackgroundSync — Android-only, nice-to-have

Everything in Step 3 works on every platform — it syncs whenever the app is open and online fires. On Chrome/Android, you can also use the BackgroundSync API, which lets the service worker sync even when the app is closed, once the device reconnects. On iOS it doesn't work at all. Treat it as a progressive enhancement.

sw.js · add BackgroundSync handler (progressive enhancement)
// Service worker: wake up when the device reconnects and flush pending-sync
self.addEventListener('sync', (event) => {
  if (event.tag === 'flush-pending') {
    event.waitUntil(flushPendingFromSW());
  }
});

// App.js: register the sync tag when queueing a request
async function registerSync() {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const reg = await navigator.serviceWorker.ready;
    await reg.sync.register('flush-pending');
  }
  // iOS + older browsers: no-op. The on-online listener from Step 2 handles it instead.
}

The rule for BackgroundSync: register it where available, but also keep the window online handler. Together they cover every platform — Android gets SW-level sync, iOS gets the "sync on next open" fallback. Neither alone is sufficient.

SHARP M4 The Confident GuessAI will tell you BackgroundSync "works everywhere." It doesn't.
Ask Claude "will BackgroundSync work for my users?" and the default answer is an upbeat "yes, it's well-supported." That's wrong in a specific, costly way — iOS Safari and Firefox have zero support, meaning roughly half of your real user base gets nothing from SW-level sync. The fix is the Step-3 pattern: always have a window-level online handler as the primary path. Treat BackgroundSync as an Android bonus. If you shipped relying on BackgroundSync alone, your iOS users' queued requests would pile up forever until they re-open the app.
Checkpoint — Offline Loop Works End-to-End
In Chrome DevTools → Network tab, set throttling to "Offline." Use your app — submit an analysis. Does the UI show the offline indicator and a "queued" message instead of an error? Now set back to "Online." Within ~2 seconds, does the queued request auto-fire and the response appear?
Your PWA now has the full offline-first experience: reads work from cache, writes queue locally, sync flushes on reconnect, and the user gets a clear UI about all of it. This is the experience that makes PWAs feel like apps instead of websites. Most web apps skip this — you now have the pattern, and it applies to every PWA you build from here on.
Segment 9 of 9 · PWA Add-On
By the end of this segment you will be able toDeploy the PWA, install it on a real phone, verify the offline experience works, and close the project into your portfolio.
Deploy the PWA to your Netlify site, install it on a real Android phone (and iOS if you have one), verify the offline experience works in actual airplane mode (not just DevTools emulation), and close the project into your portfolio with a README section and a one-liner that pulls its weight. Your PWA is live, installable, offline-capable, and shippable.

Ship & Portfolio Close

⏱ ~25 min⬡ The finish line

This is the shortest segment. Your PWA is built. The Worker's already deployed from BUILD. The manifest, service worker, iOS banner, offline UI, and IndexedDB queueing are all in code. This segment is the ship flow — push to Netlify, install on a real phone, verify the behaviour matches what you think it does, and turn the thing into a portfolio line.

Step 1: The pre-ship checklist

Before the deploy, walk these. Five minutes; saves a rollback.

☐ Lighthouse PWA audit is all green — run it from DevTools → Lighthouse → PWA category. No red or amber items.
☐ Service worker's cache name has a versioncache-v2 if you made any asset changes since last deploy. Missing the bump = users get stale cached assets.
☐ manifest.json version is current — not a required field, but adding a "version" like "1.0.0" helps you track which build users have installed.
☐ iOS install banner displays correctly — test in Safari responsive mode, iPhone 14 viewport.
☐ Offline path works in DevTools Offline mode — submit, see queued message, back online, sync completes.
☐ No console.error or uncaught exceptions on any page of your PWA when used normally.
☐ HTTPS-only — no HTTP assets — mixed-content warnings kill PWA installability. Netlify serves HTTPS by default; just make sure any external scripts/images you reference use https://.
Deploy
git add .
git commit -m "PWA add-on complete: manifest, SW, offline-first, iOS support"
git push

Netlify picks up the push, builds, deploys. 30-60 seconds later your live URL serves the new version. The service worker on returning users' devices detects the update on their next visit and either swaps in the new version immediately (if you used skipWaiting from Seg 4) or prompts them to refresh.

Step 2: Real-device testing — the non-negotiable

Everything you've tested so far has been in Chrome DevTools on your development machine. That's ~80% of the truth. The other 20% — where PWAs bite you — only shows up on real phones in real network conditions.

  1. Open the live URL in Chrome on an Android phone. Use it for 30+ seconds. The browser should show an install prompt (icon in address bar, or bottom banner). Install.
  2. Launch from the home screen icon. Should open standalone — no URL bar, no browser chrome. Your theme colour should tint the status bar.
  3. Put the phone in airplane mode. Open the installed app. It should load — HTML, CSS, JS, your UI should all appear even though there's no network.
  4. Try an action that normally calls your Worker. Your offline banner should appear, and the UI should show the "queued" message instead of a generic error.
  5. Turn airplane mode off. Within seconds, the queued request should sync and the response should appear.
  6. Repeat on iPhone if available. Safari install flow is manual (Share → Add to Home Screen). Everything else should behave the same.
Things that look fine in DevTools but break on real phones: viewport scaling (an iPhone SE's actual viewport is narrower than you'd think), touch hit-targets (finger-sized, not cursor-sized), IndexedDB quota (real iPhones get evicted more aggressively than desktop Chrome), the installed state detection on iOS (display-mode: standalone query matches differently than Android). Test on real hardware.
Privacy policy — add one if you're distributing. Your PWA stores user data in IndexedDB. If you also register push notifications (iOS 16.4+) or you later ship to the Google Play Store via a TWA wrapper, a privacy policy at a public URL becomes mandatory. For a solo tool only you use, you can skip this. For anything you publish, add a privacy.html page to your Netlify site before you ship — the prompt template in the Chrome Extension add-on Seg 10 transfers almost verbatim (just swap "extension" for "PWA" and list IndexedDB + push as your data types). Privacy policy URL and your actual data handling must match exactly or you're exposed under GDPR / CCPA.
Step 3: Portfolio close

Your live PWA is a real portfolio piece. Same three steps as Automation and Chrome Extension, adapted for the PWA reality.

① Update the README
Add a Progressive Web App section to your BUILD project's README. List what's shipped: installable on Android/Desktop via browser prompt and on iOS via Share → Add to Home Screen, offline-first with queued writes, cache-versioning handling for updates. Include a screenshot of your PWA installed on a phone home screen.
② Record a 30-second install video
On your phone, screen-record: open the URL in Chrome → tap install → launch from home screen → use it offline → come back online → queued work syncs. 30 seconds. Upload as an unlisted YouTube or a file in the repo. Link from the README. Nothing demonstrates "I built a real PWA" like a working install.
③ The "about" line
One sentence for LinkedIn / GitHub profile / cover letters: "Shipped an AI tool as a cross-platform Progressive Web App — installable on phone and desktop, works offline, auto-syncs when connection returns." Every clause is load-bearing and factually yours.
Direct Claude — draft README + portfolio one-liner
# Paste this prompt into Claude; edit the output for accuracy before committing.

I just shipped a Progressive Web App add-on to my existing AI tool.
Here's what it does:

- Installable on Android Chrome / Desktop via the browser's native
  install prompt (beforeinstallprompt)
- Installable on iOS Safari via manual Share -> Add to Home Screen,
  with a custom banner teaching users the flow
- Offline-first: service worker caches the app shell + runtime assets,
  IndexedDB stores user history and a pending-sync queue
- When users make requests offline, the app queues them and syncs
  automatically when the network returns (window 'online' listener
  on iOS, BackgroundSync API on Android as an enhancement)
- Cache versioning handles updates cleanly — old caches are cleaned
  up in the activate event, new SW takes control with clients.claim()
- Works on standalone mode with my theme colour tinting the status bar
- Full Lighthouse PWA audit passes

Give me:
(1) A README section I can paste into my project README, 5-7 bullets,
    plain English, with placeholders for screenshot/video links
(2) A 280-char LinkedIn Featured tile description
(3) Three portfolio one-liners at different technical specificity levels
    (recruiter / hiring-manager / engineering-peer)

Tone: calm confidence, not hype. Factual. Past tense ("built", "shipped",
not "worked on"). Don't invent features I didn't list.
PWA Add-On Complete
Your AI tool is installable and offline-capable on every device with a browser. Same URL. Same backend.
What you just did, in plain English
You turned a website into an installable, offline-capable, cross-platform app — without leaving the web platform. One manifest file, one service worker, one offline UI, one IndexedDB queue. No App Store, no native code, no second team, no duplicate codebase. Your BUILD Worker serves this PWA and your original website and (if you built the other add-on) your Chrome extension from the same backend. This is the architecture pattern companies pay senior engineers to implement. You now know it end-to-end.
FINAL · TESTS JUDGMENT NOT TRIVIA
Your PWA is live. A user on iPhone emails: "I added it to my home screen yesterday and now when I open it, it still shows the old version even though your website has an update." You check — the site is updated, service worker is registered, cache is versioned. What's the most likely cause?
iOS Safari doesn't support service worker updates
False. iOS Safari supports service worker updates fully. The lifecycle is the same as other browsers — install → waiting → activate. This is the kind of "iOS can't do X" answer AI defaults to; always verify against webkit.org/status rather than assuming.
The new service worker is installed but waiting, because the installed PWA has been open continuously and skipWaiting isn't being triggered
This is it. When a user "opens" an installed PWA, it's usually resuming a backgrounded session, not a fresh load — so the old service worker keeps controlling it. The new SW sits in the waiting state indefinitely. The fix is either (a) skipWaiting() on install if you're confident the update is backward-compatible, or (b) an in-app "update available, tap to refresh" banner that messages the waiting SW to skip waiting. This is the segment-4 lesson applied to iOS's app-resume behaviour specifically.
You need to increment the manifest.json version
manifest.json version doesn't drive service worker updates — service worker updates are triggered by byte-for-byte changes to the SW file. Bumping the manifest doesn't trigger anything. It helps with your own tracking but not this problem.
The user needs to uninstall and reinstall the PWA
Works, but it's a symptom-level fix, not a cause-level one. You'd be telling every user to uninstall-reinstall every time you ship an update, which defeats the PWA model. The correct fix lives in your code (skipWaiting or an update prompt), not in support instructions.
9 segments. One installable AI tool. Offline-capable. Cross-platform.
You started with a BUILD website and ended with an app that users install on their phones and launch from their home screens — the kind of tool most developers never ship because they assume it needs native code. Your architecture doesn't. One codebase, one deploy, three surfaces (website, PWA, extension), one Worker backend. You're past where most self-taught developers stop.
If you've done all three add-ons: your BUILD tool now ships as a website, a PWA, and a Chrome extension, plus the Automation add-on runs scheduled AI work in the background. Four surfaces, one Worker. That's architecture most senior engineers haven't shipped in production. Credit yourself appropriately in interviews and portfolios.
Glossary

Key terms for this add-on

apple-touch-icon
An HTML <link> tag iOS uses for the Home Screen icon. Separate from manifest.json icons; iOS ignores manifest icons.
BackgroundSync API
A service worker API that retries queued fetches once the device reconnects. Chrome/Android only; iOS needs a window-level fallback.
beforeinstallprompt
Browser event fired when your PWA meets installability criteria. You intercept it to show a custom install button. Never fires on iOS.
Cache-first / Network-first / SWR
Three cache strategies. Cache-first for static assets. Network-first for dynamic content with cache fallback. Stale-while-revalidate (SWR) for both speed and freshness.
clients.claim()
Service worker call that makes a newly-activated SW take control of all open pages immediately. Without it, open tabs keep using the old SW.
Declarative Web Push
Simpler push notification model added in Safari 18.4. No service worker needed for delivery — payload alone defines the notification.
IndexedDB
Browser-native async database API. Structured objects, up to 60% of free disk. The right store for PWA user data and offline queues.
Installability criteria
Manifest + SW with fetch handler + HTTPS + 30s engagement + start_url returning 200. Meeting all fires beforeinstallprompt.
Lighthouse
Chrome DevTools auditing tool. Run PWA audit before every ship — all-green means installability checks pass.
manifest.json (Web App Manifest)
The JSON file defining your PWA's name, start URL, icons, theme color, and display mode. Required for installability.
PWA (Progressive Web App)
A website that meets installability criteria, works offline, and feels app-like. Same codebase as your website; no app store needed.
Service worker
A JS file that runs separately from your page, intercepts network requests, and decides cache vs network. The magic behind PWA offline support.
skipWaiting()
SW call that immediately replaces an old SW with a new one. Skips the "waiting" phase. Use carefully — can break open pages using the old SW.
Stale-while-revalidate (SWR)
Cache strategy that returns cached data immediately while fetching fresh data in the background. Next visit gets the update.
TWA (Trusted Web Activity)
Android wrapper that lets you publish a PWA to Google Play. iOS has no equivalent — App Store doesn't accept PWAs.
Web Push
The W3C spec for server-sent notifications to the browser. Supported on iOS 16.4+ for installed PWAs only, not for sites in a tab.
Welcome back — resume at slide ??
0:00