Detecting Installed Apps¶
Detecting an installed Progressive Web App means answering two different questions. The first is asked by the running document: am I running as an installed app right now? The second is asked from an ordinary browser tab: is my app installed on this device? The web platform has no single API for either. You combine the display-mode media feature, Safari's navigator.standalone, markers in start_url, Android app referrers, and Chromium's getInstalledRelatedApps(). Each is reliable only on some platforms. This page explains exactly what each signal means, where it fails, and how to combine them in client and server code for UI adaptation and analytics.
Key takeaways
- In the app:
matchMedia("(display-mode: standalone)")is the standard signal, but engines disagree. iOS Home Screen apps withdisplay: "standalone"reportfullscreen, and desktop Firefox never reportsstandalone. Checknavigator.standalone === truefirst on Apple platforms. - Launch attribution: put a marker in
start_url(for example/?source=pwa), set an explicit manifestidso the marker doesn't change the app's identity, and persist it insessionStorage. It only appears on the first navigation of a launch, and not at all whenlaunch_handlerfocuses an existing window; readlaunchQueuefor those launches. - Trusted Web Activities: the first page load has
document.referrerset toandroid-app://followed by the launching package name. Any Custom Tab opened by an Android app has the same kind of referrer, so compare the package name. - From a tab:
navigator.getInstalledRelatedApps()reports your installed PWA or native app when you declare it inrelated_applications. It's Chromium-only: Android 84+ for PWAs, desktop 140+ for PWAs in the same scope (with an absoluteid), Android 80+ for Play apps, Windows 85+ for UWP apps. - Safari and Firefox offer no way to learn from a tab that the app is installed. Only launches are observable.
- For server-side tracking, log the
start_urlmarker and the TWARefereron the launch request, and report display mode from the client. Don't rely on a display-mode cookie on Chromium: the app and the browser tabs share one cookie jar.
Two questions, eight signals¶
| Signal | Question it answers | Engines | Reliability |
|---|---|---|---|
display-mode media feature | Is this document in an app window? | All (with differences) | Good on Chromium and Firefox for Android; wrong value on iOS; limited on desktop Firefox |
navigator.standalone | Is this a Home Screen web app (iOS, iPadOS) or a Dock web app (macOS)? | Safari on iOS and iPadOS; Safari 17+ on macOS | Good; its presence doesn't imply iOS |
start_url marker | Did this session start from the app icon? | All | Good for icon launches; misses shortcuts, share targets, file and protocol handlers |
document.referrer android-app://… | Did an Android app (TWA or Custom Tab) open this page? | Chrome on Android | Good on the first page load only |
appinstalled event | Did an install just happen? | Chromium | Good; fires once, in the installing tab |
beforeinstallprompt absent | Maybe installed (or not promotable) | Chromium | Weak; ambiguous |
getInstalledRelatedApps() | Is my app installed? (asked from a tab) | Chromium | Good within its documented limits |
| Server logs | How many launches and installed sessions? | All | As good as the client markers you send |
Detecting app mode inside the document¶
The display-mode media feature¶
The Media Queries Level 5 display-mode feature reports the display mode the browser actually applied: browser, minimal-ui, standalone or fullscreen, plus the extended values window-controls-overlay and picture-in-picture, and Chromium's tabbed (tabbed app windows) and unframed (formerly borderless, which Chrome 152 shipped for Isolated Web Apps only). It's the right tool for CSS, and for JavaScript through matchMedia():
/* Browser tab: show install promotion and "open in app" hints. */
.only-in-app { display: none; }
@media (display-mode: standalone), (display-mode: minimal-ui),
(display-mode: window-controls-overlay), (display-mode: fullscreen) {
.only-in-browser { display: none; }
.only-in-app { display: revert; }
}
The value reflects the applied mode, not the one you requested. A manifest asking for minimal-ui in a browser that doesn't support it matches standalone. The cross-engine differences that matter for detection, from MDN's compatibility data as of September 2026:
| Engine and context | What matches |
|---|---|
| Chromium desktop app window | The applied mode: standalone, minimal-ui, window-controls-overlay, tabbed; fullscreen when the window is fullscreen |
| Chromium desktop tab | browser, or fullscreen in F11 or element fullscreen |
| Chrome for Android (WebAPK, TWA) | The applied mode; a minimal-ui app reports standalone when its controls aren't drawn |
Safari on iOS, Home Screen app with display: "standalone" | ⚠️ fullscreen, not standalone (WebKit bug 264218) |
| Safari on iOS, in the Safari app | Always browser, even during element fullscreen |
| Safari on macOS, Dock web app | The manifest's supported display value; minimal-ui is never true |
| Firefox desktop | browser; fullscreen in Firefox's full-screen mode; taskbar web apps on Windows match minimal-ui; standalone never matches |
| Firefox for Android 116+ | The applied mode of the installed app |
Support data as of September 2026. See MDN's display-mode compatibility table for live data. Display Modes explains how each engine computes the value.
Two practical consequences. First, fullscreen is ambiguous: it can mean an installed fullscreen app, an iOS standalone app, or a browser tab in F11. Second, a query for standalone alone misses iOS entirely.
navigator.standalone on iOS, iPadOS and macOS¶
navigator.standalone is a non-standard, read-only boolean from WebKit. In WebKit's source it returns the frame's standalone setting, which Safari sets for Home Screen web apps. The ENABLE_NAVIGATOR_STANDALONE build flag is on for every Cocoa platform, so the property exists in Safari on macOS too. Since Safari 17 (June 2023, WebKit commit 265004@main), it's false in macOS browser tabs and true in Dock web apps; Apple's own documentation doesn't mention it, so test on the macOS versions you support. Test the value, not the property's presence, and combine it with navigator.maxTouchPoints > 0 when you need to know that you are on iOS or iPadOS:
// true in an iOS/iPadOS Home Screen web app and (Safari 17+) in a macOS Dock
// web app; false in Safari browser tabs; undefined in Chromium and Firefox.
export const isAppleWebApp = navigator.standalone === true;
// Touch support separates iPhone/iPad (including iPadOS's desktop-class UA)
// from the Mac.
export const isIOSStandalone = isAppleWebApp && navigator.maxTouchPoints > 0;
Since iOS 26, every site added to the Home Screen with Open as Web App switched on becomes a Home Screen web app, with or without a manifest. Detect that context with navigator.standalone rather than inferring it from your manifest's display value, which may not exist or may not have been applied.
A complete launch-context module¶
The module below combines the signals into one answer, records where the session came from, and keeps that answer for the rest of the session. The start_url marker and the TWA referrer are only present on the first navigation of a launch, so later page loads in the same window need the stored value.
/**
* Determine whether this document runs as an installed app, and how the
* current app session was launched. Works in every engine; each signal
* covers the platforms where it is reliable.
*/
const STORAGE_KEY = "launch-context";
const TWA_PACKAGE = "com.example.twa"; // Your Trusted Web Activity's package name.
const LAUNCH_PARAM = "source";
const DISPLAY_MODES = [
"window-controls-overlay",
"tabbed",
"minimal-ui",
"standalone",
"fullscreen",
"picture-in-picture",
"browser",
];
export function getDisplayMode() {
for (const mode of DISPLAY_MODES) {
if (window.matchMedia(`(display-mode: ${mode})`).matches) return mode;
}
return "unknown";
}
function readStored() {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "null");
} catch {
return null;
}
}
function store(context) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(context));
} catch {
// Storage blocked: detection still works per page, attribution degrades.
}
}
/** Detect the launch source from the current navigation, if any. */
function detectLaunchSource() {
const referrer = document.referrer;
if (referrer.startsWith("android-app://")) {
// Chrome sets android-app://<package> for TWAs and for Custom Tabs
// opened by any app. Only your own package means "our TWA".
const pkg = new URL(referrer).host;
return pkg === TWA_PACKAGE ? "twa" : `android-app:${pkg}`;
}
const params = new URLSearchParams(location.search);
const source = params.get(LAUNCH_PARAM);
// Values you put in start_url, shortcuts, share_target and handlers.
if (["pwa", "twa", "shortcut", "share-target", "file-handler", "protocol"].includes(source)) {
return source;
}
return null;
}
export function getLaunchContext() {
const displayMode = getDisplayMode();
const iosStandalone = navigator.standalone === true;
const stored = readStored();
const launchSource = detectLaunchSource() ?? stored?.launchSource ?? null;
const installed =
iosStandalone ||
["standalone", "minimal-ui", "window-controls-overlay", "tabbed"].includes(displayMode) ||
launchSource === "twa" ||
// fullscreen is ambiguous (F11, element fullscreen): trust it only
// when the session started from an app entry point.
(displayMode === "fullscreen" && launchSource !== null);
const context = {
installed,
displayMode,
iosStandalone,
launchSource: launchSource ?? (installed ? "unknown-app-launch" : "browser"),
};
store(context);
return context;
}
/** Remove launch markers from the visible URL without reloading. */
export function stripLaunchParams() {
const url = new URL(location.href);
if (!url.searchParams.has(LAUNCH_PARAM)) return;
url.searchParams.delete(LAUNCH_PARAM);
history.replaceState(history.state, "", url);
}
Use it once at startup, before rendering anything that depends on it:
import { getLaunchContext, stripLaunchParams } from "./launch-context.js";
const launch = getLaunchContext();
document.documentElement.dataset.appMode = launch.installed ? "installed" : "browser";
stripLaunchParams(); // Keeps shared and bookmarked URLs clean.
Setting a data-app-mode attribute on the root element lets CSS use the combined answer, including the iOS case that @media (display-mode: standalone) misses:
:root[data-app-mode="installed"] .install-promo { display: none; }
:root[data-app-mode="installed"] .app-back-button { display: inline-flex; }
Reacting to display mode changes¶
The display mode can change while a document is alive:
- On desktop Chrome and Edge, installing moves the current tab into the new app window. The same document continues with a new display mode.
- Users can move an app window's page back to a browser tab (Open in Chrome), toggle the window controls overlay, or enter fullscreen.
Listen to change events on the media query lists and recompute:
import { getLaunchContext } from "./launch-context.js";
const modes = [
"browser", "standalone", "minimal-ui", "window-controls-overlay", "tabbed", "fullscreen",
];
export function watchDisplayMode(onChange) {
const lists = modes.map((mode) => window.matchMedia(`(display-mode: ${mode})`));
const handler = () => onChange(getLaunchContext());
for (const list of lists) list.addEventListener("change", handler);
return () => {
for (const list of lists) list.removeEventListener("change", handler);
};
}
Launch source attribution with start_url¶
The display mode says where the document runs. It doesn't say how the session started, and on Safari and Firefox it's your only install signal. A marker in start_url fills that gap: the browser navigates to exactly that URL when the user taps the app icon.
{
"id": "/",
"name": "Example Tasks",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"shortcuts": [
{ "name": "New task", "url": "/tasks/new?source=shortcut" }
],
"share_target": {
"action": "/share?source=share-target",
"method": "GET",
"params": { "title": "title", "text": "text", "url": "url" }
}
}
Rules that keep this safe:
- Set
idexplicitly. Withoutid, the app's identity is computed fromstart_url, so adding or changing the marker later creates a different app for browsers that already installed the old one. With"id": "/", you can changestart_urlfreely. See App Identity & Updates. - Mark every entry point. Shortcuts, the share target
action, file handlers and protocol handlers open their own URLs, notstart_url. Give each its own marker if you want to attribute those launches. - Persist the marker. It exists only on the first navigation. Store it in
sessionStorage(per window) as the module above does.sessionStoragedoesn't leak into other tabs or windows. - Strip it from the address. Remove the parameter with
history.replaceState()so users don't share URLs containing it. Also keep a<link rel="canonical">without the parameter for SEO. - Make it cacheable. If your service worker precaches
/, a navigation to/?source=pwadoesn't match the precached URL by default. Normalize the URL, or match withignoreSearchfor navigations (see below). - Remember manifest caching. Installed apps pick up a changed
start_urlonly when the browser updates the manifest. On Android, a WebAPK update is needed, and on iOS, the Home Screen app keeps the URL it was created with.
When the launch doesn't navigate: launch_handler and launchQueue¶
A start_url marker assumes that tapping the icon navigates to start_url. With the Launch Handler API that assumption can break. When your manifest sets "launch_handler": { "client_mode": "focus-existing" }, Chromium focuses an already open app window without navigating it, so no request for /?source=pwa is made and location.search doesn't change. With navigate-existing, the existing window does navigate, but the document that ran your startup code is replaced. In both cases, Chromium enqueues a LaunchParams object whose targetURL is the URL the launch would have opened, including your marker:
import { getLaunchContext } from "./launch-context.js";
/**
* Record launches that reuse an existing window. launchQueue is available in
* Chromium on desktop (MDN lists Chrome 102); feature-detect everywhere else.
*/
export function watchLaunches(onLaunch) {
if (!("launchQueue" in window)) return;
window.launchQueue.setConsumer((launchParams) => {
// Guard anyway: a launch without a target URL carries nothing to attribute.
if (!launchParams.targetURL) return;
const source = new URL(launchParams.targetURL).searchParams.get("source");
onLaunch({ ...getLaunchContext(), launchSource: source ?? "app-launch" });
});
}
Chromium can also queue parameters for the launch that created the current document, so a consumer registered at startup may see the first launch as well as later ones. Deduplicate against the start_url check if you record both. See MDN's launch_handler reference for the client_mode values. Firefox and Safari don't implement the Launch Handler API, but they also don't reuse windows this way, so the plain marker is enough there.
Serving start_url from the service worker cache¶
A navigation to /?source=pwa while offline must still resolve to your app shell. Either strip known launch parameters before the cache lookup or ignore the query string for navigations:
const LAUNCH_PARAMS = ["source"];
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.mode !== "navigate") return;
event.respondWith((async () => {
try {
return await fetch(request); // Network first for navigations.
} catch {
// Offline: look up the URL without launch markers.
const url = new URL(request.url);
for (const param of LAUNCH_PARAMS) url.searchParams.delete(param);
const cached = await caches.match(url.href);
return cached ?? (await caches.match("/offline.html")) ?? Response.error();
}
})());
});
Caching Strategies and Workbox Fundamentals cover navigation handling in depth.
Trusted Web Activities: the android-app:// referrer¶
A Trusted Web Activity renders your PWA inside an Android app from the Play Store. The page runs in Chrome (or another browser that supports TWAs), so display-mode queries return the TWA's display mode and can't distinguish it from a WebAPK. The distinguishing signal is the referrer. When an Android app starts a Custom Tabs session, which is what a TWA is, Chrome sets the default referrer from the calling app's package name. Chromium's ClientManager.getDefaultReferrerForSession() builds it with IntentHandler.constructValidReferrerForAuthority(), which produces a URL with the android-app scheme and the package name as its host:
web.dev's Learn PWA detection chapter uses the same check (document.referrer.startsWith('android-app://')) to report a twa display mode. Caveats:
- First load only. After the user navigates or the page reloads,
document.referreris the previous page or empty. Persist the result insessionStorage, aslaunch-context.jsdoes. - Not proof of your TWA. Any Android app that opens a link in a Custom Tab produces an
android-app://referrer with its package name, for example a mail app opening a link to your site. Compare the host with your own package name. - Your referrer policy doesn't affect it. The value comes from the launching intent, not from a previous page.
- Add a marker as well. Point the TWA's launch URL at
/?source=twa(Bubblewrap and PWABuilder let you set the start URL), whichlaunch-context.jsalready accepts. The marker survives cases where the referrer doesn't, such as a launch that restores a previous page.
To distinguish store installs from browser installs in the manifest, declare the Play app in related_applications. getInstalledRelatedApps() can then tell a tab that the TWA is installed, as described next.
Detecting installation from a browser tab: getInstalledRelatedApps()¶
navigator.getInstalledRelatedApps() lets a page ask whether apps it's verified to be related to are installed: your Android app, your Windows app, or your PWA. It's the only API that answers "is my app installed?" from a normal tab, which makes it useful for hiding install promotion, offering an Open the app link, or avoiding duplicate notifications from both the web and native app.
partial interface Navigator {
[SecureContext] Promise<sequence<RelatedApplication>> getInstalledRelatedApps();
};
dictionary RelatedApplication {
required USVString platform;
USVString url;
DOMString id;
DOMString version;
};
Behavior, from Chromium's InstalledAppController and InstalledAppProviderImpl:
- Secure contexts only, and only in the outermost main frame. In an iframe, the call throws
InvalidStateErrorwith the message "getInstalledRelatedApps() is only supported in top-level browsing contexts." - The browser reads
related_applicationsfrom the page's own linked manifest, filters it to the entries that are installed and verified, and resolves with those. An empty array means "none of the declared apps is installed", or "not verifiable". - Only the first few entries count. Chrome's documentation says only the first three apps declared in the manifest are taken into account, to stop sites from probing a broad set of apps. The shared browser code also truncates the list to 10 before the per-platform checks.
- Always
[]in incognito. The off-the-record check runs after the lookups complete, so response timing can't reveal private mode. min_versionandfingerprintsinrelated_applicationsare part of the manifest spec, but Chrome's documentation states that no browser implements them for this API.
Supported app types¶
| App type | platform | Where it's checked | Verification of the relationship |
|---|---|---|---|
| Android app (including a TWA) | play | Chrome for Android 80+ | Digital Asset Links statement in the Android app (delegate_permission/common.handle_all_urls for your site) |
| Windows (UWP) app | windows | Chrome and Edge 85+ on Windows | App URI handler in the app manifest plus a windows-app-web-link file on your site |
| PWA, same origin and page within its scope | webapp | Chrome for Android 84+; Chrome and Edge 140+ on Windows, macOS, Linux and ChromeOS | The PWA's manifest lists itself; desktop requires the app's id |
| PWA, different scope or origin | webapp | Chrome for Android 84+ only | assetlinks.json on the PWA's origin with delegate_permission/common.query_webapk |
| Any | any | Firefox, Safari | ❌ Not supported |
Support data as of September 2026, from Chrome's documentation and MDN. MDN's compatibility data didn't yet list the desktop PWA support that Chrome 140's release notes announced ("Additional support for web apps on Desktop was enabled in Chrome 140").
Checking whether your PWA is installed (same scope)¶
The PWA declares itself in its own manifest. On desktop, the id must be the app's absolute manifest ID: Chromium's desktop matcher parses id as a URL and skips entries where it isn't a valid absolute URL. Android doesn't need it.
{
"id": "/",
"name": "Example Tasks",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"related_applications": [
{
"platform": "webapp",
"url": "https://tasks.example.com/manifest.webmanifest",
"id": "https://tasks.example.com/"
}
]
}
The manifest id of "/" resolves against start_url's origin to https://tasks.example.com/. That resolved form is what goes in related_applications[].id. DevTools shows it as the Computed App ID in Application > Manifest.
Call the method from a page inside the PWA's scope. Called outside it, the result is []. On desktop, Chromium answers from the web apps installed in the current browser profile, so an app installed from another profile or another browser isn't reported.
Checking from a different scope or origin (Android only)¶
A marketing site at www.example.com can check for the PWA at app.example.com on Android. The PWA's origin publishes an asset links file that names the checking site's manifest, and the checking site lists the PWA's manifest URL:
[
{
"relation": ["delegate_permission/common.query_webapk"],
"target": {
"namespace": "web",
"site": "https://www.example.com/manifest.webmanifest"
}
}
]
{
"related_applications": [
{ "platform": "webapp", "url": "https://app.example.com/manifest.webmanifest" }
]
}
Checking for your Android or Windows app¶
For a Play Store app, including a TWA built with Bubblewrap or PWABuilder, declare { "platform": "play", "id": "com.example.twa" } and make sure the Android app includes an asset statement for your site. Bubblewrap and PWABuilder generate that statement. For a Windows app, declare { "platform": "windows", "id": "<PackageFamilyName>!App" } and publish the windows-app-web-link file described in Chrome's documentation. Publishing to App Stores covers the packaging side.
A production wrapper¶
/**
* Returns the installed related apps, or null when the answer is unknown
* (unsupported browser, iframe, timeout). Never throws.
*/
export async function getInstalledRelatedAppsSafe({ timeoutMs = 3000 } = {}) {
if (!("getInstalledRelatedApps" in navigator) || window.top !== window.self) {
return null; // Unsupported, or not the top-level document.
}
try {
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs));
return await Promise.race([navigator.getInstalledRelatedApps(), timeout]);
} catch (error) {
console.warn("getInstalledRelatedApps() failed:", error.name);
return null;
}
}
/** true: installed; false: checked and not installed; null: unknown. */
export async function isOurAppInstalled() {
const apps = await getInstalledRelatedAppsSafe();
if (apps === null) return null;
return apps.some((app) => app.platform === "webapp" || app.platform === "play");
}
import { isOurAppInstalled } from "./related-apps.js";
import { getLaunchContext } from "./launch-context.js";
const launch = getLaunchContext();
if (!launch.installed) {
const installed = await isOurAppInstalled();
if (installed === true) {
// Hide install promotion; the user already has the app.
document.querySelectorAll(".install-promo").forEach((element) => element.remove());
// Chrome shows its own "Open in app" affordance on desktop. On Android,
// an in-scope link opened from outside Chrome can launch the WebAPK.
document.getElementById("open-in-app-hint")?.removeAttribute("hidden");
}
}
Treat null ("unknown") differently from false. In Safari and Firefox the answer is always unknown, and your UI should behave as it does for users without the app.
The weak signal: beforeinstallprompt never fires¶
In Chromium, a page that meets the installability criteria but is already installed in the current profile doesn't receive beforeinstallprompt (the pipeline stops with ALREADY_INSTALLED). The absence of the event is therefore consistent with "installed", but it also happens when the page isn't promotable, when the manifest prefers a related native app, in incognito, or when the event fired before your listener was attached. Don't use it to decide anything user-visible. For analytics it's usable only when combined with getInstalledRelatedApps(). The event itself is covered in Install Prompts & Custom UI.
Adapting the UI in app mode¶
Once you know you're in an app window, a few adjustments make the difference between "a website in a frame" and an app:
- Hide install promotion and "get the app" banners, including on iOS where only
navigator.standalonetells you. - Provide back navigation when the window has no browser UI (
standalone,fullscreen): an in-app back button, or an app bar with Up navigation. - Replace missing browser features: a Share or Copy link button (the address bar is gone), a reload action or pull-to-refresh, and visible loading indicators.
- Handle external links deliberately. Links outside
scopeopen in a browser tab or an in-app browser surface, depending on the platform. Mark them visually. - Respect safe areas and the title bar:
env(safe-area-inset-*)on iOS, andtitlebar-area-*variables with window controls overlay.
import { getLaunchContext } from "./launch-context.js";
import { watchDisplayMode } from "./watch-display-mode.js";
function applyAppMode({ installed, displayMode }) {
const root = document.documentElement;
root.dataset.appMode = installed ? "installed" : "browser";
root.dataset.displayMode = displayMode;
// Show an in-app back button only where the browser provides none.
const needsBackButton = installed && displayMode !== "minimal-ui";
document.querySelector(".app-back-button")?.toggleAttribute("hidden", !needsBackButton);
}
applyAppMode(getLaunchContext());
watchDisplayMode(applyAppMode);
document.querySelector(".app-back-button")?.addEventListener("click", () => {
// Fall back to the start page when there is no in-app history.
if (history.length > 1) history.back();
else location.assign("/");
});
history.length also counts entries you can't inspect, such as out-of-scope pages visited in the same window, so treat this fallback as a best effort. The Navigation API's navigation.canGoBack is a more precise check where it's supported. App-Like UX Patterns and Display Modes go deeper into standalone navigation, title bars and safe areas.
Server-side install and launch tracking¶
Servers see requests, not display modes. What you can observe on the server:
| Observable | Where it comes from | What it tells you |
|---|---|---|
GET /?source=pwa | start_url marker | An app launch from the icon (all platforms) |
GET /tasks/new?source=shortcut | Shortcut URL marker | A launch from an app shortcut |
Referer: android-app://<package> | TWA or Custom Tab launch | Your TWA, when the package matches |
POST /api/install-events | Client beacon from appinstalled | An install in Chromium |
POST /api/sessions with displayMode | Client beacon from launch-context.js | Installed sessions on every platform |
The launch request is often served by a service worker, especially offline, so markers in the URL never reach your server in those cases. Report sessions from the client and let the server join them with a user ID:
import { getLaunchContext } from "./launch-context.js";
const context = getLaunchContext();
// One report per window session: sessionStorage is per window.
let alreadyReported = false;
try {
alreadyReported = sessionStorage.getItem("session-reported") === "1";
sessionStorage.setItem("session-reported", "1");
} catch {
// If storage is blocked, report every page load; the server deduplicates.
}
if (!alreadyReported) {
const body = JSON.stringify({
installed: context.installed,
displayMode: context.displayMode,
launchSource: context.launchSource,
iosStandalone: context.iosStandalone,
at: new Date().toISOString(),
});
const sent = navigator.sendBeacon?.("/api/sessions", new Blob([body], { type: "application/json" }));
if (!sent) {
// Offline or beacon refused: queue it for Background Sync or retry later.
fetch("/api/sessions", { method: "POST", body, keepalive: true,
headers: { "Content-Type": "application/json" } }).catch(() => {});
}
}
A minimal receiving endpoint that also logs the server-visible markers:
import express from "express";
const app = express();
app.use(express.json({ limit: "4kb" }));
// The Referer header is client-controlled: parse it defensively.
function androidAppPackage(referer) {
if (!referer.startsWith("android-app://")) return null;
try {
return new URL(referer).host.slice(0, 128) || null;
} catch {
return null; // Malformed header: ignore instead of failing the request.
}
}
// Log launch markers on navigations that reach the network.
app.use((req, res, next) => {
const referer = req.get("referer") ?? "";
const source = typeof req.query.source === "string" ? req.query.source.slice(0, 32) : null;
const androidApp = androidAppPackage(referer);
if (source || androidApp) {
console.log(JSON.stringify({
type: "launch_request",
path: req.path,
source,
androidApp,
at: new Date().toISOString(),
}));
}
next();
});
const DISPLAY_MODES = new Set([
"browser", "standalone", "minimal-ui", "fullscreen",
"window-controls-overlay", "tabbed", "picture-in-picture", "unknown",
]);
app.post("/api/sessions", (req, res) => {
const { installed, displayMode, launchSource } = req.body ?? {};
// Validate: this is client-supplied data.
if (typeof installed !== "boolean" || !DISPLAY_MODES.has(displayMode)) {
return res.sendStatus(400);
}
console.log(JSON.stringify({
type: "session",
installed,
displayMode,
launchSource: String(launchSource).slice(0, 64),
at: new Date().toISOString(),
}));
res.sendStatus(204);
});
app.listen(3000);
Don't store the display mode in a cookie on Chromium
It's tempting to set document.cookie = "app_mode=standalone" so the server sees the mode on every request. On Chromium the installed app and browser tabs share the profile's cookie jar, so a tab opened after an app session sends app_mode=standalone until your script overwrites it, and the first navigation of every session carries the previous session's value. Report the mode explicitly per session instead.
Uninstalls are invisible to both the client and the server. You can infer them from sessions that stop arriving, or from push subscriptions that start failing with 404 or 410 from the push service. See The Web Push Protocol and Analytics for PWAs.
Common pitfalls¶
- Using only
(display-mode: standalone). Misses iOS Home Screen apps, which matchfullscreen, and desktop Firefox taskbar apps, which matchminimal-ui. - Treating
'standalone' in navigatoras "is iOS". The property exists in Safari on macOS too. Compare the value withtrue. - Relying on the
start_urlmarker after the first page. Persist it per session, and give shortcuts and handlers their own markers. - Changing
start_urlwithout anid. It changes the app's identity and orphans existing installs. - Counting every
android-app://referrer as your TWA. Custom Tabs from any app have one. Match your package name. - Using a relative
idinrelated_applicationsfor desktop. Chromium's desktop matcher needs the absolute manifest ID. - Calling
getInstalledRelatedApps()outside the PWA's scope or in an iframe. You get[]orInvalidStateError. - Interpreting
[]as "not installed" in unsupported contexts. Feature-detect and treat unsupported as unknown.
Further reading¶
On this site
- Display Modes: how each engine computes
display-mode, and adapting the standalone UI. - Install Prompts & Custom UI:
beforeinstallprompt,appinstalledand install funnels. - App Identity & Updates: the manifest
idused by desktop related-app checks. - Trusted Web Activity: Android apps that host your PWA.
- Advanced & Integration Members:
related_applicationsandprefer_related_applications. - Analytics for PWAs: reporting installed sessions.
- iOS & iPadOS: Home Screen web app behavior and storage isolation.
External references
- Get Installed Related Apps API (Chrome for Developers)
- MDN: Navigator.getInstalledRelatedApps()
- MDN: display-mode
- Media Queries Level 5: display-mode (W3C)
- Learn PWA: Detection (web.dev)
- Get Installed Related Apps specification (WICG)
- WebKit bug 264218: display-mode reports fullscreen for standalone apps