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.

Manifest field reference

FieldNeeded?What it doesThe common mistake
nameRequiredFull app name on the install prompt and splash screenLeft as the site title, so the prompt offers to install a portfolio
short_nameStrongly advisedThe label under the home-screen iconLonger than about 12 characters, so it truncates with an ellipsis
start_urlRequiredThe URL opened when the icon is tappedSet to /, so every launch lands on the site homepage
scopeAdvisedURL prefix the app treats as its ownNarrower than start_url, which breaks the app on launch
displayRequired for installstandalone, fullscreen, minimal-ui or browserbrowser, which makes installing pointless
display_overrideOptionalOrdered list of display modes tried before displayUsed without leaving a valid display fallback behind it
iconsRequired192px and 512px PNGs at minimumNo maskable variant, so Android crops the logo into a circle
background_colorAdvisedSplash background before the first paintWhite against a dark app — a flash on every single launch
theme_colorAdvisedTint of the OS status or title barDifferent from the page's theme-color meta, so the bar changes colour after load
idOptionalStable identity independent of start_urlOmitted, so changing start_url later installs a second app beside the first
orientationOptionalLocks portrait or landscapeLocked when the layout works fine either way
shortcutsOptionalLong-press menu entries pointing at deep linksShipped 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.

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.

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.

StateWhat puts it thereWhat you can do here
installingregister() resolves and the script downloads and parsesPopulate the cache inside the install event — the only safe place to call addAll()
installed / waitingInstall finished, but an existing worker still controls open tabsNothing happens here unless skipWaiting() is called or every controlled tab is closed
activatingNo client still needs the old worker, or skipWaiting() forced the transitionDelete stale caches inside the activate event, before the new worker takes requests
activatedActivation finishedThe worker now controls fetch events for everything in its scope
redundantA newer worker replaced it, or install or activate threw an exceptionNothing — 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.

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

BrowserTypical quotaEviction trigger
Chrome / Edge (desktop)Up to roughly 60% of free disk space, shared across an origin's storageLeast-recently-used origin evicted only once the disk is genuinely full
FirefoxUp to roughly 50% of free disk space, group-limited per siteSimilar LRU eviction, only under real disk pressure
Safari, installed to home screenOn the order of 1 GB in practice, not formally documentedCan be cleared after roughly a week or more without the app being opened, or by the OS under storage pressure
Safari, ordinary tabSame rough ceiling as installedAlso 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 statewindow.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.

CapabilityiOS SafariNote
Install promptNo prompt, no beforeinstallpromptManual Share → Add to Home Screen only
Web push notificationsiOS 16.4 and later onlyRequires the app to already be installed to the home screen — no push before that
Background syncNot implementedNo deferred retry when connectivity returns
Periodic background syncNot implementedNo scheduled background refresh
Badging APIPartial, version-dependentDo not build a feature that depends on it working everywhere
Share TargetNot implementedThe app can share out; it cannot receive an incoming share
Storage durabilityBetter once installed, still not permanentExtended 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.

  1. Confirm HTTPS. Local development over http://localhost is exempt; anywhere else, no service worker registers without it.
  2. Open DevTools → Application → Manifest and read the errors panel. A single invalid field can silently disqualify installability.
  3. 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.
  4. Check the scope. A worker registered from /app/sw.js only controls /app/ and below, never a URL outside that prefix.
  5. Inspect Cache Storage for stale entries sitting under an old cache name — proof that the activate cleanup step either never ran or never shipped.
  6. Run a Lighthouse PWA audit. It checks the installability criteria directly and names the missing field instead of leaving you to guess.
  7. 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 hubTradeLog, 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.

FAQ

Quick answers

What makes a website installable as a PWA?

Three things: it is served over HTTPS, it links a valid web app manifest with name, icons, start_url and display, and it registers a service worker with a fetch handler. If any one is missing the browser simply never offers installation, with no error message shown.

What is the difference between cache-first and network-first?

Cache-first serves from the cache and only touches the network on a miss, which suits the app shell — HTML, CSS, JS and icons that change only on deploy. Network-first tries the network and falls back to the cache, which suits data that goes stale, like prices or API responses.

Why do users still see the old version after I deploy a PWA?

Because the new service 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. Fix it by bumping the cache name on every deploy, deleting old caches during the activate event, and calling skipWaiting() plus clients.claim().

Should I use localStorage or IndexedDB?

Use localStorage for small amounts of JSON — a few thousand records is comfortable within its roughly 5 MB limit. Move to IndexedDB when you need to store binary data, query with indexes instead of loading everything, or when synchronous writes start causing visible jank.

How do you install a PWA on an iPhone?

Open the site in Safari, tap the Share button, then choose Add to Home Screen. iOS provides no automatic install prompt and no beforeinstallprompt event, so the page cannot trigger it — and third-party browsers on iOS have historically not offered the option at all.

Can a PWA work with no backend at all?

Yes. If all state lives in the user's browser through localStorage or IndexedDB and the service worker caches the app shell, there is nothing left for a server to do beyond serving static files. That also means no accounts, no sync, and no server-side compute.

How do you avoid reloading a PWA out from under the user?

Do not call skipWaiting() unconditionally. Let the new worker sit in the waiting state, listen for the updatefound and statechange events to detect it, show a banner, and only call skipWaiting() through postMessage once the user agrees. Guard the resulting controllerchange reload with a flag so it only fires once.

How much storage does a PWA actually get?

Chrome and Firefox typically allow up to 50 to 60 percent of free disk space per origin, evicted only under real disk pressure. Safari's effective ceiling is closer to 1 gigabyte and can be evicted after roughly a week or more of the app not being opened, even when installed. Call navigator.storage.estimate() to read the real number instead of assuming one.