Making a PWA Installable: Web App Manifest, Install Flows, navigator.install and App Badging
What makes a PWA installable is its manifest: write the id once and keep it, separate any and maskable icons, show the install offer in Chromium with beforeinstallprompt only after the user has seen some value, and give Safari users Add to Home Screen instructions. With real output from Chromium 158 and WebKit we show the identity calculation, the installability check, the errors of navigator.install() and App Badging for unread orders.
Version note: The examples in this post were run with Angular 22.2 (TypeScript 6.0), @angular/service-worker 22.2.6, Node.js 24 and Playwright 1.65; browser output comes from Playwright's Chromium 158 and WebKit 27.2 builds, on a production build. Support data was read from MDN browser-compat-data 8.1.9 and web-features 3.46; Firefox and real Safari/iOS could not be run in this environment, so statements about them come from that data and from the browser vendors' documentation.
Predict First
In the previous post, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, started working offline. We did not touch the manifest that ng add @angular/pwa generated; these lines come from it:
{
"name": "bookstore-web",
"short_name": "bookstore-web",
"display": "standalone",
"scope": "./",
"start_url": "./",
...
}
Marketing wants to know whether the app is opened from the home screen or from the browser, so they ask you to change start_url to "/?source=pwa". It looks harmless. What changes for users who have already installed the app?
In this post we turn Bookstore into an installable app: the manifest's identity and members, maskable icons, an install offer with beforeinstallprompt in Chromium that does not annoy the user, the new Web Install API (navigator.install()), the different path on Safari and iOS, and App Badging to show unread order updates on the app icon. Everything an installable PWA needs is one JSON file and a few lines of TypeScript.
The Manifest's Identity: id, start_url and scope
The browser recognizes an installed app by the manifest's id. Without an id, the identity is computed from start_url. The Page.getAppId command of Chromium's DevTools protocol returns the computed identity and a recommended id; I queried the ng add manifest first as it is, then with only start_url changed:
ng add: appId=http://localhost:5282/ recommendedId=/
installability errors: none
ng add + start_url "/?source=pwa": appId=http://localhost:5282/?source=pwa recommendedId=/?source=pwa
installability errors: none
Here is the answer: when start_url changed, the app's identity changed too. To the browser this is a different app; existing installations keep the old identity, do not receive the new manifest as an update, and the same user can end up with two Bookstores. The fix is to write the id once, explicitly, and never change it again. The id is resolved against the origin; "/" means http://localhost:5282/, the same identity existing installations already have. Bookstore's new manifest:
{
"id": "/",
"name": "Bookstore",
"short_name": "Bookstore",
"description": "Browse the catalog, keep a reading list and follow your orders.",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"theme_color": "#1e3a5f",
"background_color": "#ffffff",
"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-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"screenshots": [
{
"src": "screenshots/catalog-wide.png",
"sizes": "1280x800",
"type": "image/png",
"form_factor": "wide",
"label": "The Bookstore catalog"
},
{
"src": "screenshots/catalog-narrow.png",
"sizes": "540x960",
"type": "image/png",
"form_factor": "narrow",
"label": "The Bookstore catalog on a phone"
}
],
"shortcuts": [
{ "name": "My orders", "url": "/orders?source=shortcut", "icons": [{ "src": "icons/icon-192.png", "sizes": "192x192" }] },
{ "name": "Settings", "url": "/settings?source=shortcut" }
]
}
scope draws the app's boundary: the installed window opens URLs under this path itself, and a link that leaves it opens in the browser (or with a toolbar on top of the window). The "./" values ng add generated are resolved against the manifest's URL; as long as the app is deployed at the root they equal "/". If you deploy the Angular app under a sub-path such as /store/, set scope, start_url and id accordingly, and keep in mind that the id must not change even if the deployment URL does. The ?source=pwa in start_url is now safe: the id carries the identity, so the analytics parameter can change and the installation stays the same app. With the server stopped I also opened /?source=pwa and the shortcut URL /orders?source=shortcut; both were served with the index.html in NGSW's cache and showed the Catalog and My orders pages.
Icons: any and maskable
ng add generates icons in eight sizes and gives all of them "purpose": "maskable any". These two purposes contradict each other. An any icon is shown as it is; its corners can be transparent and the logo can reach the edges. A maskable icon is cropped by the operating system's mask (a circle, rounded square or teardrop on Android), so it needs a fully filled background and its important part must stay in the safe zone, a circle whose radius is 40% of the icon's width. When one file serves both purposes, either the logo's edges are cut off in the masked icon or the icon shown as any looks smaller and padded than it should. Chrome DevTools' Manifest section warns about this usage too. Bookstore has two separate sets: icon-192/512.png with rounded corners (any) and maskable-192/512.png, navy to the edges with the book figure inside the safe zone.
I also checked which icons installability really needs. Chrome's documentation asks for 192 and 512 pixel icons; Chromium's Page.getInstallabilityErrors command tells the actual check:
Bookstore: appId=http://localhost:5282/ recommendedId=/?source=pwa
installability errors: none
Bookstore, 192px icons only: appId=http://localhost:5282/ recommendedId=/?source=pwa
installability errors: none
Bookstore, one 96px icon: appId=http://localhost:5282/ recommendedId=/?source=pwa
installability errors: manifest-missing-suitable-icon [{"name":"minimum-icon-size-in-pixels","value":"144"}], no-acceptable-icon [{"name":"minimum-icon-size-in-pixels","value":"144"}]
Chromium 158 accepted the installation with only the 192 pixel icons; the error came only when a single icon smaller than 144 pixels was left. Provide both sizes anyway: the 512 pixel icon is used on Android's splash screen and for large icons on the desktop. These checks ran in a profile with service workers blocked entirely and reported no installability error; Chrome's documentation no longer lists a service worker as an install requirement either. Working offline is still what users expect from an installed app; the NGSW from the previous post stays in place.
The other conditions in Chrome's documentation: the page must come from HTTPS (or localhost), the manifest needs name or short_name, start_url, display (or display_override), and prefer_related_applications must not be true. On top of these, Chrome applies an engagement check that waits for the user to interact with the page.
The Install Window: display, screenshots and shortcuts
display: "standalone" opens the app in its own window without an address bar. display_override takes a list of preferences and the browser picks the first value it supports; on desktop Chromium, "window-controls-overlay" lets the app's content extend into the title bar area. Bookstore does not need that, so it only has display. Remember that the installed window has no back button; the Angular shell uses the display-mode media query to show a Back button only when installed:
styles: `
header {
display: flex;
gap: 1rem;
align-items: center;
}
nav {
display: flex;
gap: 0.75rem;
}
.back {
display: none;
}
@media (display-mode: standalone) {
.back {
display: inline;
}
}
`,
description and screenshots are not required for installation itself, but with both present Chrome shows the install dialog as a richer window that resembles an app store listing. According to Chrome's documentation this window exists since Chrome 94 on Android and 108 on the desktop. form_factor: "wide" is used on the desktop and "narrow" on mobile; I took Bookstore's two screenshots from the app itself with Playwright. shortcuts is the menu that appears when you right-click the app icon (long-press on Android): "My orders" and "Settings" open those pages directly. According to BCD, shortcuts are supported in Chrome 96 on the desktop, Chrome Android 84 and Safari 17.4 (macOS).
Finally I added two lines to index.html: theme-color, which sets the color of the window and the tab, and apple-touch-icon for the iOS home screen icon:
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#1e3a5f" />
<!-- iOS uses this icon for "Add to Home Screen" -->
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png" />
Your Own Install Offer with beforeinstallprompt
When the installability conditions are met, Chromium sends the beforeinstallprompt event to the page. If you do nothing, it shows its own small install bar on mobile and puts an install icon in the address bar on the desktop. If you call preventDefault() on the event and keep it, you decide when to make the offer. In Bookstore that decision lives in a service:
import { computed, inject, Service, signal } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
// Chromium-only APIs that are not in TypeScript 6.0's lib.dom.d.ts
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
prompt(): Promise<void>;
}
declare global {
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
}
interface Navigator {
install?(): Promise<unknown>; // Web Install API
standalone?: boolean; // Safari (WebKit) only
}
}
const DISMISSED_KEY = 'install-offer-dismissed-at';
const QUIET_DAYS = 30;
@Service()
export class InstallPrompt {
private deferred: BeforeInstallPromptEvent | null = null;
private readonly promptable = signal(false);
private readonly pagesSeen = signal(0);
private readonly dismissedAt = signal(Number(localStorage.getItem(DISMISSED_KEY) ?? 0));
private readonly standalone = matchMedia('(display-mode: standalone)');
readonly installed = signal(this.standalone.matches || navigator.standalone === true);
// Safari never fires beforeinstallprompt; the user installs from the Share or File menu
readonly safariHint = 'standalone' in navigator && !this.installed();
// offer the install only after the user has looked around, and not again for 30 days after "Not now"
readonly offer = computed(
() =>
this.promptable() &&
!this.installed() &&
this.pagesSeen() >= 3 &&
Date.now() - this.dismissedAt() > QUIET_DAYS * 24 * 60 * 60 * 1000,
);
constructor() {
addEventListener('beforeinstallprompt', (event) => {
event.preventDefault(); // no mini-infobar: we decide when to offer
this.deferred = event;
this.promptable.set(true);
});
addEventListener('appinstalled', () => {
this.deferred = null;
this.promptable.set(false);
this.installed.set(true);
});
this.standalone.addEventListener('change', (e) => this.installed.set(e.matches));
inject(Router)
.events.pipe(filter((e) => e instanceof NavigationEnd))
.subscribe(() => this.pagesSeen.update((n) => n + 1));
}
async install(): Promise<string> {
const event = this.deferred;
if (event) {
// prompt() works once per event; Chromium fires a new event later
this.deferred = null;
this.promptable.set(false);
await event.prompt();
const { outcome } = await event.userChoice;
if (outcome === 'dismissed') this.notNow();
return outcome;
}
if (navigator.install) {
try {
await navigator.install();
return 'accepted';
} catch (err) {
return (err as DOMException).name; // AbortError: the user cancelled, DataError: manifest without id
}
}
return 'unavailable';
}
notNow() {
const now = Date.now();
localStorage.setItem(DISMISSED_KEY, String(now));
this.dismissedAt.set(now);
}
}
The rule is simple: no offer before the user has seen at least three pages, and none for 30 days after "Not now". An install window that opens in the app's first second arrives before the user knows what they would install, and it is usually closed. BeforeInstallPromptEvent and navigator.install are not in TypeScript 6.0's lib.dom.d.ts, so we declared the types at the top of the file. The shell shows the offer as two small buttons in the header (the @if (installer.offer()) block in app.ts). In Playwright's Chromium with a persistent profile the flow looks like this:
[chromium] beforeinstallprompt fired
[chromium] Banner not shown: beforeinstallpromptevent.preventDefault() called. The page must call beforeinstallpromptevent.prompt() to show the banner.
[chromium] page 1 (catalog): offer visible = false
[chromium] page 2 (Dune): offer visible = false
[chromium] page 3 (Settings): offer visible = true
[chromium] 10 s after the click: no answer, the install dialog is open, offer visible = false
[chromium] install: dismissed
[chromium] beforeinstallprompt fired
[chromium] Banner not shown: beforeinstallpromptevent.preventDefault() called. The page must call beforeinstallpromptevent.prompt() to show the banner.
[chromium] after opening another tab: install-offer-dismissed-at saved: true
A few things stand out. The event arrived on the first page, but the offer stayed hidden until the third. Calling preventDefault() makes Chromium log its own note to the console; that is not an error. Clicking the install button opened Chromium's install dialog; in a headless browser nobody answers it, so userChoice stayed pending for 10 seconds, and when another tab opened the dialog closed and the result was dismissed. The service recorded that like "Not now". We also saw the event come again: prompt() works once per event, and after the user declines Chromium sends a new beforeinstallprompt. The service keeps the new event but shows no offer until the 30 quiet days are over.
A small note on testing: this flow did not work in Playwright's default headless shell, and in full Chromium a new, incognito-like context made Page.getInstallabilityErrors return in-incognito. Testing the install flow needs full Chromium and a persistent profile (launchPersistentContext).
navigator.install(): the Web Install API
beforeinstallprompt never became a standard; BCD marks it as not on the standards track, and it exists only in Chromium-based browsers. The Web Install API, proposed by the Microsoft Edge team, reduces the same job to one method call: when the user presses a button you call navigator.install() and the browser shows the install dialog. Called without arguments it installs the current page's app; with a { manifest, manifestId } argument it can also install an app from another origin, which opens up scenarios such as an app catalog. Support is moving: BCD 8.1.9 lists Chrome and Edge 154 behind a flag only, Chromium's Intent to Ship from September 2026 targets 156 on the desktop, and in Playwright's Chromium 158 build navigator.install is defined without a flag. Firefox and Safari do not have it. I tried three cases:
ng add manifest (no id), click: install() rejected DataError: The data provided does not meet requirements.
Bookstore manifest, no user activation: install() rejected NotAllowedError: Failed to execute 'install' on 'Navigator': Unable to install app. This API can only be called shortly after a user activation.
Bookstore manifest, click: still pending after 10 s (the install dialog waits for the user)
The call without arguments needs an id in the manifest; that is why the ng add manifest got DataError. A call without user interaction was rejected with NotAllowedError; opening an install window on its own when a page loads is not possible. With the manifest that has an id, clicking the button opened the dialog and the promise waited for the user's answer; if the user cancels, AbortError comes back. That is why InstallPrompt.install() first uses the stored beforeinstallprompt event (we can learn the result through userChoice), and falls back to navigator.install when there is no event and the browser has it. One trap when writing tests: Playwright's page.evaluate runs code with a user gesture in Chromium, so a call made from inside evaluate counts as "the user clicked". To see NotAllowedError I had to make the call from a timer, seconds after any interaction.
Safari and iOS: Add to Home Screen
Safari has no install API: neither beforeinstallprompt nor navigator.install. Users install the app themselves, with "Add to Home Screen" in the Share menu on iPhone and iPad, and with "Add to Dock" in the File menu on the Mac (since Safari 17 and macOS Sonoma). All you can do is explain it in the right place. According to MDN, since iOS 16.4 other iOS browsers such as Chrome, Edge and Firefox can also add to the home screen from the Share menu. According to WebKit's Safari 26 announcement, on iOS 26 and iPadOS 26 every site added to the home screen opens as a web app by default, even without a manifest; the user can turn off "Open as Web App".
Bookstore's Settings page shows the instructions to Safari users. To recognize Safari we checked whether navigator.standalone exists; this property exists only in WebKit and is true in an installed iOS web app. I first wrote the instructions for the iPhone only; Playwright's WebKit showed that the property is defined in desktop WebKit too, so I fixed the text to cover the Mac as well:
[webkit] setAppBadge: undefined, beforeinstallprompt: false, navigator.standalone: true
[webkit] nav "Catalog Order report Covers My orders (2) Settings", install text: "To install Bookstore in Safari, choose Share, then Add to Home Screen (iPhone, iPad) or File, then Add to Dock (Mac)."
On iOS, add an apple-touch-icon (180×180, without transparency) for the icon. Safari has read manifest icons since 15.4, but which one wins when both are present has changed between versions; apple-touch-icon is the path that works in every version.
Unread Orders with App Badging
Showing a number on an installed app's icon takes two methods of the Badging API: navigator.setAppBadge(n) and navigator.clearAppBadge(). In Bookstore the number is the order updates the customer has not seen yet. I added two endpoints to the mock API:
} else if (req.method === "GET" && url.pathname === "/api/me/order-updates") {
sendJson(req, res, no, { unread: unreadOrderUpdates });
} else if (req.method === "POST" && url.pathname === "/api/me/order-updates/read") {
unreadOrderUpdates = 0;
sendJson(req, res, no, { unread: 0 });
} else if (req.method === "GET" && url.pathname === "/api/reports/orders.csv") {
On the Angular side, a small service reflects the number both on the "My orders" link in the header and on the app icon:
import { HttpClient } from '@angular/common/http';
import { inject, Service, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
interface OrderUpdates {
unread: number;
}
@Service()
export class OrderBadge {
private readonly http = inject(HttpClient);
readonly unread = signal(0);
async refresh() {
try {
const { unread } = await firstValueFrom(this.http.get<OrderUpdates>('/api/me/order-updates'));
await this.show(unread);
} catch {
// offline: keep the last count
}
}
async markRead() {
await firstValueFrom(this.http.post<OrderUpdates>('/api/me/order-updates/read', null));
await this.show(0);
}
private async show(count: number) {
this.unread.set(count);
if (!('setAppBadge' in navigator)) return; // Firefox, and Safari outside an installed web app
try {
if (count > 0) await navigator.setAppBadge(count);
else await navigator.clearAppBadge();
} catch (err) {
console.log(`badge: ${(err as DOMException).name}`);
}
}
}
The shell calls refresh() at startup, and the "My orders" page calls markRead() when it opens. You cannot see in a headless browser whether the badge really reaches the icon, but Chromium's DevTools protocol returns the installed app's state on the operating system side through PWA.getOsAppState. I installed the app with PWA.install and reloaded the page:
[chromium] installed (PWA.install over CDP), badge 0
[chromium] after reload: nav "Catalog Order report Covers My orders (2) Settings", badge 2
[chromium] orders page opened: nav "Catalog Order report Covers My orders Settings", badge 0
setAppBadge calls made before installation resolve without an error but do nothing; after installation the same call put 2 on the icon, and it went back to 0 when the orders page opened. When I tried it, a call from an ordinary browser tab in the same scope also changed the installed app's badge. Support: according to BCD, Chrome and Edge since 81 on Windows and macOS (ChromeOS since 91; Linux has no operating system support), Safari 17 for installed Mac apps, and web apps added to the home screen since iOS 16.4. Chrome Android and Firefox do not have setAppBadge; WebKit 27.2 did not define it in a normal tab either, hence the feature detection in the service. According to WebKit's iOS 16.4 announcement, the badge shows on iOS once the user grants notification permission; we cover permissions together with Web Push in the next post.
In Production
Measure installations: the appinstalled event and the ?source=pwa parameter in start_url let you separate sessions that come from the installed app. You can also send display-mode: standalone to analytics as a dimension. Tracking the offer's acceptance rate (the userChoice results) lets you decide with data when to show the offer.
Treat the manifest like a contract. The id is written once and never changes; narrowing scope pushes some pages of the installed app out of its window. The manifest itself must be served with no-cache (the header rules from the previous post already do that). An installed app's manifest is read again when the user opens the app or visits the site, and changes are applied then. According to Chrome's documentation, name and icons are treated separately because they are security sensitive and may be left for the user to approve; only small icon differences are applied automatically. Do not expect name and icon changes to show up right away.
When is a PWA not enough? If you need deep access to native APIs such as Bluetooth, NFC or background location, if being in an app store is a business requirement, or if you need regular background work on iOS, a PWA falls short. Web Bluetooth and Web NFC exist only in Chromium (NFC only on Android); Safari has no periodic background work. For an app like Bookstore that does catalog, cart and order tracking a PWA is enough; if a store listing is required, packaging the same PWA as a Trusted Web Activity on Android is a middle ground.
Common Pitfalls
- Going live without an
idin the manifest. Ifstart_urlchanges later, the identity changes too, and existing installations remain as a different app. - Giving the same icon
"purpose": "maskable any". The masked icon gets cropped or theanyicon looks small; produce two separate sets. - Showing the install offer as soon as the page opens, or repeating it every session after it was declined. Ask after the user has seen some value, and respect "Not now".
- Writing UI that waits for
beforeinstallpromptin every browser. The event never comes in Safari and Firefox; you need feature detection and instructions for Safari. - Calling
navigator.install()without user interaction. You getNotAllowedError; the call must come from a click handler. - Setting the badge once while the page is open and leaving it. Call
clearAppBadge()when the user sees the related page; otherwise the number on the icon does not reflect reality.
A Small Experiment
★☆☆ Add a third shortcut to the manifest: "Order report" (/reports/orders). In Chromium, print the order and URLs of the shortcuts in the manifest parsed by Page.getAppManifest.
★★☆ Add a measurement to InstallPrompt that sends how many times the offer was shown and its results (accepted, dismissed) to the mock API's /api/events endpoint with sendBeacon. Check the lines you see in the log of the event endpoint from this series' first post.
★★★ Build Bookstore to be deployed under the /store/ sub-path (--base-href /store/). Set id, scope and start_url correctly in the manifest; verify with Page.getAppId that the identity differs from the root installation, and explain why the two installations are separate apps.
For now the number on the app icon only updates while the app is open. Telling the user that an order has shipped, even while the app is closed, is the job of Web Push. Next up: VAPID keys, Angular's SwPush and sending notifications from ASP.NET Core.