What makes a site installable?
HTTPS, a valid manifest with icons and start_url, and a service worker with a fetch handler. Miss one and the prompt silently never appears.
Guide · Web
A progressive web app is three files on top of a normal website: a manifest, a service worker, and a storage decision. Get those right and the thing installs to a home screen, launches without a browser chrome, and works on a plane.
Browsers apply a short checklist before they will offer installation: the site is served over HTTPS, it links a valid web app manifest with the required fields, and it registers a service worker with a fetch handler. Miss any one and the install prompt silently never appears — there is no error, which is why this is the first thing to verify.
Everything else — offline behaviour, caching strategy, storage — is your design problem, not a requirement. A PWA that installs but breaks offline is worse than a bookmark, so the caching work is where the actual engineering is.
A manifest is a small JSON file linked from the head. Most of the spec is optional; a handful of fields decide whether the install looks like an app or looks like a shortcut.
<link rel="manifest" href="/apps/tradelog/"> { "name": "TradeLog", "short_name": "TradeLog", "start_url": "/apps/tradelog/", "scope": "/apps/tradelog/", "display": "standalone", "background_color": "#0B0B0D", "theme_color": "#0B0B0D", "icons": [ {"src": "icon-192.png", "sizes": "192x192", "type": "image/png"}, {"src": "icon-512.png", "sizes": "512x512", "type": "image/png"}, {"src": "icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"} ] }standalone removes browser chrome. fullscreen also removes the status bar; minimal-ui keeps a thin bar.maskable variant with roughly 20% safe padding so Android does not crop your logo into a circle.A service worker is a script that sits between the app and the network and can answer requests from a cache. There are two strategies worth learning and one rule for choosing between them: cache-first for things that rarely change, network-first for things that do.
| Strategy | How it works | Use it for |
|---|---|---|
| Cache-first | Serve from cache; only hit the network on a miss | App shell: HTML, CSS, JS, icons, fonts |
| Network-first | Try the network; fall back to cache on failure | API responses, prices, anything time-sensitive |
| Stale-while-revalidate | Serve cache immediately, refresh cache in background | Content that can be one version old without harm |
| Network-only | Never cache | Analytics, POSTs, anything with side effects |
For an app with no backend, this collapses nicely: everything is app shell, so almost everything is cache-first, and there is no data endpoint to worry about because the data lives in the browser.
const CACHE = 'tradelog-v7'; const SHELL = ['/apps/tradelog/', '/apps/tradelog/index.html', '/apps/tradelog/app.js', '/apps/tradelog/app.css']; self.addEventListener('install', e => { e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL))); self.skipWaiting(); }); self.addEventListener('activate', e => { e.waitUntil( caches.keys().then(keys => Promise.all( keys.filter(k => k !== CACHE).map(k => caches.delete(k)) )).then(() => self.clients.claim()) ); }); self.addEventListener('fetch', e => { e.respondWith(caches.match(e.request).then(r => r || fetch(e.request))); });The single most common PWA bug: you ship a fix, users reload, and they still see the old app. This is not a mystery, it is the service worker lifecycle working as designed. A new worker installs but stays in a waiting state until every tab controlled by the old one is closed — and reloading a tab does not close it.
tradelog-v6 becomes tradelog-v7. If the name does not change, addAll writes into the same bucket and old entries survive.activate. Otherwise every version you ever shipped stays on the user's disk forever.skipWaiting() and clients.claim() so the new worker takes over immediately instead of waiting for all tabs to close.sw.js is cached by the HTTP layer, the browser cannot see that it changed, and the app is frozen until the cache header expires.The failure mode is silent and it looks like your deploy did not work. Before debugging your build, open DevTools → Application → Service Workers and check whether a worker is stuck in waiting. That is the answer roughly nine times out of ten.
Both keep data on the user's device. They are not interchangeable.
| localStorage | IndexedDB | |
|---|---|---|
| API | Synchronous, three methods, trivial | Asynchronous, transactional, verbose |
| Data type | Strings only — you JSON round-trip everything | Structured objects, blobs, files |
| Practical size | About 5 MB per origin | Large — typically a share of free disk |
| Querying | None. You load everything and filter in JS | Indexes and cursors |
| Blocking | Blocks the main thread on every read and write | Off the main thread |
The rule I use: localStorage until it hurts. A few thousand records of JSON is fine — a trade journal, a weight log, a habit checklist. Move to IndexedDB when you are storing binary data, when you need to query rather than load-everything-and-filter, or when the payload passes a megabyte or two and the synchronous writes start showing up as jank.
One caveat for either: browser storage is not backup. It survives reloads and reboots, but it does not survive a cleared browsing history, and on iOS it can be evicted after extended non-use. Ship an export button and say so plainly.
This is the part that costs support emails, because the two platforms behave completely differently and only one of them tells the user anything.
beforeinstallprompt event and trigger it from your own button, which converts far better than waiting for the browser banner.beforeinstallprompt event. The user must tap Share → Add to Home Screen manually. Nothing on the page can trigger it.window.matchMedia('(display-mode: standalone)') works broadly; iOS also exposes navigator.standalone.Practical consequence: detect iOS Safari and show a small one-line instruction with the share icon, and hide it once the app is running in standalone mode. Without that hint, most iPhone users will never install.
No App Store listing and no App Store search traffic. No push notifications on iOS unless the app has been added to the home screen. No background execution to speak of. Nothing that requires a native API the browser has not exposed.
What you get in exchange: zero platform fees, no review queue, no annual developer fee, and a deploy that reaches every user the moment you push. For a personal tool, that trade is usually correct. I ship three of these — TradeLog, Cut, and Fire — all offline, all local storage, no accounts anywhere.
FAQ
HTTPS, a valid manifest with icons and start_url, and a service worker with a fetch handler. Miss one and the prompt silently never appears.
Cache-first for the app shell — HTML, CSS, JS, icons. Network-first for anything that goes stale. No backend means almost everything is shell.
The new worker is stuck waiting behind open tabs. Bump the cache name, delete old caches on activate, call skipWaiting() and clients.claim().
localStorage until it hurts — a few thousand JSON records inside ~5 MB. IndexedDB for binary data, real queries, or when writes cause jank.
Safari → Share → Add to Home Screen, done manually. iOS has no install prompt and no beforeinstallprompt event to hook.
Yes — state in browser storage, shell in the service worker cache. The trade is no accounts, no sync, no server-side compute.