Skip to content

PWA Installability Criteria

Installability criteria are the conditions a browser checks before it promotes installing your site as an app: showing an install icon or banner and firing beforeinstallprompt. They matter because the answer changed a lot between 2023 and 2026. Chromium dropped the service worker requirement, Safari made every site installable, and Firefox added web apps on Windows. Most tutorials still repeat the old checklist. This page lists what each engine checks as of September 2026, how to see why your app fails, and how to fix each failure.

Key takeaways

  • Installing and promoting are different. Chrome, Edge, Safari and Firefox let a user install almost any site from the browser menu. The criteria decide whether the browser offers installation (install icon, banners, beforeinstallprompt) and whether it installs a manifest-driven app or a plain shortcut.
  • Chromium's promotion checks (desktop): a secure context, not an incognito window, a linked manifest that parses, name or short_name, an explicit same-origin start_url, display of standalone, fullscreen or minimal-ui (or a supported first display_override entry), and a downloadable purpose: "any" icon in PNG, SVG or WebP. That icon must declare sizes and be at least 144 px. Google documents 192 px and 512 px; ship both.
  • No service worker is required. Chrome dropped the fetch-handler requirement for menu installs in Chrome 108 (Android) and 112 (desktop). Current Chromium source has no service worker check in the promotion pipeline either, and beforeinstallprompt fires without one.
  • Safari has no criteria. Since iOS/iPadOS 26, every site added to the Home Screen opens as a web app by default. On macOS, Add to Dock works for any site. The manifest only refines the result.
  • Firefox: Android installs manifest-based apps as shortcuts (HTTPS, display other than browser, an icon of at least 192 px). Desktop web apps shipped for Windows in Firefox 143. Firefox never fires beforeinstallprompt.
  • Debug with the browser's own checker: DevTools Application > Manifest, the DevTools Protocol method Page.getInstallabilityErrors (usable in CI), chrome://web-app-internals and about://webapks.

Installable, promotable and shortcut: three different outcomes

"Is my PWA installable?" mixes up three questions, and each browser answers them differently:

  1. Can the user install it at all? In 2026, almost always yes. Chrome desktop has Cast, save, and share > Install page as app for any site. Chrome Android offers Install from the add-to-home-screen flow. Edge has Apps > Install this site as an app. Safari has Add to Home Screen and Add to Dock. Firefox on Windows can pin any site to the taskbar. None of these user-initiated paths needs a manifest.
  2. Will the browser promote it? Only Chromium-based browsers promote installation automatically: the address-bar install icon, the Android install message, and the beforeinstallprompt event your own install button depends on. This is where the criteria apply. Chromium engineers call it promotability.
  3. What gets installed? A crafted app (Chromium's term) uses your manifest for its name, icons, scope, display mode, shortcuts, file handlers and so on. A site installed without a usable manifest gets a generic app built from the page title and favicon. On Android the result is either a WebAPK (a real Android package) or a home-screen shortcut (a browser-badged bookmark). The criteria and the browser's WebAPK support decide which.
Browser User can install any site? Automatic promotion beforeinstallprompt What an install produces
Chrome / Edge (desktop) Yes, from the menu Address-bar install icon when criteria pass Yes OS-integrated app window
Chrome (Android, with Google Play services) Yes (Add to home screen > Install) Install message and menu entry when criteria pass Yes WebAPK
Samsung Internet (Android) Yes Install icon in the URL bar when criteria pass Yes WebAPK on Samsung devices, shortcut elsewhere
Edge, Opera, Brave (Android) Yes Varies Varies Browser-badged shortcut
Safari (iOS/iPadOS) Yes (Share > Add to Home Screen) Never No Home Screen web app
Safari (macOS) Yes (File > Add to Dock) Never No Dock web app
Firefox (Android) Yes Menu label changes to install when criteria pass No Browser-badged shortcut
Firefox (Windows) Yes (taskbar web apps) No No Taskbar web app

The rest of this page is about questions 2 and 3: what each engine checks before it promotes your app or installs it with your manifest.

Chromium: Chrome, Edge and other Chromium browsers

The documented criteria

Google's install-criteria article on web.dev (last updated September 2024) says Chrome fires beforeinstallprompt and shows install promotion when:

  • the web app is not already installed;
  • the user has clicked or tapped on the page at least once, and has spent at least 30 seconds viewing it (at any time, even during a previous page load);
  • the page is served over HTTPS;
  • the manifest includes short_name or name, icons with a 192 px and a 512 px icon, start_url, and display set to fullscreen, standalone, minimal-ui or window-controls-overlay;
  • prefer_related_applications is absent or false.

That list is a good target. It is not an exact description of what current Chromium enforces. The code is stricter in some places and looser in others, and the engagement rule is historical: current Chromium no longer has an engagement requirement before beforeinstallprompt. The next sections go through the actual checks.

What Chromium actually checks: the install pipeline

The logic lives in components/webapps/browser/ in the Chromium tree. On every committed primary-frame navigation, AppBannerManager waits for the page to finish loading and then runs this pipeline:

flowchart TD
    A["Page load finishes"] --> B{"Secure context and not incognito?"}
    B -- no --> X1["Stop: not-from-secure-origin or in-incognito"]
    B -- yes --> C["Fetch and parse the linked manifest"]
    C --> D{"Manifest found and parsed?"}
    D -- no --> X2["Stop: no-manifest or manifest-parsing-or-network-error"]
    D -- yes --> E{"Android: prefer_related_applications with a Play app?"}
    E -- yes --> N["Native app flow: beforeinstallprompt with platforms play"]
    E -- no --> F["Check name, start_url, display and icon, then download the icon"]
    F --> G{"Any errors?"}
    G -- yes --> X3["Stop: first error is recorded"]
    G -- no --> H{"Already installed or conflicting app?"}
    H -- yes --> X4["Stop: already-installed"]
    H -- no --> I["Page is promotable: update install icon, dispatch beforeinstallprompt"]
    I --> J{"preventDefault called?"}
    J -- yes --> K["Browser waits for prompt from a user gesture"]
    J -- no --> L["Browser may show its own install UI"]

Details worth knowing:

  • Desktop and Android use different strictness. AppBannerManagerDesktop requests InstallableCriteria::kValidManifestWithIcons: every field must come from a valid manifest. AppBannerManagerAndroid requests kImplicitManifestFieldsHTML, which still needs a linked manifest but falls back to page metadata when members are missing. The name can come from <meta name="application-name"> or <title>, and the icon from a non-default favicon. Only an explicit display: "browser" is rejected. Don't rely on these fallbacks. Desktop installability, WebAPK quality and maskable icons all need a complete manifest.
  • Changing the manifest link restarts the pipeline. If script swaps <link rel="manifest"> to a new URL, Chromium aborts the running check (manifest-location-changed) and starts over. Removing the link marks the page as not installable.
  • The page-load trigger matters for your code. The pipeline starts after the load event, and beforeinstallprompt can be dispatched within milliseconds of it (about 7 ms after load on a local test page in Chrome 153). If your listener is attached late, for example after an await in a module, you miss the event. The tutorial hit exactly this bug during testing.
  • The address-bar icon doesn't depend on your event handler. On desktop, the install icon appears as soon as the check result becomes promotable, before beforeinstallprompt is dispatched. Calling preventDefault() suppresses Chrome's automatic install message on Android (the "mini-infobar"), but you can't use it to hide the desktop icon.

Secure context and profile requirements

InstallableEvaluator::CheckEligibility() rejects two situations before looking at the manifest:

  • in-incognito: the page is in an off-the-record profile (incognito or guest).
  • not-from-secure-origin: the page is not a secure context. HTTPS with a valid certificate passes. So do http://localhost, http://127.0.0.1 and other loopback hosts, and origins listed in chrome://flags/#unsafely-treat-insecure-origin-as-secure. That flag is useful for testing on a phone over the LAN. A certificate error you clicked through in the interstitial counts as insecure.

Manifest members, check by check

The following was verified against the Chromium source and by loading test manifests in Chrome 153 (desktop, headless, macOS). For each variant, the result of Page.getInstallabilityErrors was recorded along with whether beforeinstallprompt fired.

name or short_name

At least one must be a non-empty string. A manifest with only short_name passes. Missing both gives manifest-missing-name-or-short-name. On Android, the <title> or application-name fallback applies. For what each member is used for, see the Members Reference.

start_url

Desktop requires a start_url that was present in the JSON and parsed successfully. The flag is has_valid_specified_start_url. The spec's fallback (the document URL) doesn't count:

Manifest Result
"start_url": "/" or "./?source=pwa" Passes (relative URLs resolve against the manifest URL)
start_url missing start-url-not-valid
"start_url": "https://example.com/" on another origin Ignored by the parser ("property 'start_url' ignored, should be same origin as document"), then start-url-not-valid
start_url outside scope Installs, but scope is ignored and falls back to the default scope (the start_url directory)

Because the app's identity defaults to start_url when id is missing, changing start_url later can turn an installed app into a different app. Set id from day one (see App Identity & Updates).

display and display_override

The effective display mode is the first entry of display_override the browser recognizes, or display when there is no override. That mode must be one of:

  • standalone, fullscreen or minimal-ui (in either member);
  • window-controls-overlay, tabbed or unframed, which count only when they come from display_override. unframed is reserved for Isolated Web Apps, and tabbed depends on the platform.

Test results:

Manifest Result
"display": "standalone" / "minimal-ui" / "fullscreen" Passes
display missing manifest-display-not-supported
"display": "browser" manifest-display-not-supported
"display": "window-controls-overlay" Parser warning "inapplicable 'display' value ignored", then manifest-display-not-supported
"display": "browser", "display_override": ["window-controls-overlay"] Passes: the first override wins
"display": "standalone", "display_override": ["browser", "standalone"] manifest-display-override-not-supported: the first override is browser

A typo such as "standalon" is dropped silently and behaves like a missing display. DevTools shows a parser warning for it. Display Modes explains the fallback chain and the display-mode media query.

icons

DoesManifestContainRequiredIcon() looks for one icon that meets all of the following:

  • purpose contains any, either explicitly or through the default. An icon list with only maskable icons fails, even though the DevTools message says "any or maskable".
  • Its type is PNG, SVG or WebP. The type comes from type, or from the file extension when type is missing. JPEG, GIF and ICO don't count.
  • It declares sizes, and one size is at least 144 px, or it is an SVG with "sizes": "any". The 144 px floor is kMinimumPrimaryIconSizeInPx: 48 dp times 3x density.
  • On desktop, it is no larger than 1024 px. On Android there is no upper limit.
  • It downloads successfully and decodes as a valid image. Otherwise you get no-acceptable-icon, cannot-download-icon or no-icon-available.
Icons in the manifest Result
192 px + 512 px PNG Passes
Only 192 px PNG Passes
Only 144 px PNG Passes
Only 128 px PNG manifest-missing-suitable-icon (minimum 144)
512 px PNG without sizes manifest-missing-suitable-icon
192 + 512 PNG, both purpose: "maskable" manifest-missing-suitable-icon
512 px declared as image/jpeg manifest-missing-suitable-icon
Only a 2048 px PNG (desktop) manifest-missing-suitable-icon (desktop maximum 1024)
SVG with "sizes": "any" Passes
src returns 404 no-acceptable-icon

Meeting the 144 px minimum is not a good target. Android launchers and splash screens scale up from the best icon available, and the WebAPK minting service and desktop OS integrations look better with a 512 px source. Add maskable icons alongside the any icons: on Android, Chrome prefers a maskable icon for the launcher when one exists (prefer_maskable_icon). The Icons & Maskable Icons page covers the safe zone and the full size matrix.

This member mostly affects installation on Android. With "prefer_related_applications": true and a related_applications entry for "platform": "play", Chrome for Android runs the native-app flow: it fetches Play Store details and dispatches beforeinstallprompt with platforms: ["play"], and prompt() opens the Play install UI. Misconfigured entries produce platform-not-supported-on-android, no-id-specified or ids-do-not-match. Chrome also skips web-app promotion when a listed related app is already installed. On desktop the member is mostly ignored (a manifest listing only a play app stayed installable and beforeinstallprompt fired, which matches MDN's compatibility data), with one exception: desktop Chromium suppresses install promotion when related_applications lists a chrome_web_store entry (or, on ChromeOS devices that run Android apps, a play entry). To detect installed native or web apps from script, see Detecting Installed Apps.

A minimal manifest that passes everywhere Chromium checks

manifest.webmanifest
{
  "id": "/",
  "name": "Pocket Notes",
  "short_name": "Notes",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "background_color": "#f7f4ed",
  "theme_color": "#1f6f5c",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
    { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}

Link it from every page that should be installable, not only the home page:

index.html
<link rel="manifest" href="/manifest.webmanifest">

Chromium doesn't check the manifest's Content-Type. A manifest served as application/octet-stream passed in testing. The specification only encourages application/manifest+json (any JSON MIME type is acceptable), so still configure it on your server. What does break the fetch:

  • a Content Security Policy whose manifest-src (or default-src) doesn't allow the manifest URL;
  • a manifest that requires cookies. The manifest is fetched in CORS mode without credentials unless the link has crossorigin="use-credentials", so an authenticated manifest URL returns a login page that fails to parse;
  • a manifest on another origin (for example a CDN) that doesn't send Access-Control-Allow-Origin.

The service worker requirement: history and current state

For years, a service worker with a fetch handler was the most famous installability rule. It no longer applies:

Period Chromium behavior Source
Until Chrome 107 (Android) / 111 (desktop) Promotion and menu installs required a service worker that controls the page and start_url and has a fetch handler. Many sites shipped empty fetch handlers to pass, which slowed every request down. Revisiting Chrome's installability criteria
Chrome 108 (Android) / 112 (desktop) Menu installation no longer requires a fetch handler. Installed apps without an offline experience get a default offline page from Chrome. The 2023 post noted that the promotion algorithm still required a fetch handler. Same post
September 2024 The web.dev criteria list no longer mentions a service worker. web.dev install criteria
2026 (Chromium main) InstallableParams, InstallableEvaluator and the banner pipeline contain no service worker check. In Chrome 153, beforeinstallprompt fired on test pages that registered no service worker at all. installable_params.h

Build a service worker anyway, for what it gives users: offline launch, instant repeat loads, and access to push, background sync and the other APIs listed in Core Building Blocks. An installed app that shows Chrome's generic offline page when the network drops feels broken. Offline UX & Fallbacks covers doing better. Don't add an empty fetch handler: it gives the browser no benefit and costs a worker startup on every navigation. The pitfalls page explains why.

User engagement, timing and the beforeinstallprompt lifecycle

The engagement rule that web.dev still documents ("clicked at least once and viewed for 30 seconds") is historical: Chromium removed it, and it doesn't reproduce in testing. The banner pipeline in current Chromium has no site-engagement check between the installability check and SendBannerPromptRequest(). In headless Chrome 153, the event fired on the first page load, a few milliseconds after load, with no clicks, taps or dwell time at all. It also fired on test pages that registered no service worker. MDN puts it correctly: "There's no guaranteed time this event is fired, but it usually happens on page load." Write code that works whenever the event arrives, or if it never does:

  • Listen early. Attach the beforeinstallprompt listener synchronously while your first script evaluates.
  • prompt() needs transient user activation. Called outside a user gesture, it rejects with NotAllowedError ("The prompt() method must be called with a user gesture"). A second call on the same event throws InvalidStateError.
  • One prompt per event. prompt() resolves with the same result as userChoice: { outcome: "accepted" | "dismissed", platform }. That has been true since Chrome 76; earlier versions resolved with nothing. If the user dismisses the dialog, Chromium dispatches a new beforeinstallprompt event, so your button can come back.
  • appinstalled fires for every successful install, including installs started from the browser menu or address-bar icon.

Chrome's own promotional UI is rate-limited. The beforeinstallprompt event is not. On Android, the automatic install message goes through guardrails in app_banner_settings_helper.cc: it is suppressed for 90 days after the user dismisses it and for 7 days after it was shown and ignored. It is also gated by an on-device machine-learning classifier (kWebAppsEnableMLModelForPromotion, enabled by default on Android and disabled on desktop in current source). Chrome's July 2024 post describes this model as predicting "whether a user will want to install a given page based on a collection of signals". This is why two users on the same site can see different behavior. Building install UI on top of beforeinstallprompt is covered in Install Prompts & Custom UI.

Deep dive: the console message you see when you defer the prompt

When your handler calls preventDefault(), Chrome logs an informational console message: "Banner not shown: beforeinstallpromptevent.preventDefault() called. The page must call beforeinstallpromptevent.prompt() to show the banner." This is expected, not an error. It confirms Chrome considered the page promotable and handed control to your code.

Installing sites that fail the criteria

Since 2024, Chrome lets users install pages that don't meet the criteria. The desktop path is More > Cast, save, and share > Install page as app. On Android it's More > Add to home screen > Install (Chrome's labels vary slightly by version and platform). According to Chrome's July 2024 post, Chrome 128 also changed Create shortcut: it now makes a shortcut that opens in a normal tab, and Install page as app is the path to a standalone window. Chrome builds such an app from whatever the page offers: its title, favicon and a default standalone display. The post's example is Wordle, which lacked icons and start_url in its manifest yet could still be installed.

So the criteria are no longer a gate on possibility. They decide promotion and the quality of the installed app. An app installed without your manifest gets no shortcuts, file handlers, share target or protocol handlers, and no maskable icon. It may also get the wrong id, which makes later migrations painful.

Android: WebAPK or home-screen shortcut

On Android, an install produces one of two very different things:

WebAPK Home-screen shortcut
Who creates it A minting server trusted by the device signs a real APK. Chrome uses Google's servers; Samsung Internet uses Samsung's. The browser pins a launcher shortcut
Browsers Chrome on devices with Google Play services; Samsung Internet on Samsung devices Firefox, Edge, Opera, Brave, Samsung Internet on non-Samsung devices, Chrome without Google Play services
Launcher and app drawer Real app entry; appears in Settings > Apps Home-screen icon only, badged with the browser logo
Link capturing Intent filters for the manifest scope: links open in the app No
Manifest updates Chrome checks when the WebAPK is launched, at most about once a day, and regenerates the WebAPK in the background when key members change Not updated
Capabilities that need installation (shortcuts, share target, badging) Yes No

WebAPK minting has its own constraints on top of the promotion criteria. Manifest URLs (start_url, scope, icon src) must not contain embedded credentials (https://user:pass@…), or you get url-not-supported-for-webapk. If minting fails or the service is unavailable, Chrome falls back to a shortcut. According to web.dev's manifest update article, Chrome for Android updates a WebAPK when name, short_name, icons, background_color, display, orientation, scope, shortcuts, start_url, theme_color or web_share_target change. Android covers WebAPK behavior in depth, and Trusted Web Activity covers the Play Store alternative.

Microsoft Edge

Edge on Windows, macOS and Linux uses the same Chromium pipeline and error codes. When a page is promotable, Edge shows an App available icon in the address bar. Installed apps are managed at edge://apps or through Settings and more > More tools > Apps. On Windows they integrate like native apps: Start menu, taskbar pinning, Alt+Tab, Add or remove programs, and an optional Auto-start on device login. Many PWAs are also listed in the Microsoft Store. See Publishing to App Stores. Edge on Android creates shortcuts, not WebAPKs.

Samsung Internet

Samsung Internet is Chromium-based, and MDN lists the same manifest requirements for it as for Chrome: name or short_name, 192 px and 512 px icons, start_url, display or display_override, and no prefer_related_applications: true. When it detects an installable PWA, it shows an install icon in the URL bar. It installs WebAPKs only on Samsung devices and creates shortcuts on other Android phones. According to MDN's compatibility data, it supports BeforeInstallPromptEvent (since Samsung Internet 5.0, with the onbeforeinstallprompt handler property since 8.0) and appinstalled (since 7.0). Test on a Samsung device: its minting and UI differ from Chrome's.

Safari on iOS and iPadOS

Safari never promotes installation and has no beforeinstallprompt or appinstalled events. Installation is always initiated by the user through the Share sheet. What changed in 2025 is what the result looks like.

Before iOS 26

From iPhone OS 2.1 (2008) until iOS 18, a Home Screen icon opened as a standalone web app only if the site asked for it: either <meta name="apple-mobile-web-app-capable" content="yes">, or, from iOS 11.3, a manifest with display set to standalone or fullscreen. Anything else became a bookmark that opened in the browser. From iOS and iPadOS 16.4, other browsers can also add Home Screen web apps, through the share sheet of any browser that holds Apple's com.apple.developer.web-browser entitlement (Chrome, Edge and Firefox on iOS support it).

iOS 26 and later: every site opens as a web app

Safari 26, shipped with iOS 26 and iPadOS 26 in September 2025, reversed the default. In WebKit's words: "By default, every website added to the Home Screen opens as a web app. If the user prefers to add a bookmark for their browser, they can disable 'Open as Web App' when adding to Home Screen." The Safari 26.0 release post adds: "There are now zero requirements for 'installability' in Safari."

The current flow, as Apple's iPhone User Guide describes it, is: open the site in Safari, tap ⋯, then Share (or tap Share directly when the tab layout is Bottom or Top), choose Add to Home Screen, keep Open as Web App switched on, and tap Add. The Safari 27.0 release notes (released September 14, 2026) contain no web app or Home Screen changes, so this is still the current behavior.

This matters for your UX. Users can now install your site whether or not you planned for it, and the toggle means the user, not the manifest, decides between app and bookmark. Detect Home Screen launches by checking navigator.standalone === true first: a web app whose manifest says "display": "standalone" matches (display-mode: fullscreen) on iOS (WebKit bug 264218), and one with no manifest reports browser. Fall back to matchMedia('(display-mode: standalone)') elsewhere (see Detecting Installed Apps). Also design for sites that were never meant to run without browser chrome: navigation needs visible back affordances. See App-Like UX Patterns.

How Safari uses your manifest on iOS

The manifest is optional but still used. From MDN's compatibility data:

Member Safari on iOS/iPadOS
name, short_name, start_url, scope Supported since iOS 11.3 (users can still edit the name)
display standalone supported (11.3); fullscreen opens as standalone; minimal-ui not supported
icons Since 15.4, only when no apple-touch-icon link exists, and only icons whose purpose is any or absent
theme_color Since 15
id Since 16.4. Used, among other things, to sync Focus settings across devices
background_color, orientation, shortcuts, share_target, screenshots Not supported

Two practical consequences. First, keep an apple-touch-icon (180 × 180, opaque, full-bleed). iOS applies its own mask, and your manifest icons are ignored when the link exists. Second, capabilities such as Web Push and the Badging API are available only to Home Screen web apps, not to Safari tabs. Installation is therefore a prerequisite for those features on iOS, with no API to trigger it. Explain the Share flow in your UI, as the tutorial's iOS hint does. iOS & iPadOS covers storage, lifecycle and the other iOS details.

Safari on macOS: Add to Dock

Safari 17 on macOS Sonoma introduced web apps on the Mac: File > Add to Dock (also in the Share menu) works for any website. WebKit's announcement says that when a user clicks the icon, "the website always opens in its own window as a web app, even if the site does not have a manifest file." Details:

  • The manifest customizes the app. WebKit's Safari 17 announcement names the display mode, name, theme color and start URL; Safari 18 uses scope for link handling, and Safari 17.4 added support for shortcuts.
  • When the app is created, Safari copies the site's cookies into it, so a signed-in user stays signed in. Safari doesn't copy other storage (IndexedDB, Cache Storage, localStorage), so the app starts with empty client-side data.
  • Since Safari 18 (macOS Sequoia), links opened from other apps that match an installed web app's scope open in the web app instead of the default browser. Links clicked inside Safari stay in Safari, which shows an Open in web app banner unless the user dismissed it before. Without a manifest scope, matching is based on the host of the page the app was created from.
  • Like iOS, macOS never fires beforeinstallprompt. Web Push works for Dock web apps as it does for Safari.

Desktop Platforms compares the macOS, Windows, Linux and ChromeOS behaviors.

Firefox

Firefox for Android

Firefox for Android supports manifest-based installation. Its criteria are in the Android Components library that Firefox is built from (SessionState.installableManifest() and WebAppManifest.hasLargeIcons()):

  • The page is loaded over a secure connection.
  • A manifest is linked and parses, with name or short_name. Without either, parsing fails.
  • display is not browser. Gecko sets a missing display to browser, so you have to set standalone, fullscreen or minimal-ui explicitly.
  • At least one icon has purpose any or maskable and a declared size whose shorter side is at least 192 px.

When these pass, the menu offers to install the app ("Add app to Home screen" or "Install", depending on version) instead of adding a plain shortcut. The result is a pinned launcher shortcut badged with the Firefox logo, not a WebAPK. It launches in Firefox's standalone web-app activity, with the task switcher showing your name, icon and theme_color. Only standalone and fullscreen apps get a trusted scope (scope, or start_url when scope is missing). No service worker is required, and Firefox doesn't implement beforeinstallprompt, so your install button must fall back to instructions, as it does on iOS.

Firefox on the desktop

Firefox dropped its early "open web apps" platform years ago, and for a long time desktop Firefox couldn't install web apps at all. MDN's installability guide still says Firefox "does not support installing PWAs using a manifest file". That changed on Windows:

  • Firefox 143 (September 16, 2025) shipped taskbar web apps. The release notes say: "On Windows, Firefox now supports running websites as web apps pinned directly to the taskbar." Pinned sites run in a simplified window and keep your installed add-ons. The feature was not yet available in the Microsoft Store build.
  • Firefox 150 (April 21, 2026) made web apps available to Windows users who installed Firefox through the Microsoft Store.

As of the Firefox releases published by September 2026, web apps are enabled by default only on Windows. On Linux the feature exists but is disabled by default behind the browser.taskbarTabs.enabled preference; macOS has no equivalent. Treat Firefox desktop installation as user-initiated with no developer-facing criteria. There is no install event, no appinstalled, and no documented list of manifest members it honors, so don't depend on manifest-only features there.

Emerging: installing apps from other sites

Experimental

Two proposals let one page install a web app, including one on another origin, which beforeinstallprompt can't do. navigator.install() (the Web Install API) ran as an origin trial in Chrome and Edge 143 through 148, extended through milestone 150, and an Intent to Ship posted in September 2026 targets desktop Chrome 156 (no Android). The declarative <install> element ran a desktop origin trial from milestone 148 through 153, which has ended; its attributes are manifest and manifestId. WebKit opposes site-initiated installation and Mozilla has not signaled a position. Both identify the target by its install URL plus an optional manifest ID, so an explicit id in your manifest makes your app a predictable install target. Neither is enabled by default in any stable browser as of September 2026. See Install Prompts & Custom UI for the current status and code.

Browser support: installability criteria compared

Support data as of September 2026. For live data, see MDN's Making PWAs installable, the manifest compatibility tables, and caniuse: Web App Manifest.

Requirement for promotion or manifest-based install Chrome & Edge (desktop) Chrome (Android) Samsung Internet Safari (iOS/iPadOS 26+) Safari (macOS 14+) Firefox (Android) Firefox (Windows)
Secure context (HTTPS or localhost) ✅ Required ✅ Required ✅ Required ❌ Not required ❌ Not required ✅ Required ❌ Not required
Linked, parseable manifest ✅ Required ✅ Required ✅ Required ❌ Optional ❌ Optional ✅ Required ❌ Optional
name or short_name ✅ Required ⚠️ Falls back to page title1 ✅ Required ❌ Optional ❌ Optional ✅ Required ❌ Optional
Explicit start_url ✅ Required ⚠️ Can fall back1 ✅ Required ❌ Optional ❌ Optional ❌ Defaults to page URL ❌ Optional
display ✅ standalone, fullscreen, minimal-ui (others via display_override) ⚠️ Anything but browser1 ✅ Same as Chrome ❌ Ignored for install ❌ Ignored for install ✅ Anything but browser ❌ Ignored
Icon ✅ any, PNG/SVG/WebP, sizes set, ≥ 144 px, ≤ 1024 px ✅ any, ≥ 144 px (favicon fallback1) ✅ 192 + 512 px per MDN ❌ Optional (apple-touch-icon preferred) ❌ Optional ✅ any or maskable, ≥ 192 px ❌ Optional
prefer_related_applications not true ⚠️ Mostly ignored3 ✅ Required for web install ✅ Required ❌ Ignored ❌ Ignored ❌ Ignored ❌ Ignored
Service worker ❌ Not required ❌ Not required ❌ Not listed by MDN ❌ Not required ❌ Not required ❌ Not required ❌ Not required
beforeinstallprompt / appinstalled ✅ ✅ ✅ ❌ ❌ ❌ ❌
Result App window WebAPK2 WebAPK on Samsung devices Home Screen web app Dock web app Badged shortcut Taskbar web app

Common failure reasons and how to fix them

The error IDs below are the ones Chromium returns in DevTools and in Page.getInstallabilityErrors. The quoted strings are the messages from installable_logging.cc. One problem can produce more than one ID: in Chrome 153, a manifest whose only icon is 128 px, has no sizes, or is marked only maskable reports both manifest-missing-suitable-icon and no-acceptable-icon, each with the argument minimum-icon-size-in-pixels=144. Fix the first ID and re-run the check.

Error ID Message Typical cause Fix
not-from-secure-origin "Page is not served from a secure origin" HTTP in production, testing a phone via http://192.168.x.x, invalid certificate Use HTTPS. On a phone, use chrome://inspect port forwarding so the phone can load localhost, or the unsafely-treat-insecure-origin-as-secure flag
in-incognito "Page is loaded in an incognito window" Testing in incognito or guest mode Use a normal profile, or a separate test profile
no-manifest "Page has no manifest \<link> URL" Link missing on this page, typo in rel, link injected after the check ran Add <link rel="manifest"> to every page's <head> in the server-rendered HTML
manifest-parsing-or-network-error "The manifest could not be fetched, parsed, or the document is on an opaque origin" 404, JSON syntax error (trailing comma, comments), auth-protected manifest, CSP manifest-src, missing CORS header on a cross-origin manifest, sandboxed document Validate the JSON; open the manifest URL directly; add crossorigin="use-credentials" if cookies are needed; allow the URL in manifest-src
manifest-location-changed "Manifest location changed during fetch" Script replaced the manifest link during the check Render the final link server-side; if you swap manifests on purpose, expect a re-check
start-url-not-valid "Manifest start URL is not valid" start_url missing, cross-origin (ignored) or unparseable Add a same-origin start_url such as "/?source=pwa"
manifest-missing-name-or-short-name "Manifest does not contain a 'name' or 'short_name' field" Both missing or empty Add name (up to about 45 characters is typical) and short_name (about 12)
manifest-display-not-supported "Manifest 'display' property must be one of 'standalone', 'fullscreen', or 'minimal-ui'" Missing, browser, typo, or window-controls-overlay placed in display Set "display": "standalone"; put window-controls-overlay in display_override
manifest-display-override-not-supported "…the first supported display mode must be one of 'standalone', 'fullscreen', or 'minimal-ui'" First recognized display_override entry is browser Reorder display_override, or remove browser from it
manifest-missing-suitable-icon "Manifest does not contain a suitable icon - PNG, SVG or WebP format of at least 144px is required, the sizes attribute must be set…" Only maskable icons, no sizes, JPEG/ICO, all icons smaller than 144 px or (desktop) larger than 1024 px Add purpose: "any" PNG icons at 192 and 512 px with correct sizes and type
no-acceptable-icon "No supplied icon is at least 144px square in PNG, SVG or WebP format" The chosen icon's URL returns 404 or HTML, or the declared size is wrong Open each icon URL; make the real pixel size match sizes
cannot-download-icon / no-icon-available "Could not download a required icon from the manifest" / "Downloaded icon was empty or corrupted" Network failure, zero-byte file, corrupt PNG, wrong Content-Type Re-export the image; check the server response
already-installed "The app is already installed" An app with the same id (or conflicting start_url) is installed Expected. To test again, uninstall via chrome://apps, edge://apps or the app's menu
prefer-related-applications "Manifest specifies prefer_related_applications: true" Android is routed to the Play Store listing Remove it or set it to false if you want web installs
prefer-related-applications-only-beta-stable "prefer_related_applications is only supported on Chrome Beta and Stable channels on Android" Testing native-app promotion in Canary or Dev Test on Beta or Stable
platform-not-supported-on-android, no-id-specified, ids-do-not-match Related-application errors Malformed related_applications entries Use { "platform": "play", "id": "com.example.app", "url": "https://play.google.com/store/apps/details?id=com.example.app" }
url-not-supported-for-webapk "A URL in the manifest contains a username, password, or port" Credentials embedded in start_url, scope or an icon URL Remove the credentials (the current code checks only username and password; the message is older)

Debugging installability

Chrome and Edge DevTools: the Manifest pane

Open DevTools (Ctrl+Shift+I or Cmd+Option+I), then Application > Manifest:

  • Errors and warnings lists parser problems, such as ignored members, invalid colors and unknown display values. A member that fails to parse is dropped silently at runtime, so read these warnings.
  • Installability appears when the check fails, with the human-readable form of the error IDs above.
  • Identity shows the computed app ID. When id is missing, DevTools recommends a value derived from start_url. For a start_url of /?source=pwa, the CDP method Page.getAppId returned appId: "http://localhost:4180/" and recommendedId: "/?source=pwa".
  • Presentation, Icons and Screenshots render what the browser parsed. Tick Show only the minimum safe area for maskable icons to check your maskable artwork.

The Service workers and Storage panes sit next to it. A full walkthrough of the Application panel is in Browser DevTools. Lighthouse no longer has a PWA category, so the Manifest pane and the protocol method below are the authoritative checks. See Lighthouse & Auditing for what Lighthouse still checks.

Checking installability in CI with the DevTools Protocol

DevTools builds its Installability section from the protocol method Page.getInstallabilityErrors, which runs Chromium's real check. You can call it from Puppeteer or Playwright in a pipeline and fail the build when the result isn't empty. This script was run against a passing site and a page without a manifest (which reported no-manifest and exited with status 1):

scripts/check-installability.mjs
// Fails the build when Chromium reports installability errors.
// Usage: node scripts/check-installability.mjs https://staging.example.com/
import puppeteer from 'puppeteer';

const url = process.argv[2] ?? 'http://localhost:3000/';
const browser = await puppeteer.launch();

try {
  const page = await browser.newPage();
  await page.goto(url, { waitUntil: 'load' });

  const cdp = await page.createCDPSession();
  // Runs Chromium's own installability check (the one behind DevTools' Manifest pane).
  const { installabilityErrors } = await cdp.send('Page.getInstallabilityErrors'); // (1)!
  const manifest = await cdp.send('Page.getAppManifest'); // (2)!

  console.log(`Manifest: ${manifest.url || '(none linked)'}`);
  for (const { message } of manifest.errors) console.warn(`  parse warning: ${message}`);

  if (installabilityErrors.length === 0) {
    console.log('Installable: no errors reported.');
  } else {
    for (const { errorId, errorArguments } of installabilityErrors) {
      const args = errorArguments.map(({ name, value }) => `${name}=${value}`).join(', ');
      console.error(`  ✗ ${errorId}${args ? ` (${args})` : ''}`);
    }
    process.exitCode = 1;
  }
} finally {
  await browser.close();
}
  1. Returns { installabilityErrors: [{ errorId, errorArguments: [{ name, value }] }] }. The IDs match the table above. For icon errors, errorArguments includes minimum-icon-size-in-pixels.
  2. Returns the manifest URL, the raw text, and parser errors. Warnings such as "inapplicable 'display' value ignored." show up here before they turn into installability errors.

Both methods are marked experimental in the protocol, so pin your Puppeteer or Chrome version in CI. The check runs in a headless browser and doesn't depend on user engagement. For more on end-to-end testing of service workers and manifests, see Automated Testing.

Internal pages

  • chrome://web-app-internals (desktop Chrome) dumps every installed web app: its manifest-derived data, id, scope, install source, OS integration state and recent install and update activity. Use it when an app installs but looks wrong, or doesn't update.
  • about://webapks (Chrome for Android) lists installed WebAPKs with their manifest data, shell version and update status.
  • edge://apps and chrome://apps list installed apps and let you uninstall them. You need to do that to re-test the first-install path.

Debugging on iOS, Android and Firefox

  • Android (Chrome, Samsung Internet): connect over USB with developer options enabled and open chrome://inspect on the desktop. Port forwarding (for example device port 3000 to localhost:3000) lets the phone treat your dev server as localhost, a secure context, so you can test the real install flow without certificates.
  • iOS and iPadOS: enable Web Inspector on the device (Settings > Apps > Safari > Advanced), connect it to a Mac, and use Safari's Develop menu. Home Screen web apps are listed separately from Safari tabs. There are no installability errors to inspect. Check that your icons, start_url and scope behave as intended after Add to Home Screen.
  • Firefox for Android: use about:debugging in desktop Firefox with USB debugging. If the menu shows only the plain Add to Home screen, check display first, then the 192 px icon requirement.

Common pitfalls

  • Treating the old checklist as current. Chromium no longer needs a service worker, and Safari needs nothing. Test with the current browsers rather than a blog post from 2019.
  • Shipping only a 512 px maskable icon. Maskable-only manifests fail the purpose: "any" requirement. Always ship any icons, and add maskable ones next to them.
  • Putting window-controls-overlay in display. It's valid only in display_override. In display, the parser drops it and installability fails.
  • Omitting start_url because "it defaults to the current page". The spec default doesn't satisfy desktop Chrome, and it also makes your app identity depend on which page the user installed from.
  • Attaching beforeinstallprompt late. The event can fire right after load. Register the listener before any await, and keep your install UI hidden until the event arrives.
  • Assuming engagement gates the event. Don't show "install" UI on a timer expecting the event later. Show it when the event has arrived and the user has done something meaningful.
  • Forgetting the manifest on deep pages. Users install from whichever page they're on. Link the manifest on every page, and choose start_url and id so the result is the same app.
  • Testing only in Chrome. On iOS, users can now install any page as a web app, and Firefox Android has its own 192 px rule. Check each platform in the table above.

Further reading

On this site

External references


  1. Current Chromium source uses kImplicitManifestFieldsHTML on Android, which falls back to <title> or application-name, a non-default favicon, and page metadata. Desktop requires the members themselves. ↩↩↩↩

  2. On devices without Google Play services, Chrome creates a browser-badged shortcut. ↩

  3. Desktop Chromium suppresses install promotion when related_applications lists a chrome_web_store entry (or a play entry on ChromeOS devices that run Android apps). ↩