Guide · Web

An installable app, no backend, no app store

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.


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.
  • displaystandalone 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.

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.

StrategyHow it worksUse it for
Cache-firstServe from cache; only hit the network on a missApp shell: HTML, CSS, JS, icons, fonts
Network-firstTry the network; fall back to cache on failureAPI responses, prices, anything time-sensitive
Stale-while-revalidateServe cache immediately, refresh cache in backgroundContent that can be one version old without harm
Network-onlyNever cacheAnalytics, 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.

  1. 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.
  2. Delete old caches in activate. Otherwise every version you ever shipped stays on the user's disk forever.
  3. Call skipWaiting() and clients.claim() so the new worker takes over immediately instead of waiting for all tabs to close.
  4. 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.

localStorage or IndexedDB?

Both keep data on the user's device. They are not interchangeable.

localStorageIndexedDB
APISynchronous, three methods, trivialAsynchronous, transactional, verbose
Data typeStrings only — you JSON round-trip everythingStructured objects, blobs, files
Practical sizeAbout 5 MB per originLarge — typically a share of free disk
QueryingNone. You load everything and filter in JSIndexes and cursors
BlockingBlocks the main thread on every read and writeOff 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.

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 statewindow.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.

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 — TradeLog, Cut, and Fire — all offline, all local storage, no accounts anywhere.

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.

FAQ

Quick answers

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.

Cache-first or network-first?

Cache-first for the app shell — HTML, CSS, JS, icons. Network-first for anything that goes stale. No backend means almost everything is shell.

Why is my deploy not showing up?

The new worker is stuck waiting behind open tabs. Bump the cache name, delete old caches on activate, call skipWaiting() and clients.claim().

localStorage or IndexedDB?

localStorage until it hurts — a few thousand JSON records inside ~5 MB. IndexedDB for binary data, real queries, or when writes cause jank.

How do you install on iPhone?

Safari → Share → Add to Home Screen, done manually. iOS has no install prompt and no beforeinstallprompt event to hook.

Can a PWA run with no server?

Yes — state in browser storage, shell in the service worker cache. The trade is no accounts, no sync, no server-side compute.