Skip to content

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 with display: "standalone" report fullscreen, and desktop Firefox never reports standalone. Check navigator.standalone === true first on Apple platforms.
  • Launch attribution: put a marker in start_url (for example /?source=pwa), set an explicit manifest id so the marker doesn't change the app's identity, and persist it in sessionStorage. It only appears on the first navigation of a launch, and not at all when launch_handler focuses an existing window; read launchQueue for those launches.
  • Trusted Web Activities: the first page load has document.referrer set to android-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 in related_applications. It's Chromium-only: Android 84+ for PWAs, desktop 140+ for PWAs in the same scope (with an absolute id), 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_url marker and the TWA Referer on 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():

app-mode.css
/* 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 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:

is-ios-standalone.js
// 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.

launch-context.js
/**
 * 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:

main.js
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:

app-mode.css
: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:

watch-display-mode.js
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.

manifest.webmanifest
{
  "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 id explicitly. Without id, the app's identity is computed from start_url, so adding or changing the marker later creates a different app for browsers that already installed the old one. With "id": "/", you can change start_url freely. See App Identity & Updates.
  • Mark every entry point. Shortcuts, the share target action, file handlers and protocol handlers open their own URLs, not start_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. sessionStorage doesn'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=pwa doesn't match the precached URL by default. Normalize the URL, or match with ignoreSearch for navigations (see below).
  • Remember manifest caching. Installed apps pick up a changed start_url only 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:

launch-queue.js
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:

sw.js
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();
    }
  })());
});
sw.js
import { precacheAndRoute } from "workbox-precaching";

// The default is [/^utm_/, /^fbclid$/]; add your launch marker.
precacheAndRoute(self.__WB_MANIFEST, {
  ignoreURLParametersMatching: [/^utm_/, /^fbclid$/, /^source$/],
});

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:

document.referrer === "android-app://com.example.twa"
Referer: android-app://com.example.twa

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.referrer is the previous page or empty. Persist the result in sessionStorage, as launch-context.js does.
  • 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), which launch-context.js already 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.

navigator_installed_app.idl (Chromium)
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 InvalidStateError with the message "getInstalledRelatedApps() is only supported in top-level browsing contexts."
  • The browser reads related_applications from 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_version and fingerprints in related_applications are 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.

manifest.webmanifest
{
  "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:

https://app.example.com/.well-known/assetlinks.json
[
  {
    "relation": ["delegate_permission/common.query_webapk"],
    "target": {
      "namespace": "web",
      "site": "https://www.example.com/manifest.webmanifest"
    }
  }
]
https://www.example.com/manifest.webmanifest (excerpt)
{
  "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

related-apps.js
/**
 * 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");
}
open-in-app-banner.js
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.standalone tells 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 scope open 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, and titlebar-area-* variables with window controls overlay.
app-mode-ui.js
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:

report-session.js
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:

server.mjs
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 match fullscreen, and desktop Firefox taskbar apps, which match minimal-ui.
  • Treating 'standalone' in navigator as "is iOS". The property exists in Safari on macOS too. Compare the value with true.
  • Relying on the start_url marker after the first page. Persist it per session, and give shortcuts and handlers their own markers.
  • Changing start_url without an id. 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 id in related_applications for desktop. Chromium's desktop matcher needs the absolute manifest ID.
  • Calling getInstalledRelatedApps() outside the PWA's scope or in an iframe. You get [] or InvalidStateError.
  • Interpreting [] as "not installed" in unsupported contexts. Feature-detect and treat unsupported as unknown.

Further reading

On this site

External references