Install Prompts and Custom Install UI¶
An install prompt is the browser dialog that asks the user to install your site as an app. A custom install UI is your own button, card or banner that opens that dialog at a moment you choose. In Chromium-based browsers you get that control through the beforeinstallprompt event. You intercept it, keep it, and call its prompt() method from a click. Safari and Firefox have no equivalent, so a complete implementation also needs an instructions UI for Add to Home Screen and Add to Dock. This page covers the event down to Chromium's source, complete code for the common promotion patterns, the appinstalled event, the iOS fallback, the experimental Web Install API, funnel analytics, and the UX rules that separate helpful promotion from nagging.
Key takeaways
beforeinstallpromptis Chromium-only (Chrome, Edge, Samsung Internet, Opera). It fires atwindowonce the page is promotable: afterload, once per document, again after each dismissal, and again after a back/forward cache restore. It never fires when the app is already installed in that browser profile.preventDefault()suppresses the browser's automatic install UI (the install message on Android). It doesn't remove the address-bar install icon on desktop.prompt()needs transient user activation and consumes it. Without it, the promise rejects withNotAllowedError. Each event shows the dialog at most once. The result is{ outcome, platform }, withplatformset to"web"on accept and""on dismiss.appinstalledfires for every successful install, including installs from browser UI. On Android it fires when the user accepts, before the WebAPK exists.- On iOS, iPadOS, macOS Safari and Firefox you can only explain installation. Detect the platform, show instructions on demand, and never show them inside the installed app. Since iOS 26, the Share button's location depends on the user's tab layout, so describe the steps instead of pointing at a fixed spot.
navigator.install()and<install>can install the current app or another origin's app. Both are experimental: the origin trials have ended, Chrome Platform Status lists intents to ship on desktop in Chrome 156 (stable scheduled for October 20, 2026), and WebKit opposes them.- Measure a funnel: promotable, promotion shown, prompt shown, accepted, installed, launched. Deduplicate
beforeinstallprompt, because it fires on every page load.
How Chromium decides to fire beforeinstallprompt¶
The event is the last step of a pipeline Chromium's AppBannerManager runs for every main-frame document. Knowing the pipeline explains when the event arrives, why it sometimes doesn't, and why it can arrive more than once.
sequenceDiagram
participant Page
participant ABM as AppBannerManager (browser)
participant UI as Browser install UI
Page->>ABM: main frame finishes loading (after load)
ABM->>ABM: fetch and parse manifest
ABM->>ABM: installability check (icons, display, start_url...)
ABM->>ABM: already installed? prefer_related_applications?
ABM->>Page: dispatch beforeinstallprompt (platforms ["web"])
alt page calls preventDefault()
Page-->>ABM: reply CANCEL (console info message)
else not canceled
ABM->>UI: may show automatic install UI (Android install message)
end
Page->>ABM: prompt() inside a click handler
ABM->>UI: show install dialog
alt user accepts
UI-->>Page: userChoice {outcome "accepted", platform "web"}
UI-->>Page: appinstalled
else user dismisses
UI-->>Page: userChoice {outcome "dismissed", platform ""}
ABM->>Page: new beforeinstallprompt event
end The pipeline, as implemented in app_banner_manager.cc:
- Start after load. The pipeline starts from
DidFinishLoadfor the primary main frame (and also fromDidFailLoadwithERR_ABORTED, so a user who stops loading doesn't block it), so the event normally arrives afterload. MDN puts it like this: "There's no guaranteed time this event is fired, but it usually happens on page load." How long afterloaddepends on the manifest fetch, icon downloads and the installability check, all of which run in the browser process. - Fetch the manifest and check installability. The page must meet the installability criteria. If it doesn't, the pipeline stops silently. DevTools shows the reason in Application > Manifest.
- Check for an existing installation. If an app with the same identity is already installed in this browser profile, the pipeline stops with
ALREADY_INSTALLED. Your installed users never see the event in a tab, which is itself a weak detection signal (see Detecting Installed Apps). - Check
prefer_related_applications. If the manifest prefers a related app for this platform (aplayentry on Android; on desktop, achrome_web_storeentry, orplayon ChromeOS with Android apps), the web app isn't promoted. On Android, Chrome may promote the Play Store app instead, and then the event'splatformsis["play"]rather than["web"]. - Dispatch the event. Chromium creates a new event, sends it to the renderer, and records whether the page canceled it.
Several consequences follow directly from the source:
- Once per document, not once per visit. Same-document navigations (
history.pushState()in a single-page app) don't restart the pipeline. A multi-page app gets a new event on every page load. - Again after every dismissal. When the user dismisses the dialog,
SendBannerDismissed()callsSendBannerPromptRequest()straight away, so a fresh event is dispatched. Your code must decide whether to show the button again. - Again after a back/forward cache restore. A page restored from the back/forward cache runs the pipeline again and gets a new event. The event you saved before the page entered the cache is dead: its connection to the browser was reset.
- Never after installation. After a successful install,
OnInstall()drops the pending event, and the app now counts as installed for that profile. - Only in the top-level document. The spec fires the event at the top-level browsing context's global object. Iframes never receive it.
- Not in incognito windows. Chromium doesn't install web apps in off-the-record profiles.
Chrome's own promotion is rate-limited; the event is not¶
Chrome's automatic install UI on Android (the install message and the bottom sheet) is subject to guardrails in app_banner_settings_helper.cc: it's suppressed for 90 days after a dismissal and for 7 days after it was shown and ignored, and it's gated by an on-device machine-learning model. Installability Criteria documents those rules. The beforeinstallprompt event isn't subject to them. Your own UI can be shown on the next page load after a dismissal, which is exactly why your code needs its own frequency rules (see UX guidelines and anti-patterns).
The BeforeInstallPromptEvent interface¶
The event is specified in the WICG's Manifest Incubations draft, but Chromium's shipping interface differs from that draft. Code against Chromium's IDL:
[Exposed=Window]
interface BeforeInstallPromptEvent : Event {
constructor(DOMString type, optional BeforeInstallPromptEventInit eventInitDict = {});
readonly attribute FrozenArray<DOMString> platforms;
readonly attribute Promise<AppBannerPromptResult> userChoice;
Promise<AppBannerPromptResult> prompt();
};
enum AppBannerPromptOutcome { "accepted", "dismissed" };
dictionary AppBannerPromptResult {
required DOMString platform;
required AppBannerPromptOutcome outcome;
};
The Manifest Incubations draft defines prompt() as resolving with a PromptResponseObject whose only member is userChoice, and it has no platforms or userChoice attributes. No browser implements that shape. Everything below describes Chromium's behavior.
| Member | Type | Behavior |
|---|---|---|
platforms | string[] | Platforms the install would target. ["web"] for a web app install. ["play"] when Chrome on Android promotes a related Play Store app. |
userChoice | Promise<{outcome, platform}> | Resolves once the user responds to the dialog, whether it was opened by prompt() or by browser UI. Pending forever if no dialog is shown. |
prompt() | Promise<{outcome, platform}> | Shows the install dialog. Needs transient user activation. Resolves with the same result as userChoice. |
preventDefault() | inherited | Cancels the browser's automatic install UI. The event is cancelable: true and bubbles: false. |
platforms and the platform result¶
platforms is set by the browser when it dispatches the event. Chromium's AppBannerManager::GetBannerType() returns "web" for a web app and "play" for a native Android app, and the array contains that single value. On acceptance, userChoice resolves with platform set to the same string. On dismissal, Chromium's renderer sets platform to the empty string. Check outcome, not platform, to decide what happened.
prompt(): user activation, one dialog per event¶
prompt() checks for transient user activation and consumes it. The code in before_install_prompt_event.cc is short:
// Not connected to the browser: a synthetic event, or one whose
// connection was reset.
if (!banner_service_remote_.is_bound()) {
exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError,
"The prompt() method cannot be called.");
return EmptyPromise();
}
LocalDOMWindow* window = LocalDOMWindow::From(script_state);
if (!LocalFrame::ConsumeTransientUserActivation(window ? window->GetFrame()
: nullptr)) {
exception_state.ThrowDOMException(
DOMExceptionCode::kNotAllowedError,
"The prompt() method must be called with a user gesture");
return EmptyPromise();
}
banner_service_remote_->DisplayAppBanner();
return user_choice_->Promise(script_state->World());
Because prompt() returns a promise, both exceptions become rejected promises, not synchronous throws. The checks run in a fixed order, which tells you what each rejection means:
| Rejection | Message | Cause |
|---|---|---|
InvalidStateError | "The prompt() method cannot be called." | The event isn't connected to the browser: you constructed it yourself, or its connection was reset |
NotAllowedError | "The prompt() method must be called with a user gesture" | No transient user activation, or it was already consumed |
The same connection check guards userChoice: reading it on a disconnected event gives a promise rejected with InvalidStateError ("userChoice cannot be accessed on this event."). Practical effects:
- Call
prompt()synchronously inside aclick,keydownorpointeruphandler, or within the activation window that follows one (about five seconds in Chromium). Code that first awaits a slow network request can lose activation and getNotAllowedError. - A second
prompt()call in the same handler rejects withNotAllowedError, because the first call consumed the activation and the activation check runs before anything else happens. - The browser side of the event resets its end of the connection on the first
DisplayAppBanner()call. Chromium's comment reads "Prevent this from being called multiple times on the same connection." Even with a fresh gesture, a used event can't show the dialog again. Throw it away after one use and wait for the next event. - You can call
prompt()from inside yourbeforeinstallpromptlistener while the event is still being dispatched. Chromium tracks this as an "early prompt" state. It still needs user activation, so in practice this only works when the event was dispatched shortly after a click. - Since Chrome 76,
prompt()resolves with the{ outcome, platform }result. Chrome 44 to 75 resolved it withundefined, which is why older samples readuserChoiceinstead. ReadinguserChoicestill works, and code that falls back to it works in both.
The dialog itself is browser UI. On desktop Chrome and Edge it's an anchored install dialog. When the manifest has screenshots it becomes the rich install UI. On Android it's a bottom sheet or dialog, depending on the Chrome version and on whether the manifest provides screenshots.
preventDefault(): what it cancels and what it doesn't¶
Calling preventDefault() tells Chromium not to show its automatic install UI. On Android this suppresses the install message ("ambient badge") that would otherwise appear. Chrome logs an informational console message, not an error:
Banner not shown: beforeinstallpromptevent.preventDefault() called. The page must call beforeinstallpromptevent.prompt() to show the banner.
What preventDefault() does not do:
- It doesn't remove the install icon from the desktop address bar, or Install page as app from the menu. Users can still install through browser UI, and you'll get
appinstalledif they do. - It doesn't stop the next event. A dismissal or a new page load produces a new event, and you have to call
preventDefault()on each one. - It's not required in order to call
prompt()later. You can keep an event you didn't cancel. Cancel it when you plan to show your own UI and don't want Chrome's message competing with it.
Constructing the event in tests¶
The interface has a constructor, so unit tests can create one: new BeforeInstallPromptEvent("beforeinstallprompt", { platforms: ["web"] }). A synthetic event isn't connected to the browser and can never show a dialog. In Chromium, its prompt() and userChoice both reject with InvalidStateError (the Manifest Incubations draft specifies NotAllowedError for untrusted events instead). The constructor only exists in Chromium, so tests running in jsdom or happy-dom can't use it anyway. Dispatch a plain Event with stubbed prompt and userChoice properties instead, as shown in Testing the install flow.
Browser support¶
| Browser | beforeinstallprompt | prompt() resolves with result | appinstalled |
|---|---|---|---|
| Chrome (desktop and Android) | ✅ 44 (onbeforeinstallprompt property 61) | ✅ 76 | ✅ 64 desktop, 57 Android |
| Edge (Chromium) | ✅ 79 | ✅ 79 | ✅ 79 |
| Samsung Internet | ✅ 5.0 (onbeforeinstallprompt property 8.0) | ✅ 5.0 | ✅ 7.0 |
| Opera (desktop and Android) | ✅ | ✅ | ❌ (see note) |
| Firefox (desktop and Android) | ❌ | ❌ | ❌ |
| Safari (macOS, iOS, iPadOS) | ❌ | ❌ | ❌ |
According to MDN, Opera exposes the onappinstalled handler property but never fires the event, so feature-detecting "onappinstalled" in window gives a false positive there.
Support data as of September 2026, from MDN's browser compatibility data. See MDN: BeforeInstallPromptEvent and MDN: appinstalled for live data. Chrome on iOS and every other iOS browser use WebKit and don't fire the event.
Capturing the event early¶
The event can fire before your application code runs. Module scripts are deferred, bundles are lazy-loaded, and frameworks hydrate late. If nobody listens when the event fires, it's gone until the next page load or dismissal. Capture it with a tiny classic script in <head> and let your app pick it up later:
<script>
// Runs before any deferred or module script. Keeps the most recent
// beforeinstallprompt event until the install controller takes it over.
window.addEventListener("beforeinstallprompt", function (event) {
// Only cancel the browser's own install UI if you will offer yours.
event.preventDefault();
window.__deferredInstallPrompt = event;
});
</script>
Keep this listener even after your controller loads. A later dismissal or back/forward cache restore delivers a new event, and the controller below reads whatever is newest.
A complete install controller¶
Every install pattern needs the same state: whether a prompt is available, whether the app is already installed, which fallback to offer, and what happened. Centralize it in one module and let the UI subscribe.
/**
* Install controller: one source of truth for install state.
*
* States:
* "installed" running as an installed app, or installed during this session
* "promptable" a BeforeInstallPromptEvent is available (Chromium)
* "manual" the platform supports manual installation (Safari, Firefox)
* "unavailable" nothing to offer right now
*/
import { detectInstallPlatform } from "./install-platform.js";
import { track } from "./install-analytics.js";
const APP_DISPLAY_MODES = ["standalone", "minimal-ui", "window-controls-overlay", "tabbed"];
const subscribers = new Set();
let deferredPrompt = window.__deferredInstallPrompt ?? null;
let installedThisSession = false;
let lastPromptSource = null;
export function isRunningAsInstalledApp() {
// iOS/iPadOS Home Screen web apps: WebKit reports display-mode "fullscreen"
// for standalone apps, so check navigator.standalone first.
if (navigator.standalone === true) return true;
return APP_DISPLAY_MODES.some((mode) =>
window.matchMedia(`(display-mode: ${mode})`).matches,
);
}
export function getInstallState() {
if (installedThisSession || isRunningAsInstalledApp()) return "installed";
if (deferredPrompt) return "promptable";
const platform = detectInstallPlatform();
if (platform.manualInstructions) return "manual";
return "unavailable";
}
export function subscribe(callback) {
subscribers.add(callback);
callback(getInstallState());
return () => subscribers.delete(callback);
}
function notify() {
const state = getInstallState();
for (const callback of subscribers) callback(state);
}
/**
* Show the browser's install dialog. Must be called synchronously from a
* user gesture handler (click, keydown), because prompt() consumes
* transient user activation.
*/
export async function promptInstall(source) {
const event = deferredPrompt;
if (!event) return { outcome: "unavailable" };
// An event can show the dialog only once. Drop it now so a double click
// can't reuse it; Chromium dispatches a new event after a dismissal.
deferredPrompt = null;
window.__deferredInstallPrompt = null;
lastPromptSource = source;
notify();
track("install_prompt_shown", { source });
try {
// Chrome 76+ resolves prompt() with the result; older versions resolved
// with undefined, so fall back to userChoice.
const result = (await event.prompt()) ?? (await event.userChoice);
track("install_prompt_result", { source, outcome: result.outcome });
return result;
} catch (error) {
// NotAllowedError: no user activation (called after an await, or twice).
track("install_prompt_error", { source, error: error.name });
return { outcome: "error", error };
}
}
// Newer events replace older ones: after a dismissal, after a
// back/forward cache restore, or on the first load if the inline stub missed it.
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
const firstInSession = safeSessionGet("install:promotable") !== "1";
deferredPrompt = event;
if (firstInSession) {
safeSessionSet("install:promotable", "1");
track("install_promotable", { platforms: event.platforms.join(",") });
}
notify();
});
window.addEventListener("appinstalled", () => {
installedThisSession = true;
deferredPrompt = null;
window.__deferredInstallPrompt = null;
// lastPromptSource is null when the user installed from browser UI.
track("app_installed", { source: lastPromptSource ?? "browser_ui" });
notify();
});
// A saved event does not survive the back/forward cache: Chromium re-runs
// its pipeline and sends a new event. Remember which event was current when
// the page was frozen, and drop it on restore unless a newer one already
// replaced it.
let eventBeforeFreeze = null;
window.addEventListener("pagehide", (event) => {
if (event.persisted) eventBeforeFreeze = deferredPrompt;
});
window.addEventListener("pageshow", (event) => {
if (!event.persisted) return;
if (deferredPrompt && deferredPrompt === eventBeforeFreeze) {
deferredPrompt = null;
window.__deferredInstallPrompt = null;
}
eventBeforeFreeze = null;
notify();
});
// Desktop Chromium moves the tab into the new app window after an install,
// so the display mode can change under a live document.
for (const mode of APP_DISPLAY_MODES) {
window.matchMedia(`(display-mode: ${mode})`).addEventListener("change", notify);
}
// Storage can be blocked (privacy settings, sandboxed contexts); analytics
// deduplication degrades gracefully instead of breaking install UI.
function safeSessionGet(key) {
try {
return sessionStorage.getItem(key);
} catch {
return null;
}
}
function safeSessionSet(key, value) {
try {
sessionStorage.setItem(key, value);
} catch {
// Ignore.
}
}
Detecting the platform for fallbacks¶
Feature detection answers "can I call prompt()?" It can't answer "how would this user install manually?", which depends on the browser and OS. That needs user-agent parsing, with its usual caveats: iPadOS Safari reports a Mac user agent by default, and in-app browsers add their own tokens.
/**
* Classify the current browser for install instructions.
* Returns { id, manualInstructions } where id is one of:
* "ios-safari", "ios-other-browser", "ios-in-app", "ipados-safari",
* "macos-safari", "firefox-android", "firefox-windows", "chromium", "other"
*/
export function detectInstallPlatform() {
const ua = navigator.userAgent;
const hasBip = "onbeforeinstallprompt" in window;
// iPadOS 13+ Safari requests the desktop site and reports "Macintosh".
// Macs report maxTouchPoints 0 today, so a value above 1 means iPad.
// Revisit this check if Apple ships touch-screen Macs.
const isIPadOS = /Macintosh/.test(ua) && navigator.maxTouchPoints > 1;
const isIOS = /iPhone|iPod|iPad/.test(ua) || isIPadOS;
if (isIOS) {
// Common in-app browsers built on WKWebView. They have no Add to Home Screen.
if (/FBAN|FBAV|Instagram|Line\/|MicroMessenger|GSA\//.test(ua)) {
return { id: "ios-in-app", manualInstructions: true };
}
// Chrome (CriOS), Firefox (FxiOS) and Edge (EdgiOS) on iOS 16.4+
// can add Home Screen web apps through their own share sheet.
if (/CriOS|FxiOS|EdgiOS/.test(ua)) {
return { id: "ios-other-browser", manualInstructions: true };
}
return { id: isIPadOS || /iPad/.test(ua) ? "ipados-safari" : "ios-safari", manualInstructions: true };
}
// Safari on macOS: no Chrome/Chromium/Edge tokens, and "Version/" present.
const isMacSafari =
/Macintosh/.test(ua) && /Version\/(\d+)/.test(ua) && /Safari\//.test(ua) &&
!/Chrome|Chromium|Edg\//.test(ua);
if (isMacSafari) {
const major = Number(ua.match(/Version\/(\d+)/)[1]);
// Add to Dock arrived in Safari 17 on macOS Sonoma.
return { id: "macos-safari", manualInstructions: major >= 17 };
}
if (/Firefox\//.test(ua) && /Android/.test(ua)) {
return { id: "firefox-android", manualInstructions: true };
}
if (/Firefox\//.test(ua) && /Windows/.test(ua)) {
return { id: "firefox-windows", manualInstructions: true };
}
if (hasBip) return { id: "chromium", manualInstructions: false };
return { id: "other", manualInstructions: false };
}
The in-app browser list is illustrative, not exhaustive. Keep it short and test it against the apps your users actually come from (your referrer logs tell you which). For in-app browsers, the right instruction is "open this page in Safari", not "tap Share".
Pattern 1: a persistent install button¶
The simplest pattern is also the most respectful: a button in the header, account menu or settings that is visible only when installation is possible, and does nothing surprising.
<button type="button" class="install-button" id="install-button" hidden>
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24">
<path d="M12 3v12m0 0-4-4m4 4 4-4M5 21h14" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Install app
</button>
import { subscribe, promptInstall } from "./install-controller.js";
import { openInstallInstructions } from "./install-instructions.js";
const button = document.getElementById("install-button");
subscribe((state) => {
// Hide the button when there's nothing useful to do. "manual" still gets a
// button: it opens platform instructions instead of a browser dialog.
button.hidden = state === "installed" || state === "unavailable";
button.dataset.mode = state;
});
button.addEventListener("click", async () => {
if (button.dataset.mode === "manual") {
openInstallInstructions({ source: "header" });
return;
}
// No await before promptInstall(): prompt() needs the click's activation.
const { outcome } = await promptInstall("header");
if (outcome === "accepted") {
button.hidden = true;
}
});
/* Never show install UI inside the installed app, even before JS runs. */
@media (display-mode: standalone), (display-mode: minimal-ui),
(display-mode: window-controls-overlay) {
.install-button { display: none !important; }
}
The CSS rule is a safety net for the first paint. It can't cover iOS, which reports fullscreen for standalone Home Screen apps, so the JavaScript check with navigator.standalone stays the authority.
Pattern 2: an in-feed promotion card¶
A card inside the content (a feed, a list of documents, a dashboard) explains why to install and can be dismissed. It must remember the dismissal, or it becomes a nag.
import { subscribe, promptInstall } from "./install-controller.js";
import { openInstallInstructions } from "./install-instructions.js";
import { track } from "./install-analytics.js";
const DISMISS_KEY = "install-card:dismissed-at";
const COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const MIN_VISITS = 3;
function readNumber(key) {
try {
return Number(localStorage.getItem(key)) || 0;
} catch {
return 0;
}
}
function writeNumber(key, value) {
try {
localStorage.setItem(key, String(value));
} catch {
// Private mode or blocked storage: the card simply may reappear.
}
}
function recordVisit() {
const visits = readNumber("install-card:visits") + 1;
writeNumber("install-card:visits", visits);
return visits;
}
export function mountInstallCard(container) {
const visits = recordVisit();
const dismissedAt = readNumber(DISMISS_KEY);
const coolingDown = Date.now() - dismissedAt < COOLDOWN_MS;
if (visits < MIN_VISITS || coolingDown) return;
const card = document.createElement("section");
card.className = "install-card";
card.setAttribute("aria-labelledby", "install-card-title");
card.innerHTML = `
<h2 id="install-card-title">Get the app</h2>
<p>Open your lists from your home screen, even without a connection.</p>
<div class="install-card__actions">
<button type="button" class="install-card__primary">Install</button>
<button type="button" class="install-card__dismiss">Not now</button>
</div>`;
card.hidden = true;
container.prepend(card);
// subscribe() calls back synchronously, before it returns the unsubscribe
// function, so teardown must not touch it directly (temporal dead zone).
let unsubscribe = () => {};
let removed = false;
const teardown = () => {
if (removed) return;
removed = true;
card.remove();
queueMicrotask(() => unsubscribe());
};
let impressionLogged = false;
unsubscribe = subscribe((state) => {
if (removed) return;
if (state === "installed") {
teardown();
return;
}
const show = state === "promptable" || state === "manual";
card.hidden = !show;
card.dataset.mode = state;
if (show && !impressionLogged) {
impressionLogged = true;
track("install_promo_impression", { source: "feed_card", mode: state });
}
});
card.querySelector(".install-card__primary").addEventListener("click", async () => {
track("install_promo_click", { source: "feed_card" });
if (card.dataset.mode === "manual") {
openInstallInstructions({ source: "feed_card" });
return;
}
const { outcome } = await promptInstall("feed_card");
if (outcome === "dismissed") {
// Treat a dismissed browser dialog like "Not now".
writeNumber(DISMISS_KEY, Date.now());
teardown();
}
});
card.querySelector(".install-card__dismiss").addEventListener("click", () => {
writeNumber(DISMISS_KEY, Date.now());
track("install_promo_dismissed", { source: "feed_card" });
teardown();
});
}
Design notes:
- The visit threshold and the cooldown are yours to tune. The values above are starting points, not recommendations from any browser vendor. Pick them from your own funnel data.
- Put the card in the content flow, not over it. It shouldn't cover content, trap focus or shift layout after first paint. Reserve its space or insert it above the fold only before first render.
- Use
localStoragefor the cooldown, wrapped intry/catch. It's per-browser convenience state. On iOS, the installed app has separate storage anyway.
Pattern 3: post-engagement prompts¶
The highest-converting moment is right after the user gets value: a completed purchase, a saved document, a finished lesson, an enabled notification. Show a small, non-modal prompt there, once.
import { getInstallState, promptInstall } from "./install-controller.js";
import { openInstallInstructions } from "./install-instructions.js";
import { track } from "./install-analytics.js";
const SHOWN_KEY = "install-toast:shown";
function alreadyShown() {
try {
return localStorage.getItem(SHOWN_KEY) === "1";
} catch {
return true; // If we can't remember, don't risk repeating it.
}
}
/**
* Call after a meaningful success, for example after the order confirmation
* has rendered. Shows a non-modal toast with an install action.
*/
export function offerInstallAfter(milestone) {
const state = getInstallState();
if ((state !== "promptable" && state !== "manual") || alreadyShown()) return;
try {
localStorage.setItem(SHOWN_KEY, "1");
} catch {
// Ignore: worst case, the toast can appear once more later.
}
const toast = document.createElement("div");
toast.className = "install-toast";
toast.setAttribute("role", "status"); // Announced politely, doesn't steal focus.
toast.innerHTML = `
<span>Track this order from your home screen.</span>
<button type="button" data-action="install">Install</button>
<button type="button" data-action="close" aria-label="Close">×</button>`;
document.body.append(toast);
track("install_promo_impression", { source: `after_${milestone}` });
const close = () => toast.remove();
const timer = setTimeout(close, 15000);
toast.addEventListener("click", async (event) => {
const action = event.target.closest("button")?.dataset.action;
if (action === "close") {
clearTimeout(timer);
close();
} else if (action === "install") {
clearTimeout(timer);
close();
track("install_promo_click", { source: `after_${milestone}` });
if (state === "manual") {
openInstallInstructions({ source: `after_${milestone}` });
} else {
await promptInstall(`after_${milestone}`);
}
}
});
}
Tie the milestone to something users recognize as progress, and phrase the offer in terms of that progress ("Track this order", "Keep practicing offline"). A generic "Install our app" after checkout converts worse than a specific benefit, and it's the same message users ignore on every other site.
Using the controller from a framework¶
The controller is framework-agnostic on purpose: it lives outside the component tree, so the event captured before hydration isn't lost when components mount and unmount. Frameworks only need a thin adapter. In React, useSyncExternalStore() is the right primitive, because install state is external, mutable state that can change between renders:
import { useCallback, useSyncExternalStore } from "react";
import { subscribe, getInstallState, promptInstall } from "./install-controller.js";
import { openInstallInstructions } from "./install-instructions.js";
// subscribe() calls the callback immediately; React tolerates that, but the
// wrapper keeps React's contract explicit: subscribe returns an unsubscribe.
function subscribeToInstall(onStoreChange) {
return subscribe(() => onStoreChange());
}
export function useInstallState() {
// The server snapshot is "unavailable": install state only exists in a browser,
// and rendering no install UI on the server avoids a hydration mismatch.
return useSyncExternalStore(subscribeToInstall, getInstallState, () => "unavailable");
}
export function InstallButton({ source = "header" }) {
const state = useInstallState();
const onClick = useCallback(() => {
// Call synchronously in the event handler: no await before promptInstall().
if (state === "manual") openInstallInstructions({ source });
else promptInstall(source);
}, [state, source]);
if (state !== "promptable" && state !== "manual") return null;
return (
<button type="button" className="install-button" onClick={onClick}>
Install app
</button>
);
}
The same shape works elsewhere: a Vue composable that writes getInstallState() into a ref from the subscribe() callback, or a Svelte readable store whose start function calls subscribe(). Two rules apply in every framework:
- Import the controller only on the client. It touches
windowat module load. In server-rendered frameworks, import it from an effect, aclient:directive or a"use client"module. - Keep the inline
<head>capture script. Chromium starts its pipeline atload, and hydration or lazy-loaded route chunks often complete later, so the event can arrive before any component that listens for it exists.
The appinstalled event¶
appinstalled fires on window in the document where the installation happened, for every successful install: from your prompt(), from the address-bar icon, from the browser menu, or from Chrome's own install message. It's a plain Event with no extra properties. The Manifest Incubations draft queues it on the application life-cycle task source after the install completes. Chromium dispatches it from AppBannerManager::OnInstall().
window.addEventListener("appinstalled", () => {
// 1. Remove every piece of install promotion from the page.
document.querySelectorAll(".install-button, .install-card, .install-toast")
.forEach((element) => element.remove());
// 2. Record the install. sendBeacon survives the tab being moved or closed.
const body = JSON.stringify({ type: "app_installed", at: Date.now() });
navigator.sendBeacon?.("/api/install-events", new Blob([body], { type: "application/json" }));
});
Timing and platform details:
- Android with WebAPK: the event fires when the user accepts the dialog, not after the WebAPK is minted and installed, according to web.dev's Learn PWA detection chapter. There may be a delay of several seconds before the icon appears. Don't show "Open the app" UI right away.
- Desktop Chrome and Edge: after installation the browser moves the current tab into the new app window. The same document keeps running, now with
display-mode: standalone(or your chosen mode). Listen for the media query'schangeevent to adapt the UI, as the controller above does. - Safari and Firefox: the event never fires. Installation is invisible until the user launches the app. See Detecting Installed Apps for launch-based detection.
- Opera: exposes
onappinstalledbut, according to MDN, never fires the event.
Instructions UI for Safari and Firefox¶
On iOS, iPadOS, macOS Safari and Firefox, installation is always user-initiated from browser UI. Your job is to explain the steps clearly, on demand, and only where they apply. Installing matters more on iOS than anywhere else: Web Push and the Badging API work only in Home Screen web apps there.
The steps per platform¶
| Platform | Steps to show |
|---|---|
| iPhone, Safari (iOS 26+) | Tap the Share button (in the toolbar, or under ⋯ depending on the tab layout), then Add to Home Screen. Keep Open as Web App on, then tap Add. |
| iPhone, Safari (iOS 16.4–18) | Tap Share, then Add to Home Screen, then Add. |
| iPad, Safari | Tap Share in the toolbar, then Add to Home Screen. |
| iOS, Chrome / Edge / Firefox (16.4+) | Open the browser's Share sheet, then choose Add to Home Screen. |
| iOS in-app browser | Open the page in Safari first (from the in-app browser's menu), then follow the Safari steps. |
| Mac, Safari 17+ | Choose File > Add to Dock, or click Share and choose Add to Dock. |
| Android, Firefox | Open the ⋮ menu and choose Install or Add app to Home screen (the label varies by version). |
| Windows, Firefox 143+ | Pin the site to the taskbar from Firefox. |
WebKit's Safari 26.0 announcement describes the iOS 26 change: "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." Apple documents the current flow in its iPhone User Guide.
Don't draw arrows at a fixed position on iOS 26
Older coach marks pointed at the Share icon in Safari's bottom toolbar. Since iOS 26, the Share action can sit in the toolbar or behind the ⋯ button depending on the user's tab layout, and iPad puts it at the top. Illustrate the icons and describe the steps instead of animating an arrow toward a screen edge.
A reusable instructions dialog¶
The dialog below uses the native <dialog> element, so focus trapping, Esc to close and the backdrop come for free. It renders steps for the detected platform and never opens inside the installed app.
import { detectInstallPlatform } from "./install-platform.js";
import { isRunningAsInstalledApp } from "./install-controller.js";
import { track } from "./install-analytics.js";
// The iOS/macOS Share glyph: a square with an arrow pointing up.
const SHARE_ICON = `<svg class="glyph" aria-hidden="true" viewBox="0 0 24 24" width="20" height="20">
<path d="M12 3v12M8 7l4-4 4 4M6 11H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-1"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
const ADD_ICON = `<svg class="glyph" aria-hidden="true" viewBox="0 0 24 24" width="20" height="20">
<rect x="4" y="4" width="16" height="16" rx="3" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M12 8v8M8 12h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>`;
const STEPS = {
"ios-safari": [
`Tap the Share button ${SHARE_ICON}. If you don't see it, tap <strong>⋯</strong> first.`,
`Choose <strong>Add to Home Screen</strong> ${ADD_ICON}.`,
`Leave <strong>Open as Web App</strong> switched on, then tap <strong>Add</strong>.`,
],
"ipados-safari": [
`Tap the Share button ${SHARE_ICON} at the top of the screen.`,
`Choose <strong>Add to Home Screen</strong> ${ADD_ICON}, then tap <strong>Add</strong>.`,
],
"ios-other-browser": [
`Open your browser's Share menu ${SHARE_ICON}.`,
`Choose <strong>Add to Home Screen</strong>, then tap <strong>Add</strong>.`,
],
"ios-in-app": [
"This in-app browser can't add apps to your Home Screen.",
"Open its menu and choose <strong>Open in Safari</strong> (or open this address in Safari).",
`In Safari, tap Share ${SHARE_ICON}, then <strong>Add to Home Screen</strong>.`,
],
"macos-safari": [
"In the menu bar, choose <strong>File > Add to Dock</strong>.",
`Or click Share ${SHARE_ICON} in the toolbar and choose <strong>Add to Dock</strong>.`,
],
"firefox-android": [
"Open the <strong>⋮</strong> menu.",
"Choose <strong>Install</strong> or <strong>Add app to Home screen</strong>.",
],
"firefox-windows": [
"Pin this site to the Windows taskbar from Firefox to open it in its own window.",
],
};
let dialog;
function ensureDialog() {
if (dialog) return dialog;
dialog = document.createElement("dialog");
dialog.className = "install-instructions";
dialog.setAttribute("aria-labelledby", "install-instructions-title");
dialog.innerHTML = `
<form method="dialog">
<h2 id="install-instructions-title">Install this app</h2>
<ol class="install-instructions__steps"></ol>
<button type="submit" autofocus>Done</button>
</form>`;
document.body.append(dialog);
return dialog;
}
export function openInstallInstructions({ source }) {
// Never explain installation to someone already using the installed app.
if (isRunningAsInstalledApp()) return false;
const platform = detectInstallPlatform();
const steps = STEPS[platform.id];
if (!platform.manualInstructions || !steps) return false;
const element = ensureDialog();
if (element.open) return true; // Already showing; a double tap is harmless.
// STEPS contains only static markup defined above, never user input.
element.querySelector(".install-instructions__steps").innerHTML =
steps.map((step) => `<li>${step}</li>`).join("");
element.showModal();
track("install_instructions_shown", { source, platform: platform.id });
element.addEventListener(
"close",
() => track("install_instructions_closed", { source, platform: platform.id }),
{ once: true },
);
return true;
}
Guidelines for the instructions UI:
- Open it only on request. Show it from an Install app button or card, not automatically on page load. An unrequested modal on first visit is the worst-performing and most annoying variant of install promotion.
- Hide it in app mode.
isRunningAsInstalledApp()coversnavigator.standaloneon iOS, which matters because iOS reportsdisplay-mode: fullscreenfor standalone Home Screen apps (WebKit bug 264218). - Say why. On iOS, "Install to get notifications when your order ships" is both true and persuasive, because push requires a Home Screen web app there.
- Mention the storage split on iOS. The Home Screen app starts with empty storage, so tell users they'll sign in once more after installing, or hand over state yourself, for example with a short-lived sign-in link.
The Web Install API: navigator.install() and <install>¶
Experimental
navigator.install() and the <install> element aren't enabled by default in any stable browser as of September 2026 (Chrome 154 is the current stable release). Chrome Platform Status lists both with an intent to ship on desktop in Chrome 156, whose stable release is scheduled for October 20, 2026. Android isn't part of that intent. WebKit's standards position is oppose, and Mozilla's issue has no position. The API shape changed during the trials and may change again before it ships.
beforeinstallprompt has two limits that the Web Install API removes. It can only install the document the user is looking at, and only when the browser considers that document promotable. Microsoft's Web Install API explainer defines a promise-based method that installs either the current app or any app identified by its manifest URL, and the WICG's <install> element is a declarative, browser-rendered button backed by the same algorithm.
Status and history¶
| Milestone | navigator.install() | <install> element |
|---|---|---|
| Developer trial behind a flag | Chrome 139 desktop, about://flags/#web-app-installation-api | about://flags/#web-app-install-element |
| Origin trial | Chrome and Edge 143 to 148 (trial name WebAppInstallation), extended through 150 | Chrome and Edge 148 to 153 (trial name Web App Install Element, announced on the Chrome blog on May 12, 2026) |
| Design change | Edge 153 deprecated navigator.install(url) (a document URL) in favor of navigator.install({ manifest }); Edge 154 removes the URL form | The trialed installurl/manifestid design reaches end of life with its trial; the successor takes manifest and manifestId. The trial's promptaction and promptdismiss events are replaced by a single installresult event |
| Intent to ship | Desktop, Chrome 156 (Chrome Platform Status) | Desktop, Chrome 156 (Chrome Platform Status) |
| Other engines | WebKit: oppose (WebKit/standards-positions#463); Mozilla: no signal (mozilla/standards-positions#1179) | Same issues |
Status as of September 2026, from Chrome Platform Status for navigator.install() and for <install>, the Chrome team's <install> origin trial announcement, and Microsoft's demo documentation. MDN's compatibility data lists the dictionary form of navigator.install() as experimental, in Chrome 154 behind the #web-app-installation-api flag.
Signatures and the install algorithm¶
The current explainer defines three call forms:
// 1. Install the current document's app. Its manifest must declare an "id".
await navigator.install();
// 2. Install an app by manifest URL. That manifest must declare an "id".
await navigator.install({ manifest: "https://suite.example/mail/manifest.webmanifest" });
// 3. Install an app whose manifest has no "id": pass the computed ID.
await navigator.install({
manifest: "https://suite.example/tasks/manifest.webmanifest",
manifestId: "https://suite.example/tasks/?source=pwa",
});
For the dictionary forms, the explainer's algorithm runs these checks in order:
- Transient user activation, or reject with
NotAllowedError. - Not in a sandboxed frame or a cross-origin subframe, or reject with
InvalidStateError. manifestis a valid URL, or reject withTypeError.- If the manifest is cross-origin to the caller, the browser asks for the
web-app-installationpermission for the calling origin (unless already granted). Denied:AbortError. - The browser fetches the manifest with credentials mode
"omit", so no cookies are sent. Failure:DataError. - The manifest has an
id, or the computed ID matchesmanifestId. Otherwise:DataError. - The browser shows its install confirmation. Declined:
AbortError. Accepted: the promise resolves.
Other rules in the current design: the manifest URL must be same-origin with the app's start_url, relative URLs inside the manifest resolve against the manifest URL (the target document is never loaded), the API is unavailable in private browsing, and a browser may offer to launch an app that's already installed instead, resolving if the user accepts. The API can also be controlled with a web-app-installation Permissions Policy, and with the enterprise policy WebAppInstallByUserEnabled.
| Rejection | Meaning | What to do |
|---|---|---|
AbortError | The user canceled, denied the cross-origin permission, or the browser stopped the flow | Nothing; optionally offer a link to the app |
DataError | Manifest fetch, parse, id or manifestId problem | Fix your data; check the computed ID in DevTools Application > Manifest |
NotAllowedError | No user activation, or disallowed by policy | Call from a click handler |
InvalidStateError | Sandboxed frame or cross-origin iframe | Call from the top-level document |
NotFoundError | The Navigator has no document | Nothing |
TypeError | Bad argument type or URL | Fix the call |
Because the target document isn't loaded, the installed app's service worker isn't registered until its first launch. The explainer lists this as an open question and suggests browsers launch the app after installing.
Progressive enhancement across all three mechanisms¶
Feature-detect and layer the options: the Web Install API where it exists, beforeinstallprompt in other Chromium browsers, and instructions elsewhere.
import { promptInstall, getInstallState } from "./install-controller.js";
import { openInstallInstructions } from "./install-instructions.js";
/**
* Install the current app with the best mechanism available.
* Call directly from a click handler.
*/
export async function installCurrentApp(source) {
if ("install" in navigator) {
try {
await navigator.install(); // Requires "id" in the manifest.
return { outcome: "accepted", via: "navigator.install" };
} catch (error) {
if (error.name === "AbortError") return { outcome: "dismissed", via: "navigator.install" };
// DataError, NotAllowedError...: fall through to the older mechanism.
// Note: user activation was consumed; prompt() below may reject too.
console.warn("navigator.install() failed:", error.name, error.message);
}
}
if (getInstallState() === "promptable") {
const result = await promptInstall(source);
return { ...result, via: "beforeinstallprompt" };
}
if (openInstallInstructions({ source })) {
return { outcome: "instructions", via: "manual" };
}
return { outcome: "unavailable" };
}
The declarative element has built-in fallback content for browsers that don't recognize it, and reports results through a bubbling installresult event whose result is "success", "aborted" or "invalid_data":
<ul id="app-catalog">
<li>
<install id="install-mail"
manifest="https://suite.example/mail/manifest.webmanifest">
<!-- Rendered only by browsers without <install> support. -->
<a href="https://suite.example/mail/">Open Mail</a>
</install>
</li>
</ul>
<script>
// One delegated listener for every <install> in the catalog.
document.getElementById("app-catalog").addEventListener("installresult", (event) => {
console.log(event.target.id, event.result); // "success" | "aborted" | "invalid_data"
});
</script>
The browser renders the element's button text and icon itself, as it does for other permission elements, which limits how much you can restyle it and prevents a site from disguising it. According to the explainer, the button reads Install when the app isn't installed and switches to a Launch label and icon when it is. Styling violations (for example, text that is too small or low contrast) make the element invalid before activation, which you can observe through the inherited isValid, invalidReason and onvalidationstatuschange members. A problem discovered after the click, such as a manifest without an id, arrives as an invalid_data result instead.
Check the install element explainers before relying on attribute names. The origin trial used installurl and manifestid and reported outcomes through promptaction and promptdismiss events. The successor design's explainer uses manifest and manifestId (the repository README abbreviates the second as id) and the installresult event shown above. Code written for the trial doesn't work against the successor design.
Instrumenting the install funnel¶
An install funnel turns "we added an install button" into numbers you can improve. Each step maps to a signal from the controller above.
flowchart LR
A["Promotable<br/>(beforeinstallprompt)"] --> B["Promotion shown"]
B --> C["Promotion clicked"]
C --> D["Prompt shown"]
D --> E["Accepted"]
E --> F["appinstalled"]
F --> G["First launch in app mode"]
G --> H["Retained app sessions"] | Event | Fired when | Caveats |
|---|---|---|
install_promotable | First beforeinstallprompt in a session | Fires on every page load and after every dismissal. Deduplicate per session, as the controller does. Chromium only. |
install_promo_impression | Your button, card or toast became visible | Log visibility, not rendering. Use IntersectionObserver for below-the-fold cards. |
install_promo_click | The user clicked your promotion | Denominator for the prompt's acceptance rate |
install_prompt_shown / install_prompt_result | prompt() was called / resolved | outcome is accepted or dismissed. An error means a missing user gesture. |
install_instructions_shown | The iOS, macOS or Firefox instructions opened | You can't observe completion: there's no event |
app_installed | appinstalled fired | Includes installs from browser UI (source: "browser_ui"). Android fires before the WebAPK exists. |
app_launch | A session starts in app mode, detected with start_url or display-mode | The only install signal you get on Safari and Firefox |
A transport that survives page unloads and the desktop tab-to-window transfer:
const ENDPOINT = "/api/install-events";
export function track(type, detail = {}) {
const payload = JSON.stringify({
type,
detail,
at: new Date().toISOString(),
page: location.pathname,
});
const blob = new Blob([payload], { type: "application/json" });
// sendBeacon queues the request even if the page is being unloaded.
if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, blob)) return;
// Fallback: keepalive fetch; errors are ignored, analytics must not break the UI.
fetch(ENDPOINT, { method: "POST", body: blob, keepalive: true }).catch(() => {});
}
Read the funnel with its blind spots in mind:
- Attribution:
appinstalleddoesn't say how the user installed. The controller attributes it to the last prompt source it opened, and tobrowser_uiotherwise. - Safari and Firefox: no install event, so the funnel for those users ends at
install_instructions_shownand resumes atapp_launch. On iOS, the installed app has separate storage and cookies, so you can't join the two sessions with a client-side ID. Join them server side through a signed-in user ID. - Accept versus install: on Android, an accepted prompt can still fail to produce a WebAPK (minting unavailable, then a shortcut is created). Compare
app_installedwithapp_launchto see how many installs are used.
Analytics for PWAs covers offline-safe event queues and how to report display mode as a dimension in analytics tools. Detecting Installed Apps covers launch detection and server-side tracking.
UX guidelines and anti-patterns¶
Install promotion works when it's timely, specific and easy to ignore. The rules below come from how the browser mechanisms behave, plus guidance from web.dev's patterns for promoting PWA installation and install strategy article.
Do:
- Offer a permanent, quiet entry point (menu, settings, account page) in addition to any contextual promotion. Users who want the app look for it there.
- Ask after value, not before. Tie contextual promotion to a milestone: a second visit, a completed task, a saved item.
- State a concrete benefit that is true on that platform: offline access, a home screen icon, notifications (on iOS, only after installing), a dedicated window.
- Respect "Not now". Store the dismissal and apply a cooldown. Treat a dismissed browser dialog the same way.
- Remove all promotion in app mode and after
appinstalled, including the CSS safety net for first paint. - Make it accessible: real
<button>elements, visible focus,role="status"for toasts, a<dialog>with a heading for instructions, and text that makes sense without the icon.
Don't:
| Anti-pattern | Why it fails |
|---|---|
| Prompting on the first page load | Users haven't seen any value yet. On Chromium, prompt() needs a click anyway, so this usually means a modal that blocks content to get that click. |
| Blocking content until the user installs ("Install to continue") | Coercive, frustrating, and on platforms without an install event you can't even verify the install happened. |
| Showing a button that does nothing | If beforeinstallprompt hasn't fired and there's no manual path, hide the button. Test Firefox desktop on macOS and Linux, which can't install web apps. |
| Showing iOS instructions to everyone | Android and desktop users get steps that don't exist in their browser. Detect the platform. |
Awaiting network work before prompt() | Loses transient user activation, and prompt() rejects with NotAllowedError. |
| Reusing a stale event | An event can prompt once. After a back/forward cache restore it's disconnected entirely. |
Calling preventDefault() without offering your own UI | Suppresses Chrome's Android install message and gives users nothing in its place. |
| Promoting inside the installed app | Users already did what you asked. Check display-mode and navigator.standalone. |
| Imitating browser or OS dialogs | Users learn to distrust real prompts, and it can mislead them about what's being installed. |
Testing the install flow¶
Automate the controller logic with a fake event. A real BeforeInstallPromptEvent created with its constructor can't show a dialog, so stub the members your code uses on a plain event:
import { test, expect } from "vitest";
function fakeBeforeInstallPrompt(outcome) {
const event = new Event("beforeinstallprompt", { cancelable: true });
const result = { outcome, platform: outcome === "accepted" ? "web" : "" };
event.platforms = ["web"];
event.userChoice = Promise.resolve(result);
event.prompt = () => Promise.resolve(result);
return event;
}
test("promptInstall reports the outcome and consumes the event", async () => {
const { promptInstall, getInstallState } = await import("./install-controller.js");
window.dispatchEvent(fakeBeforeInstallPrompt("dismissed"));
expect(getInstallState()).toBe("promptable");
const result = await promptInstall("test");
expect(result.outcome).toBe("dismissed");
expect(getInstallState()).not.toBe("promptable"); // One prompt per event.
});
Run this in a DOM environment such as jsdom or happy-dom, and stub matchMedia if your environment lacks it. For end-to-end checks, the DevTools Protocol method Page.getInstallabilityErrors tells you whether Chromium considers the page installable, which is a precondition for the event. Automated Testing shows how to wire both into CI.
Debugging install prompts¶
- Nothing fires. Open DevTools Application > Manifest and read the Installability section. If the app is already installed in this profile, uninstall it (
chrome://appsoredge://apps) or use a fresh profile. Remember that the pipeline starts afterload: a page that never finishes loading never gets the event. - The event fires but
prompt()rejects. Check forNotAllowedErrorin the console. Some code path awaits something between the click andprompt(), or callsprompt()twice. - Confirm your handler ran. Look for the informational "Banner not shown: beforeinstallpromptevent.preventDefault() called" message in the console.
- Inspect installed apps.
chrome://web-app-internalslists installed apps and their manifest IDs on desktop.about://webapkslists WebAPKs on Android. Use remote debugging for Android devices. - Test Safari instructions on real devices or the iOS Simulator. The iPadOS desktop user agent and the iOS 26 tab layouts are easy to get wrong in a desktop browser's device emulation.
Common pitfalls¶
- Listening too late. The event fired before your framework mounted. Capture it with an inline
<head>script. - Assuming one event per visit. It fires on every document load in a multi-page app, and again after every dismissal. Deduplicate analytics and respect your own cooldown.
- Forgetting the back/forward cache. A saved event from before the page was cached is dead after restore. Wait for the new one.
- Treating
platformas the outcome. It's""on dismissal. Readoutcome. - Expecting
appinstalledonly from your button. It also fires for installs from browser UI. - Hiding install UI with
display-mode: standalonealone. iOS standalone apps matchfullscreen. Addnavigator.standalone. - Promising features that need installation on the wrong platform. Web Push needs installation on iOS, but not on Android or desktop.
- Shipping
navigator.install()without detection. It's behind flags or trials. Always check"install" in navigatorand keep thebeforeinstallpromptpath.
Further reading¶
On this site
- Installation overview: what installation creates on each OS.
- Detecting Installed Apps: display modes,
getInstalledRelatedApps()and launch tracking. - Installation by Platform: every browser's install flow in detail.
- Installability Criteria: when Chromium considers a page promotable.
- Rich Install UI: screenshots and descriptions in the install dialog.
- App Identity & Updates: the manifest
idthat the Web Install API depends on. - Web Push on iOS & Safari: why installation matters most on iOS.
- App-Like UX Patterns: designing for the installed window.
External references
- Manifest Incubations: installation prompts (WICG draft)
- MDN: BeforeInstallPromptEvent and appinstalled
- Learn PWA: Installation prompt (web.dev)
- Patterns for promoting PWA installation (web.dev)
- Web Install API explainer and demo (Microsoft Edge)
- The
<install>element (WICG) - WebKit features in Safari 26.0 (WebKit blog)