OfflineGENERALBROWSER-SPECIFICPLATFORM-SPECIFICSPEC-EVOLVING

Manifest and Installability

What the manifest declares, what makes a site installable, and why an installed app with no offline story is just a bookmark with an icon.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What does it take for a site to be installable, and what does installing it actually change for the user?

The user intent

Someone uses a web app daily and wants it on their home screen or dock, opening in its own window, starting fast, and working on a bad connection like the native apps beside it.

The obvious build

Add a manifest with a name and some icons, register any service worker, and the browser will offer to install the app. Installed means app-like.

Why it breaks

The install prompt does not appear, and nothing tells you why. Criteria differ by browser and platform and are not a stable published contract you can code against.

How it breaks in a real browser
  • The install prompt does not appear, and nothing tells you why. Criteria differ by browser and platform and are not a stable published contract you can code against.
  • It installs, and it opens to a blank screen on a bad connection — because a registered worker with a trivial fetch handler is not an offline story (Caching Strategies).
  • It installs on Android from the browser's own prompt and on iOS only via a manual "Add to Home Screen" that your code cannot trigger, so a flow built around beforeinstallprompt reaches only part of the audience.
  • The window opens in standalone display mode with no browser chrome — and the app has no back affordance of its own, so a user who navigates into a detail view is stuck.
  • The icon looks wrong on one platform because a maskable icon was not provided and the OS cropped a square logo into a circle.
  • Installed and browser sessions can have different storage in some environments, so a user who was signed in and had cached data finds an empty, signed-out app after installing.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A web app manifest is a JSON document, linked from the document head, declaring how the site should present as an application: name, icons, start URL, display mode, theme and background colours, scope, shortcuts (The Head: Metadata That Changes Rendering).
  • start_url is where the app opens — usually not the same as the URL the user installed from — and scope is the set of URLs that stay inside the app window. A link outside scope opens in a browser tab.
  • display (standalone, minimal-ui, fullscreen, browser) decides how much browser chrome remains. standalone removes the URL bar and, with it, the back button and the reload button the user was relying on.
  • Installability criteria are the browser's, not the spec's: broadly a secure context, a valid manifest with a name, a start URL and adequately sized icons, and — in some browsers — a service worker with a fetch handler. The specifics differ per browser and change between versions.
  • Chromium fires beforeinstallprompt, which you can capture, defer and re-fire from your own button. This is a Chromium-family API; other browsers offer no equivalent, and on iOS installation is a manual user action from the share sheet.
  • display-mode is queryable from CSS and JavaScript (matchMedia('(display-mode: standalone)')), which is how an app knows to render its own navigation affordances when the browser's are gone (Media Queries Beyond Width).
  • Installing changes presentation and entry point. It does not by itself change caching, storage durability, or offline capability — those come from the worker and the storage you wrote (Intercepting Fetch).

What this makes the browser do

And which of it is avoidable.

  • Fetching and parsing the manifest, then fetching the icons it names — extra requests during a load that is often a first visit.
  • Evaluating installability criteria, which can involve waiting for a service worker to be registered and to have a fetch handler before the browser considers the site eligible.
  • Rasterising icons per platform, per density and per shape. A single large source icon plus a maskable variant is less work and fewer surprises than a dozen fixed sizes.
  • Maintaining a separate window, and in some environments a separate storage partition, for the installed app.

What the manifest actually declares

Every member here is a presentation decision with a consequence, and two of them — scope and display — change how the app behaves rather than how it looks. scope decides which links stay inside the app window; display decides whether the user still has a back button.

What the manifest does *not* declare is any capability. There is no member that makes the app work offline, none that makes storage durable, and none that grants a permission. It describes an entry point.

manifest.webmanifest, linked from the head
1{
2 "name": "Fieldwork — survey collection",
3 "short_name": "Fieldwork",
4 "start_url": "/app?source=installed",
5 "scope": "/app",
6 "display": "standalone",
7 "background_color": "#101418",
8 "theme_color": "#101418",
9 "icons": [
10 { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
11 { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
12 { "src": "/icon-mask.png", "sizes": "512x512", "type": "image/png",
13 "purpose": "maskable" }
14 ],
15 "shortcuts": [
16 { "name": "New survey", "url": "/app/new" }
17 ]
18}

Three details do the work. source=installed is the only way to tell installed sessions apart in analytics. scope: "/app" means a link to /pricing opens a browser tab. The maskable icon exists because platforms crop, and a square logo becomes a cropped square logo without it.

Installable is not offline-capable

These are two independent axes and the interesting cells are the off-diagonal ones. A site can be installable and useless offline; it can be excellent offline and never installable, which is a perfectly reasonable place to stop.

The row that matters commercially is the top right, because it is the one that gets shipped: a manifest is an afternoon's work and a real caching strategy is not. The user experiences the difference on the first bad connection after installing, which is also the moment they decide whether to keep the icon.

  • The manifest buys presentation: an icon, a window, a start URL, a theme.
  • The service worker buys capability: cold start without the network, cached data, a queue for changes made offline (The Offline Mutation Queue).
  • Installation buys neither durability nor permissions — storage can still be evicted and every prompt still applies.
  • A user who installs is telling you they intend to come back. Cold start and offline behaviour are the two things they will judge that decision on.
No offline storyReal caching strategy
Not installableAn ordinary website. Nothing wrong with this — most sites should be here.Fast repeat visits and resilience on a bad connection, with no icon anywhere. A very good place to stop (Caching Strategies).
InstallableA bookmark with an icon. Opens in its own window and shows an error page on a train. The most commonly shipped version, and the one that damages trust.What people mean by "PWA": an entry point in the launcher plus a shell that starts cold, offline, and honestly tells the user what is stale (Offline UX).

The prompt you cannot rely on

BROWSER-SPECIFICbeforeinstallprompt, appinstalled and prompt() are Chromium-family APIs and are not part of a cross-browser standard; Safari and Firefox fire neither event, so code branching on them must degrade to platform-appropriate instructions or to nothing rather than to a broken button.

Installation is the least portable thing in this module. One browser family gives you an event you can defer and re-fire; another gives the user a menu item; another gives nothing at all on desktop; and on iOS it is a manual gesture your code can neither trigger nor observe in advance.

The design consequence is to treat an install control as progressive enhancement with a platform-aware fallback. Capture the event where it exists, show instructions where it does not and installation is still possible, and show nothing at all where it is not. A generic "Install our app" button is wrong for most of the people who see it.

Deferring the prompt, and knowing when there is none
1let deferred: BeforeInstallPromptEvent | null = null
2
3// Chromium-family only. Absence of this event is normal, not an error.
4window.addEventListener('beforeinstallprompt', (event) => {
5 event.preventDefault() // suppress the browser's own moment
6 deferred = event as BeforeInstallPromptEvent
7 showInstallControl() // ours, shown when the user has intent
8})
9
10async function onInstallClick() {
11 if (!deferred) return
12 await deferred.prompt()
13 const { outcome } = await deferred.userChoice
14 deferred = null // a prompt event can be used once
15 hideInstallControl()
16 track('install_prompt', { outcome })
17}
18
19window.addEventListener('appinstalled', () => hideInstallControl())
20
21// Already running installed? Then never offer to install.
22const installed = window.matchMedia('(display-mode: standalone)').matches
23
24// No event, not installed, and the platform installs manually:
25// show instructions for THAT platform, or show nothing at all.

deferred being null is the common case across the whole audience, not an edge case. The display-mode check is also how the app knows to render its own back and reload controls, since standalone has taken the browser's away.

How to build it

Most important first.

  • Treat the manifest as a presentation contract and the service worker as the capability. Installability without an offline story produces an app-shaped icon that shows an error page on a train.
  • Set start_url to the app's real entry point, and give it a query parameter you can attribute — otherwise installed traffic is indistinguishable from browser traffic in analytics (Analytics Events That Answer a Question).
  • Ship a maskable icon alongside the standard ones. Platforms apply their own mask, and a logo that assumed a square is what produces a cropped icon.
  • If you use standalone, provide in-app back and reload affordances. Removing the browser's chrome makes you responsible for what it was doing (History and Navigation).
  • Never show your own install prompt unprompted. Capture beforeinstallprompt, defer it, and surface an install control at a moment the user has demonstrated intent — and hide it entirely where the API does not exist rather than showing a button that does nothing.
  • Provide instructions for platforms with no programmatic prompt, shown only on those platforms, rather than a generic "install our app" banner that is wrong for most of the people who see it.
  • Verify with a real first visit on a real device: install, go offline, cold start. That is the only test that distinguishes a PWA from a manifest.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Standalone display removes the browser's back button, its reload, and its address bar — all of which were accessible affordances the user knew. Replacing them is an accessibility requirement, not a design nicety (Keyboard Operability).
  • An install prompt or banner is an interruption: it needs a real focus target, an accessible name that says what installing does, a keyboard-reachable dismiss, and it must not steal focus.
  • theme_color and background_color become real UI in the app window. They must meet contrast requirements against the content they frame, and they must not fight a user's dark-mode preference (Contrast, Colour and Motion).
  • The manifest name and short_name become the app's accessible name in the OS launcher and the window title. A truncated or duplicated short_name is what a screen-reader user hears when switching apps.
  • A cold start of an installed app is a page load with no browser chrome to explain a delay. Announce loading state and place initial focus deliberately (Focus Management).

What can go wrong

Failure modes
  • The bookmark with an icon: installed, opens in its own window, and completely dependent on the network.
  • The dead-end window: standalone display, no in-app navigation, and a user who cannot go back.
  • The escaped link: a URL outside scope opens a browser tab from inside the app, so the user is now in two places with two sessions.
  • The invisible install button: a UI built entirely on beforeinstallprompt, hidden for every user on a browser that does not fire it.
  • The stale start: start_url cached with a cache-first strategy, so every cold start of the installed app opens an old build (The Service Worker Lifecycle).
  • The evicted app: storage reclaimed under pressure, so the installed app cold-starts empty and signed out.
  • The mitigation failing: an install banner shown on every visit until dismissed, which annoys the users most likely to have installed it anyway.
What can arrive out of order
  • Manifest and icon fetches race the critical path on a first visit; a large icon set can compete with resources the user is actually waiting for (The Critical Rendering Path).
  • The install prompt racing the service worker: in browsers that require a fetch handler, eligibility can arrive after your UI has already decided not to show an install control.
  • start_url racing a deploy: the installed app cold-starts against a cached start URL while a newer build is on the origin, so the first thing the user sees after an update is the old one (The Service Worker Lifecycle).
Security
  • Installation requires a secure context for the same reason service worker registration does: an installed app is a durable grant to an origin, and it must not be obtainable by a network attacker (Origins and the Sandbox).
  • Installing does not widen permissions. An installed PWA is still same-origin, still sandboxed, and still subject to the same permission prompts as the site in a tab. Anything claiming otherwise is describing a platform-specific wrapper, not the web.
  • scope is a containment boundary for navigation, not a security boundary. It does not isolate storage or restrict what script on the origin can do (The Same-Origin Policy).
  • Standalone display hides the URL bar, which removes the user's only way to verify what origin they are looking at. Anything that renders third-party content inside an installed window inherits a phishing surface the browser was previously mitigating (Clickjacking and Framing).
Misreads
  • "Installable means offline-capable." Installability is a manifest-and-eligibility question. Offline capability is a service worker and caching question. A site can pass one and fail the other completely.
  • "A PWA is a native app." It is a web app with an entry point in the OS launcher. Same sandbox, same permissions, same origin rules.
  • "The criteria are the spec." They are per-browser policy and they change. Read what your target browsers currently require rather than treating any list, including this lesson, as permanent.
  • "beforeinstallprompt is how installation works on the web." It is how it works in Chromium-family browsers. Elsewhere there is either a different affordance or none.
  • "Adding a manifest makes the site an app." It makes the site declare how it would like to be presented. Everything users mean by "app" — fast start, works offline, remembers me — is work you still have to do.

Measuring it, and what changes in the field

How you would see this
  • The Application panel's Manifest view reports the parsed manifest, the icons it resolved, and — in Chromium — the specific installability criteria that are not met, which is the fastest answer to "why is there no prompt".
  • Track installs and installed-session share in the field, via a distinguishable start_url and the appinstalled event where it exists (Analytics Events That Answer a Question).
  • Measure cold start of the installed app specifically. It is a different code path from an in-tab navigation, usually a worse one, and it is the moment users judge whether the install was worth it (Loading: Why Content Arrives Late).
  • Compare offline success rates between installed and browser sessions. If they are the same, the install added an icon and nothing else.
Slow device, slow network, large data, old tab
  • On iOS, installation is a manual action from the share sheet with no API to trigger or detect it beforehand, and the installed context has historically differed from Safari in storage and worker lifetime.
  • On desktop, the installed window is another window in the OS switcher, and users treat it like an application — including expecting it to remember where they were.
  • On a low-storage device, the installed app is as evictable as the site was. Installation is not a durability guarantee.
  • On a first visit over a slow connection, manifest and icon requests compete with the critical path. They are not render-blocking, and they are not free (Resource Hints).
What this costs
  • Standalone display feels like an app and makes you responsible for navigation, refresh and error recovery that the browser was providing.
  • A custom install flow converts better than the browser's default affordance and is one more interruption to design, translate, test and suppress correctly.
  • A larger precache makes cold start of the installed app fast and makes first install a bigger download on whatever connection the user has.
  • Building for installability at all is a real investment whose payoff is concentrated in daily users; for a site people visit twice a year it buys almost nothing.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe manifest format itself — name, icons, start_url, scope, display, theme colours — is specified and parsed by every engine that supports installation at all.
  • BROWSER-SPECIFICInstallability criteria and the install affordance are per-browser policy, not spec: Chromium fires beforeinstallprompt and has historically required a fetch handler, Firefox on desktop offers no install path, and Safari installs only through a manual share-sheet action — so a single "is it installable" check does not exist.
  • PLATFORM-SPECIFICAndroid, iOS, Windows and macOS each apply their own icon masking, window presentation and storage lifetime to an installed web app, so the same manifest yields different icons, different chrome and different eviction behaviour on each.
  • SPEC-EVOLVINGBoth the manifest members and the eligibility rules have changed repeatedly and continue to; treat any criteria list — including this lesson's — as a snapshot to verify against current browser documentation rather than as a contract.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — an installed app is a long-lived client that may cold-start against a build several deploys old, which is version skew with an icon on the home screen.
OS & Networkingwhy-tls