What actually makes a site installable
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.
The manifest fields that matter
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"}
]
}
- start_url — where the app opens from the home screen. Point it at the app, not at the site root, or every launch lands on your homepage.
- scope — the URL prefix the app considers its own. Links outside scope open in the browser instead of in the app window.
- display —
standalone removes browser chrome. fullscreen also removes the status bar; minimal-ui keeps a thin bar.
- short_name — what appears under the icon. Over about 12 characters it gets truncated on a phone.
- icons — 192px and 512px PNGs at minimum, plus a
maskable variant with roughly 20% safe padding so Android does not crop your logo into a circle.
- background_color — the splash colour while the app boots. Set it to your real background or the launch flashes white.
Manifest field reference
| Field | Needed? | What it does | The common mistake |
name | Required | Full app name on the install prompt and splash screen | Left as the site title, so the prompt offers to install a portfolio |
short_name | Strongly advised | The label under the home-screen icon | Longer than about 12 characters, so it truncates with an ellipsis |
start_url | Required | The URL opened when the icon is tapped | Set to /, so every launch lands on the site homepage |
scope | Advised | URL prefix the app treats as its own | Narrower than start_url, which breaks the app on launch |
display | Required for install | standalone, fullscreen, minimal-ui or browser | browser, which makes installing pointless |
display_override | Optional | Ordered list of display modes tried before display | Used without leaving a valid display fallback behind it |
icons | Required | 192px and 512px PNGs at minimum | No maskable variant, so Android crops the logo into a circle |
background_color | Advised | Splash background before the first paint | White against a dark app — a flash on every single launch |
theme_color | Advised | Tint of the OS status or title bar | Different from the page's theme-color meta, so the bar changes colour after load |
id | Optional | Stable identity independent of start_url | Omitted, so changing start_url later installs a second app beside the first |
orientation | Optional | Locks portrait or landscape | Locked when the layout works fine either way |
shortcuts | Optional | Long-press menu entries pointing at deep links | Shipped without per-shortcut icons, so entries render blank on some launchers |
Two of those are worth setting even though almost nobody does. id pins the app's identity, so changing start_url a year later updates the installed app instead of quietly installing a second copy next to it. display_override is the only safe way to ask for anything newer than standalone, because browsers that do not recognise the value fall through to display instead of refusing the manifest.
Service worker caching strategies
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)));
});
Cache versioning, and the classic bug
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.
- Bump the cache name on every deploy.
tradelog-v6 becomes tradelog-v7. If the name does not change, addAll writes into the same bucket and old entries survive.
- Delete old caches in
activate. Otherwise every version you ever shipped stays on the user's disk forever.
- Call
skipWaiting() and clients.claim() so the new worker takes over immediately instead of waiting for all tabs to close.
- Never cache the service worker file itself with a long max-age. If
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.
The lifecycle, as a table
Everything above is one instance of a fixed state machine every service worker goes through. Knowing the five states by name makes the waiting-worker bug obvious instead of mysterious.
| State | What puts it there | What you can do here |
installing | register() resolves and the script downloads and parses | Populate the cache inside the install event — the only safe place to call addAll() |
installed / waiting | Install finished, but an existing worker still controls open tabs | Nothing happens here unless skipWaiting() is called or every controlled tab is closed |
activating | No client still needs the old worker, or skipWaiting() forced the transition | Delete stale caches inside the activate event, before the new worker takes requests |
activated | Activation finished | The worker now controls fetch events for everything in its scope |
redundant | A newer worker replaced it, or install or activate threw an exception | Nothing — it is discarded permanently and never runs again |
Two of those five states are where deploys go wrong in practice: a worker parked in waiting because nothing ever called skipWaiting(), or a worker that jumps straight to redundant because an uncaught exception fired during install and nobody was watching the console.
Prompting the user before you reload their app
Calling skipWaiting() and clients.claim() unconditionally, the way the caching example above does, updates the app the instant a new worker installs — including mid-session, under the user's fingers. For a checklist that is harmless. For a trade journal with an open, unsaved entry, an unannounced reload is worse than the stale-version bug it fixes. The alternative is to let the new worker wait, then ask.
// sw.js — wait for explicit permission instead of skipping automatically
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)));
});
self.addEventListener('message', e => {
if (e.data && e.data.type === 'SKIP_WAITING') self.skipWaiting();
});
// app.js — detect the waiting worker, show a banner, reload once and only once
let reloaded = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloaded) return;
reloaded = true;
window.location.reload();
});
navigator.serviceWorker.register('/sw.js').then(reg => {
reg.addEventListener('updatefound', () => {
const worker = reg.installing;
worker.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateBanner(() => worker.postMessage({type: 'SKIP_WAITING'}));
}
});
});
});
The controllerchange listener needs the guard flag because it can fire more than once in edge cases, and a second unexpected reload is its own bug report. Use the unconditional auto-update from the caching example for something as disposable as a daily checklist; use the ask-first pattern for anything holding state a reload could destroy.
localStorage or IndexedDB?
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 — the same principle is argued more generally in the client-side-only tools guide.
How much storage you actually get
The eviction risk above is not uniform across browsers, and it is worth knowing the real numbers instead of guessing at them.
| Browser | Typical quota | Eviction trigger |
| Chrome / Edge (desktop) | Up to roughly 60% of free disk space, shared across an origin's storage | Least-recently-used origin evicted only once the disk is genuinely full |
| Firefox | Up to roughly 50% of free disk space, group-limited per site | Similar LRU eviction, only under real disk pressure |
| Safari, installed to home screen | On the order of 1 GB in practice, not formally documented | Can be cleared after roughly a week or more without the app being opened, or by the OS under storage pressure |
| Safari, ordinary tab | Same rough ceiling as installed | Also subject to Intelligent Tracking Prevention's separate script-writable storage cap |
Query the real number instead of assuming one: navigator.storage.estimate() returns usage and quota for the current origin, and navigator.storage.persist() requests exemption from automatic eviction — a request, not a guarantee, and Safari in particular can still ignore it.
const { usage, quota } = await navigator.storage.estimate();
console.log(`${(usage / quota * 100).toFixed(1)}% of quota used`);
const granted = await navigator.storage.persist();
Installing on iOS versus Android
This is the part that costs support emails, because the two platforms behave completely differently and only one of them tells the user anything.
- Android Chrome — the browser detects installability and surfaces its own prompt. You can also capture the
beforeinstallprompt event and trigger it from your own button, which converts far better than waiting for the browser banner.
- iOS Safari — there is no prompt and no
beforeinstallprompt event. The user must tap Share → Add to Home Screen manually. Nothing on the page can trigger it.
- iOS third-party browsers — Chrome and Firefox on iOS have historically been unable to add a PWA to the home screen at all. If a user says the option is missing, ask which browser they are in before anything else.
- Detecting installed state —
window.matchMedia('(display-mode: standalone)') works broadly; iOS also exposes navigator.standalone.
Capturing the install prompt yourself
The browser's own install banner converts poorly because it interrupts at a moment you do not control. Capturing the event and firing it from your own button, after a return visit or a first save, converts meaningfully better.
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
installButton.hidden = false;
});
installButton.addEventListener('click', async () => {
installButton.hidden = true;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(outcome); // 'accepted' or 'dismissed'
deferredPrompt = null;
});
window.addEventListener('appinstalled', () => {
deferredPrompt = null;
});
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.
What iOS does not support
Safari implements enough of the spec to install and run offline, and stops well short of what Android Chrome offers. Budget for the gap instead of discovering it in a support thread.
| Capability | iOS Safari | Note |
| Install prompt | No prompt, no beforeinstallprompt | Manual Share → Add to Home Screen only |
| Web push notifications | iOS 16.4 and later only | Requires the app to already be installed to the home screen — no push before that |
| Background sync | Not implemented | No deferred retry when connectivity returns |
| Periodic background sync | Not implemented | No scheduled background refresh |
| Badging API | Partial, version-dependent | Do not build a feature that depends on it working everywhere |
| Share Target | Not implemented | The app can share out; it cannot receive an incoming share |
| Storage durability | Better once installed, still not permanent | Extended non-use can still evict data — see the quota table above |
Offline fallback pages
Cache-first serves the shell offline, but a navigation to a URL that was never cached — a deep link opened cold, with no connection — still fails outright unless something catches it. A dedicated offline page turns a browser error screen into something that explains itself.
const OFFLINE_URL = '/offline.html';
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll([...SHELL, OFFLINE_URL])));
});
self.addEventListener('fetch', e => {
if (e.request.mode === 'navigate') {
e.respondWith(
fetch(e.request).catch(() => caches.match(OFFLINE_URL))
);
return;
}
e.respondWith(caches.match(e.request).then(r => r || fetch(e.request)));
});
Checking e.request.mode === 'navigate' matters: applying the fallback to every failed request would swap a failed image or a failed background fetch for an entire HTML page. Scope the fallback to page navigations only, and let everything else fail the ordinary way.
A debugging checklist, in order
Almost every PWA bug report reduces to one of these, checked in this order because each one hides the next.
- Confirm HTTPS. Local development over http://localhost is exempt; anywhere else, no service worker registers without it.
- Open DevTools → Application → Manifest and read the errors panel. A single invalid field can silently disqualify installability.
- Open DevTools → Application → Service Workers and check the status. A worker stuck in waiting is the single most common complaint, and the fix is the update pattern above.
- Check the scope. A worker registered from
/app/sw.js only controls /app/ and below, never a URL outside that prefix.
- Inspect Cache Storage for stale entries sitting under an old cache name — proof that the activate cleanup step either never ran or never shipped.
- Run a Lighthouse PWA audit. It checks the installability criteria directly and names the missing field instead of leaving you to guess.
- As a last resort, unregister and hard-reload. This clears local state a real user cannot easily clear, and separates 'my code is wrong' from 'my browser's cache is stale'.
What you give up
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 on the apps hub — TradeLog, Cut, and Fire — all offline, all local storage, no accounts anywhere, on the same static-hosting setup described in my stack.
Tools referenced in this guide
- Apps hub — the three installable PWAs, all offline and account-free.
- TradeLog — R-multiple trading journal — a localStorage-backed PWA in production.
- Fire — daily ritual checklist, the smallest of the three and the clearest example of the pattern.
- Privacy policy — how the local-only storage claim is written up for users.