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.
Need to set language, AI-usage tier or other preferences? Open the full setup wizard
EverythingThreads · ICO: C1896585 · Privacy Policy
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.
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.
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.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.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.*.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.
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.
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.
Concrete output from the add-on, so you know exactly what you're working toward:
manifest.json filesw.js file (service worker)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.
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.
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.
{
"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.
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.
pwa-asset-generator (npx)npx pwa-asset-generator logo.svg ./icons generates everything including iOS splash screens. Good for CI pipelines.# 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.
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>:
<!-- 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.
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?https://yoursite.com/icons/icon-192.png directly — 404? Fix the path or upload the icon./icons/Icon-192.png ≠ /icons/icon-192.png. Match the case exactly.<link rel="manifest"> tag didn't make it into the deployed HTML. View source on your live site to confirm. Hard-refresh (Cmd+Shift+R) if the cached version is showing."display": "standalone" but forgets "start_url" — install prompt never fires, Lighthouse fails installabilityicons/icon.png instead of /icons/icon.png — breaks on subpathsDevTools → 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.
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.
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.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 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.
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:
// 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.
/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.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:
// 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:
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.load — don't register immediately. You don't want the SW competing with your page's critical rendering path for bandwidth./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.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.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.
yoursite.netlify.app, yoursite.pages.dev, your own domain with SSL. Netlify / Cloudflare Pages / Vercel / GitHub Pages all provide HTTPS automatically.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.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).
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://.sw.js with status "activated and is running"? And does the console show "SW registered with scope: ..."?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.sw.js, not just manifest.json.sw.js with the wrong Content-Type. Netlify does this right by default; if you're on a hand-rolled server, ensure .js files are served as application/javascript.APP_SHELL 404s, which rejects the entire addAll(). Open each path directly in the browser; whichever returns 404 is the bad one. Fix the path or remove the entry.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.
Here's what actually happens when you git push a change to your PWA:
/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".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.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.
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.
// 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.
# 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)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:
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 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.
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.
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?APP_SHELL files themselves are still cached by HTTP caching. Netlify caches aggressively by default. Add Cache-Control: no-cache to sw.js in your Netlify _headers file to force the browser to always check for a new SW.self.skipWaiting(). Either add it, close every tab of your PWA, or click "skipWaiting" manually in DevTools.activate handler has a bug. Log keys to see what's there; the filter predicate should keep only CACHE_NAME and delete everything else.reg.installing check fires during the install-event only; if the SW is already activated, you'll never see it for a session. Close and reopen the tab to trigger again.skipWaiting() without cache versioning — new SW serves old cached files indefinitelysw.js itself is browser-cached by default; Netlify / Cloudflare HTTP caching interferes with SW updatesDevTools → 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.
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 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.
Pick the strategy by asking three questions about the resource:
Applied to your tool:
/index.html, /style.css, /app.js → cache-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)# 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.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:
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; }
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.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:
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.
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:
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.
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.url.hostname and url.pathname in the fetch handler; compare to what you're matching against.return (not event.respondWith() with a clone).new Response(...)).
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.
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:
<link rel="manifest"> in HTML, JSON parses, required fields present.
name (or short_name), start_url, display (must be standalone, fullscreen, or minimal-ui), icons with at least 192×192 and 512×512.
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.
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.
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:
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.
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."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:
# 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.beforeinstallprompt never fired. Work through the 6-step diagnostic list on the previous slide.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.
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.
apple-touch-icon link in your HTML.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.
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.
beforeinstallprompt)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.
// 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">×</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.
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.
// ❌ 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(); }
apple-touch-icon link in HTML. Missing either and the option disappears."display": "browser" instead of "standalone". Change and redeploy.apple-touch-icon link is missing or the icon doesn't resolve. iOS ignores manifest icons.'ontouchend' in document as a secondary check.
beforeinstallprompt on iOS — the event never fires; wasted codeTested 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.
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.
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.
<script type="module"> import { openDB } from 'https://cdn.jsdelivr.net/npm/idb@8/+esm'; window.openDB = openDB; </script>
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.
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.
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.
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.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.
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; } } }
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:
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.
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.
// 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.
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.online/offline listeners aren't attached yet (app.js loaded before them) OR your indicator element has display: none permanently. Console-log in the handlers; verify they fire.upgrade callback didn't run. Most often: your DB_VERSION is unchanged but you added a new object store. Bump DB_VERSION.online handler isn't calling syncPending, OR it is, but the fetch is still failing silently. Add try/catch logging; verify the fetch actually runs.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.
Before the deploy, walk these. Five minutes; saves a rollback.
cache-v2 if you made any asset changes since last deploy. Missing the bump = users get stale cached assets.
"version" like "1.0.0" helps you track which build users have installed.
console.error or uncaught exceptions on any page of your PWA when used normally.
https://.
git add .
git commit -m "PWA add-on complete: manifest, SW, offline-first, iOS support"
git pushNetlify 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.
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.
installed state detection on iOS (display-mode: standalone query matches differently than Android). Test on real hardware.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.Your live PWA is a real portfolio piece. Same three steps as Automation and Chrome Extension, adapted for the PWA reality.
# 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.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.Key terms for this add-on
<link> tag iOS uses for the Home Screen icon. Separate from manifest.json icons; iOS ignores manifest icons.start_url returning 200. Meeting all fires beforeinstallprompt.