Skip to content

PWAs on Android

On Android, an installed Progressive Web App can be a real Android package. When Chrome installs a PWA on a device with Google Play services, it asks Google's servers to mint a WebAPK: a signed APK generated from your manifest, with its own launcher entry, App info page, intent filters for your scope, share target, shortcuts and notification channel. Other browsers create browser-badged home screen shortcuts instead, and Trusted Web Activities put the same PWA into the Play Store. This page explains how each of those works internally, what the WebAPK contains, how updates, storage and permissions behave, which Android-specific UX details (back gesture, status bar, splash screen, orientation, edge-to-edge) you need to handle, and how to debug all of it with chrome://inspect and adb.

Key takeaways

  • A WebAPK is a thin APK, not a copy of your site. It stores your manifest data in its AndroidManifest.xml and launches Chrome (the browser that minted it) to render your pages with Chrome's storage, service worker and cookies.
  • Only some browsers mint WebAPKs. Chrome does on devices with Google Play services, Samsung Internet does on Samsung devices. Firefox, Edge, Opera, Brave and Chrome without Play services create home screen shortcuts that get no intent filters, share target or shortcuts.
  • Your scope becomes Android intent filters. Links to in-scope URLs from other apps can open the installed app. Scope changes only take effect after a WebAPK update.
  • Updates are slow and conditional. Chrome checks the manifest at most once a day while the app is open, then mints a new APK in a background job that needs an unmetered network and a charger. Name changes need user confirmation, and large icon changes are held back.
  • Android UX needs explicit handling. The system back gesture walks your history and then closes the app, theme_color tints the status bar, the splash screen is generated from name, background_color and your icon, and screen.orientation.lock() needs fullscreen.
  • WebView is not a PWA runtime. It has service workers but no install, push or notifications. Use a Trusted Web Activity to ship a PWA in the Play Store.

Three ways a PWA ends up on an Android device

"Installing a PWA" on Android produces one of three technically different things. Which one your user gets depends on the browser, on Google Play services and on how they found your app.

flowchart TD
    A["User installs from a browser"] --> B{"Which browser?"}
    B -- "Chrome with Google Play services" --> W["WebAPK minted by Google's server"]
    B -- "Samsung Internet on a Samsung device" --> W2["WebAPK minted by Samsung's server"]
    B -- "Firefox, Edge, Opera, Brave, Chrome without Play services" --> S["Home screen shortcut, browser-badged"]
    W -- "minting fails" --> S
    P["User installs from Google Play"] --> T["Trusted Web Activity: your own APK that opens Chrome or another TWA browser"]
WebAPK Home screen shortcut Trusted Web Activity
Who builds the APK The browser's minting server No APK You (Bubblewrap, PWABuilder, Android Studio)
Distribution Browser install flow Browser install flow Google Play or any Android store
Launcher and Settings > Apps entry ✅ Home screen icon only ✅
Link capturing via intent filters ✅ from scope ❌ ✅ from your Android manifest
Share target, shortcuts ✅ from the web manifest ❌ ✅ from your Android project
Notifications attributed to the app ✅ ❌ (attributed to the browser) ✅ with notification delegation
Storage The host browser's The browser's The host browser's
Updated when your manifest changes ✅ automatically, slowly ❌ ❌ (you ship a new APK)
beforeinstallprompt ✅ Depends on the browser Not applicable

The rest of this page covers WebAPKs first, because that's what most Android users get, then the other browsers, Android UX behavior, TWAs and WebView, and debugging.

Chromium's source also contains a third, newer path called the auto-minted TWA: Chrome asks an Android system service (WebAppManager, shipped as a Mainline module) to install a TWA-like app on the OS side. Chromium's architecture notes describe it as "used in specific projects like Desktop Android". It isn't how phones install PWAs as of September 2026, so treat it as an implementation detail to watch rather than something to design for.

Chrome WebAPKs in depth

From the install tap to the launcher icon

A WebAPK install starts in the same place as every Chromium install: the page must pass the installability checks, and the user must accept either Chrome's install UI or the dialog your code opens with beforeinstallprompt.prompt(). On Android, Chrome's banner logic is more lenient than on desktop: it requires a linked manifest but can fill in a missing name or icon from page metadata. The exact rules and error codes are on Installability Criteria.

sequenceDiagram
    participant U as User
    participant C as Chrome (AppBannerManagerAndroid)
    participant I as WebApkInstaller
    participant M as WebAPK minting server
    participant G as Google Play services
    participant L as Launcher
    U->>C: Accepts install dialog
    C-->>U: appinstalled fires on the page
    C->>C: Fetch best icons, maskable icon, splash icon, shortcut icons
    C->>I: Build WebAPK request (manifest data, icon hashes)
    I->>M: Request a signed APK
    M-->>I: APK token or download
    I->>G: Install the package
    G-->>L: New app appears in the drawer
    C-->>U: "Installed" notification

Details that matter in production:

  • appinstalled fires early. On Android the event fires when the user accepts, "not after the WebAPK is minted and installed", in the words of web.dev's Learn PWA course. The icon may appear seconds later, and a failed mint still leaves you with an appinstalled event.
  • Minting can fail, and Chrome falls back. Without Google Play services, with an unreachable minting server, or with URLs the server rejects (for example credentials embedded in start_url, scope or icon URLs, which Chrome reports as url-not-supported-for-webapk), Chrome creates an ordinary home screen shortcut. The shortcut has none of the integrations in this section.
  • The package name isn't yours. The minting server assigns it. Chrome-minted WebAPKs use package names that start with org.chromium.webapk.. You identify your app by the manifest id, which Chrome records in the WebAPK (see below), not by package name.
  • Icons are fetched once. Chrome downloads the icons it needs when it builds the request and sends their Murmur2 hashes along. A later icon change is detected by comparing those hashes. Serve icons with stable URLs and long cache lifetimes.

What the minted APK contains

Chromium's WebAPK shell is a template AndroidManifest.xml that the server fills in. Reading it tells you precisely what your web manifest turns into. As of Chromium's current source, the template declares minSdkVersion 24 (Android 7.0), requests POST_NOTIFICATIONS, and contains:

Web manifest input What it becomes in the WebAPK
short_name (or name) The launcher label (android:label)
name Stored name, shown on the splash screen and in dialogs
start_url startUrl metadata; launches open it
scope scope metadata and a set of VIEW intent filters (https, host, path prefix) marked android:autoVerify="true", plus matching NDEF_DISCOVERED filters for NFC tags
display (after display_override) displayMode metadata
orientation orientation metadata and android:screenOrientation on the activities
theme_color, background_color themeColor and backgroundColor metadata. The template also has darkThemeColor and darkBackgroundColor slots, but no shipped manifest member fills them yet
icons The launcher icon, a separate maskable icon when you provide purpose: "maskable", and a splash icon
shortcuts A static android.app.shortcuts resource, at most four entries
share_target An activity with SEND and, for files, SEND_MULTIPLE intent filters plus metadata for action, method, enctype, parameter names and accept lists
Manifest URL and id webManifestUrl and webManifestId metadata, used later to match update checks
The browser that installed it runtimeHost metadata: the package that renders the app

Two consequences follow. First, anything that the Android manifest has to know statically (scope, share target, shortcuts, orientation) can only change through a new APK. Second, the WebAPK doesn't contain any of your HTML, JavaScript or service worker. Every launch loads your site through the host browser, so deploying web code never requires an APK update.

The runtime: a shell hosted by the browser that minted it

At launch, the WebAPK's transparent launcher activity sends an intent to its runtime host. Chrome's WebappLauncherActivity routes it to WebappActivity, which extends Chrome's Custom Tab activity and hides the toolbar, omnibox and tab switcher. The WebAPK extracts a small runtime library from Chrome at run time, so its own logic is updated whenever Chrome updates, without reinstalling the APK.

The WebAPK stays bound to the browser that minted it. If a user installs your app from Chrome and later makes Firefox the default browser, the WebAPK still opens in Chrome, with Chrome's cookies and storage. If Chrome is uninstalled or disabled, WebAPKs minted by it can't run.

Chrome also communicates with the WebAPK through a bound service (IWebApkApi). Its methods show what the shell is for: notifyNotificationWithChannel() and cancelNotification(), checkNotificationPermission() and requestNotificationPermission(), getSmallIconId() for the status bar icon, and finishAndRemoveTaskSdk23() to close the app's task.

The scope-derived VIEW filters are what make links open your app. With this manifest:

app.webmanifest
{
  "id": "/app/",
  "start_url": "/app/?source=pwa",
  "scope": "/app/"
}

the WebAPK declares filters for https://example.com with the path prefix /app/. A link to https://example.com/app/orders/42 tapped in a chat app can open the installed app. A link to https://example.com/pricing can't. The App Identity & Updates page covers scope processing and the trailing-slash trap; the Android specifics are these:

  • Android decides, not Chrome. The intent resolution happens in the OS before Chrome runs. Since Android 12, generic web intents open the user's default browser unless the target app is verified or approved for the domain. The WebAPK's filters are marked autoVerify, but whether a particular device routes a given link to the WebAPK automatically or offers a chooser depends on the Android version, on verification, and on the Open supported links setting in the app's App info page. Test on the Android versions you support.
  • Clicks inside Chrome stay in Chrome. A link tapped in a Chrome tab loads in that tab. Chrome's menu offers Open in app for in-scope pages.
  • Scope changes are slow. The filters live in the APK, so they change only after a WebAPK update (next section). Until then, keep old paths working with redirects.
  • Intent filters can't be wider than one origin and one path prefix. For multi-origin apps, scope_extensions on desktop is covered on Protocol Handlers & Launch Handling; on Android, a TWA with several verified origins is the established route.

Test link capturing from a computer with adb, which sends the same intent a chat app would:

test-links.sh
#!/usr/bin/env bash
# Sends VIEW intents like another app would, to check whether the WebAPK captures them.
set -euo pipefail

URL_IN_SCOPE="https://example.com/app/orders/42"
URL_OUT_OF_SCOPE="https://example.com/pricing"

# Find the WebAPK package Chrome minted for this origin (package names are server-assigned).
adb shell pm list packages | grep 'org.chromium.webapk' || echo "No Chrome WebAPKs installed"

# Fire a browsable VIEW intent; Android resolves it exactly as for a tapped link.
adb shell am start -W -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE -d "$URL_IN_SCOPE"

adb shell am start -W -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE -d "$URL_OUT_OF_SCOPE"

# On Android 12+, show the domain verification state of a package:
# adb shell pm get-app-links org.chromium.webapk.<hash>

App info, storage and permissions

A WebAPK appears in Settings > Apps like any app. Its App info page shows the shell's size (tiny), its notification settings and its link settings. It doesn't show your site's data, because there isn't any in the APK: pages run in Chrome, so cookies, IndexedDB, Cache Storage and service worker registrations are Chrome's for your origin, shared with Chrome tabs. A user signed in to your site in Chrome is signed in to the installed app, and clearing Chrome's site data clears the app's data. Quotas are Chrome's, as described on Storage Quotas & Persistence. Installed apps are one of the signals Chromium considers when deciding whether to grant navigator.storage.persist().

Permissions are Chrome's per-origin permissions, with one Android-specific layer for notifications:

  • Android 13 and later require POST_NOTIFICATIONS per app. The WebAPK declares it. Chrome registers the WebAPK with its InstalledWebappRegistrar on launch and, through PermissionUpdater.onWebApkLaunch(), keeps the site's notification permission in Chrome aligned with the WebAPK's Android permission. A user who turns off notifications in the app's App info page effectively turns them off for the origin inside the app.
  • Notifications look like they come from your app. Chrome hands each notification to the WebAPK over IWebApkApi, so the shade shows your app's name, and your notification channel appears under your app in system settings. That's also what drives the launcher's notification dot.
  • Uninstalling doesn't delete site data by itself. Chromium listens for package removal. For registered web apps it updates delegated permissions and can offer to clear the site's data in Chrome. Don't assume either outcome in your code. Nothing reaches your page when the user uninstalls. Detect it indirectly, for example from push subscriptions that start failing, as Detecting Installed Apps explains.

How WebAPK updates work

App Identity & Updates documents the update pipeline step by step. In short: when the WebAPK is launched, and at least one day has passed since the last check (30 days if the WebAPK server's response to the last update request set its relax_updates flag; web.dev summarizes this as Chrome increasing the interval to 30 days when it can't get an updated manifest from the server), Chrome watches page loads in the app window. The first in-scope page that links a manifest whose id matches the WebAPK's recorded id is compared with the installed data. If anything relevant differs, Chrome writes an update request to disk and schedules a background task that runs when the app is closed, the device is charging and the network is unmetered. The new APK comes from the minting server and is installed by Google Play services.

Chromium's WebApkUpdateManager compares these fields: primary icon (by hash) and whether it's maskable, splash icon, scope, start_url, short_name, name, background_color, theme_color (and their dark slots), orientation, display, share_target and shortcuts. A shell APK that is out of date, or older than 360 days, also triggers an update.

Two rules protect users against an installed app silently changing identity:

  • Name changes need consent. When name or short_name changes, Chrome shows an app identity dialog comparing old and new before it requests the update, unless the user already approved that exact change.
  • Large icon changes are held back. Chrome measures how much the new icon differs from the installed one. In the current source (WEB_APK_ICON_UPDATE_BLOCKED_AT_PERCENTAGE = 11 in WebApkUpdateManager.java, compared against a floored percentage), a change that differs by less than 11 percent updates silently. A larger change would require the identity dialog, which is behind a feature flag that is disabled by default (PwaUpdateDialogForIcon), so in practice a redesigned icon doesn't reach existing WebAPKs. Plan icon redesigns with that in mind, and test on a real device.

To force an update while testing, open about://webapks on the device, press Update for your app, close the app, keep the device on Wi-Fi and a charger, and relaunch after a few minutes.

Other Android browsers

Every major Android browser can put a PWA on the home screen. Only Chromium browsers with a trusted minting server produce WebAPKs.

Samsung Internet

Samsung Internet is Chromium-based and uses the same manifest requirements as Chrome. When a page is installable it shows an install icon in the URL bar, and it fires beforeinstallprompt (since Samsung Internet 5.0, per MDN) and appinstalled (since 7.0). On Samsung devices it mints WebAPKs through Samsung's own server; on other phones it creates shortcuts. MDN's data lists share_target from Samsung Internet 12, shortcuts from 14 and id from 17. Because the WebAPK's runtime host is Samsung Internet, the app uses Samsung Internet's storage, not Chrome's: a user who is signed in to your site in Chrome is not signed in to a Samsung Internet install.

Firefox for Android

Firefox for Android adds manifest-based apps to the home screen as a Firefox-badged launcher shortcut. It needs a manifest with name or short_name, a display other than browser, and an icon of at least 192 px, and it opens the app in Firefox's standalone web app activity. It honors start_url, scope, display (fullscreen, standalone, minimal-ui), orientation, theme_color and background_color. It doesn't support display_override, id, shortcuts or share_target (MDN notes that share_target "parses, but has no effect"), and it never fires beforeinstallprompt or appinstalled. Service workers, Web Push and Web Share work. Firefox's display-mode media query reports installed apps correctly from Firefox 116; before that, display-mode: browser was always true. MDN lists full screen.orientation.lock() support from Firefox 144; earlier Android versions exposed the method but it failed.

Microsoft Edge, Opera, Brave and others

Edge for Android is Chromium-based, but Microsoft doesn't run a WebAPK minting server for it, so installing creates a home screen shortcut badged with the Edge logo. Opera, Brave, Vivaldi and other Chromium forks behave the same way unless the vendor runs its own minting service. The practical rule: on non-Chrome, non-Samsung browsers, don't rely on anything that needs an APK (share target, shortcuts, link capturing, notification attribution).

Detecting what you're running in

android-context.js
// Classifies the Android launch context so analytics and UI can adapt.
// Feature detection first; the user agent string is only a coarse fallback.

const standaloneModes = ["fullscreen", "standalone", "minimal-ui"];

export function getAndroidLaunchContext() {
  const ua = navigator.userAgent;
  const isAndroid = /Android/i.test(ua);
  if (!isAndroid) return { platform: "other" };

  const displayMode =
    standaloneModes.find((m) => window.matchMedia(`(display-mode: ${m})`).matches) ??
    "browser";

  // A Trusted Web Activity sets document.referrer to android-app://<package>/
  // on the first navigation. Store it: later navigations lose it.
  let twaPackage = null;
  if (document.referrer.startsWith("android-app://")) {
    twaPackage = new URL(document.referrer).hostname;
    try {
      sessionStorage.setItem("twa-package", twaPackage);
    } catch {
      /* storage can be unavailable; the value is best-effort */
    }
  } else {
    try {
      twaPackage = sessionStorage.getItem("twa-package");
    } catch {
      twaPackage = null;
    }
  }

  let browser = "chromium";
  if (/SamsungBrowser\//.test(ua)) browser = "samsung-internet";
  else if (/Firefox\//.test(ua)) browser = "firefox";
  else if (/EdgA\//.test(ua)) browser = "edge";
  else if (/; wv\)/.test(ua)) browser = "webview"; // Android WebView marks itself with "wv"

  return {
    platform: "android",
    browser,
    displayMode,
    installed: displayMode !== "browser" || twaPackage !== null,
    twaPackage,
  };
}

Use the result for analytics and small UI adjustments (hiding your own install button, showing a back affordance). Don't gate features on it. Feature-detect APIs such as navigator.share or "launchQueue" in window directly.

From an ordinary Chrome tab you can also ask whether the WebAPK (or your Play Store TWA) is installed. navigator.getInstalledRelatedApps() reports your own PWA on Chrome for Android 84 and later when the manifest's related_applications lists it with "platform": "webapp", and a Play app from Chrome 80 when the Android app declares the site in its asset statements:

installed-check.js
// Hides in-page install promotion when the WebAPK or TWA is already installed.
// Manifest: "related_applications": [
//   { "platform": "webapp", "url": "https://example.com/app/manifest.webmanifest" },
//   { "platform": "play", "id": "com.example.app" } ]
export async function isAppInstalled() {
  if (!("getInstalledRelatedApps" in navigator)) return false; // non-Chromium browsers
  try {
    const apps = await navigator.getInstalledRelatedApps();
    return apps.length > 0;
  } catch {
    return false; // e.g. called from a frame or an insecure context
  }
}

Call it from a page inside the PWA's scope; elsewhere a webapp entry returns [], unless the PWA's origin publishes a delegate_permission/common.query_webapk asset links statement naming the checking site's manifest (an Android-only path). It tells you nothing about Firefox or Edge shortcuts. Detecting Installed Apps covers the rules and a production wrapper.

Android-specific UX in standalone apps

A standalone WebAPK runs in its own Android task with its own entry in Recents, the status bar visible and tinted, and no browser UI. Display Modes covers how Chrome resolves the display mode; the sections below cover what you have to build.

The back button and back gesture

In a standalone WebAPK, Android's back gesture (or button) goes through Chrome, in this order:

  1. Close requests. Since Chrome 120, back is delivered as a close request to an open modal <dialog>, an auto popover and, where available, a CloseWatcher. MDN lists the CloseWatcher interface from Chrome 126 and Firefox 149.
  2. History. If nothing handled the close request, back navigates your session history, firing popstate and the Navigation API's navigate event.
  3. Exit. When there is no history entry left, back closes the app's task.

Two consequences. A modal built from a <div> is invisible to the close-request mechanism, so back leaves the page underneath it. And an app that replaces history on every navigation (history.replaceState() everywhere) exits on the first back press. Use real <dialog> elements or CloseWatcher, and push history entries for navigations the user thinks of as "pages".

sheet.js
// A bottom sheet that closes on Android back, Esc, or its own button.
// Uses CloseWatcher when available, falls back to a history entry otherwise.

export function openSheet(sheetEl, { onClose } = {}) {
  let closed = false;
  let watcher = null;

  const finish = () => {
    if (closed) return;
    closed = true;
    sheetEl.hidden = true;
    watcher?.destroy();
    window.removeEventListener("popstate", onPopState);
    onClose?.();
  };

  const onPopState = () => finish(); // back consumed our history entry

  if ("CloseWatcher" in window) {
    // Back gesture and Esc both become "close" events; no history pollution.
    watcher = new CloseWatcher();
    watcher.addEventListener("close", finish);
  } else {
    // Fallback: a history entry that back can pop without leaving the page.
    history.pushState({ sheet: true }, "");
    window.addEventListener("popstate", onPopState);
  }

  sheetEl.hidden = false;

  return function close() {
    if (closed) return;
    if (watcher) {
      watcher.requestClose(); // fires "close", which calls finish()
    } else if (history.state?.sheet) {
      history.back(); // pops our entry; popstate calls finish()
    } else {
      finish();
    }
  };
}

CloseWatcher limits abuse: a page can't create a series of watchers to trap the user, and watchers created without user activation are grouped so a single back press can close them together. Never use a close request to block exit, for example with an "are you sure" loop. App-Like UX Patterns covers history design for SPAs.

Status bar, theme color and the task switcher

In standalone and minimal-ui, Chrome tints the status bar with theme_color and uses it for the Recents card. An in-scope <meta name="theme-color"> overrides the manifest at run time, and Chrome honors the media attribute, which is the working way to supply a dark variant today, because the manifest's color_scheme_dark member isn't implemented anywhere:

index.html
<!-- The manifest color applies until this document is parsed; keep them identical for light mode. -->
<meta name="theme-color" content="#0b57d0" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">

Chrome picks light or dark status bar icons from the color's luminance, so avoid mid-tones where neither reads well. You can also change the meta tag from JavaScript, for example to match a full-screen image viewer; Chrome updates the status bar immediately.

Edge-to-edge, safe areas and the gesture bar

From Chrome 135, Chrome on Android can draw web content into the gesture navigation area. In a browser tab it shows a dynamic bottom bar (Chrome calls it "the chin") unless the page opts in with viewport-fit=cover, in which case the viewport extends to the bottom edge and env(safe-area-inset-bottom) reports the space the gesture bar needs. Chrome's migration guide notes the change targets small-screen devices and excludes large screens.

For installed apps, Chromium's current source makes the same opt-in decisive: when its WebAppShortEdgesCutoutMode behavior is enabled, a standalone app draws edge-to-edge (and into the display cutout) only if the page declares viewport-fit=cover, and reacts if the page changes that value later; a fullscreen app goes immersive and draws into the cutout immediately. Write layouts that work either way:

edge-to-edge.css
/* Requires <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> */

.app-header {
  /* Status bar and camera cutout at the top. */
  padding-top: env(safe-area-inset-top, 0px);
}

.bottom-nav {
  position: fixed;
  inset-inline: 0;
  bottom: 0;
  /* Keep tap targets above the gesture bar; 0px fallback for browsers without insets. */
  padding-bottom: env(safe-area-inset-bottom, 0px);
  padding-inline: env(safe-area-inset-left, 0px) env(safe-area-inset-right, 0px);
}

main {
  /* Reserve room for the fixed nav plus the inset so content can scroll clear of it. */
  padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));
}

env(safe-area-inset-bottom) is dynamic in a Chrome tab: it changes as the chin slides away while scrolling, and every change triggers layout. Chrome's guide describes env(safe-area-max-inset-bottom), available from Chrome 135, which holds the maximum value of that inset. Use it for bottom-anchored elements that shouldn't move while the user scrolls, with the dynamic inset as the fallback:

edge-to-edge.css (stable bottom bar)
.bottom-nav {
  /* Browsers without the max inset fall back to the dynamic one, then to 0. */
  padding-bottom: env(safe-area-max-inset-bottom, env(safe-area-inset-bottom, 0px));
}

The generated splash screen

Android doesn't let a WebAPK show HTML before Chrome has started, so Chrome draws a native splash screen while your page loads. From Chromium's WebappSplashController and the WebAPK's shared splash layout:

  • Background: background_color, made opaque. Without it, a default color.
  • Icon: the WebAPK's splash icon, or the primary launcher icon. A maskable icon is rendered as an adaptive icon. If the best icon is missing, generated, or smaller than a minimum size, Chrome uses a layout without an icon.
  • Title: your name, in a light or dark color chosen from the background's luminance, near the bottom of the screen.
  • Hiding: the splash hides on the first visually non-empty paint, when the page finishes loading, or when loading fails, with a 300 ms fade.

So the splash lasts until your first meaningful paint, which is another reason to serve the start URL from the service worker cache (see App Shell Model). Match background_color to the first frame your page paints, or users see a color flash on every launch. Provide a 512 px icon and a maskable icon so Chrome can pick a sharp splash image. Splash Screens & Theming compares this with iOS startup images.

Orientation

The manifest orientation member is baked into the WebAPK's activities, so it applies from the first frame. At run time, screen.orientation.lock() in Chromium on Android only succeeds when the page is in element fullscreen or the app's display mode is fullscreen; otherwise it rejects with a SecurityError. A standalone app therefore calls requestFullscreen() first:

orientation.js
// Locks landscape for a video player inside a standalone app.
export async function enterLandscapePlayer(playerEl) {
  // requestFullscreen() needs transient user activation: call this from a click handler.
  await playerEl.requestFullscreen({ navigationUI: "hide" });
  try {
    await screen.orientation.lock("landscape");
  } catch (err) {
    // SecurityError: not fullscreen; NotSupportedError: desktop or unsupported device.
    console.warn("Orientation lock unavailable:", err.name);
  }
}

document.addEventListener("fullscreenchange", () => {
  // Chromium releases the lock itself when fullscreen ends; unlock() is harmless.
  if (!document.fullscreenElement) screen.orientation.unlock?.();
});

Android 16 ignores orientation restrictions for apps targeting API level 36 on displays at least 600 dp wide (tablets, unfolded foldables, desktop windowing). Expect a locked orientation not to hold on large screens, and design responsive layouts rather than relying on a lock.

Pull-to-refresh, text selection and other browser behaviors

Chrome keeps pull-to-refresh in standalone apps. Disable it where it conflicts with your own gestures with overscroll-behavior-y: contain on the scroller (or body). There is no visible reload control otherwise, so give error states a Try again action. Out-of-scope navigations stay in the app's task but show a Custom Tab-style toolbar with the origin and a close button.

OS integration features on Android

Integration Mechanism on Android Needs a WebAPK Page
Share target SEND / SEND_MULTIPLE intent filters in the APK ✅ Web Share Target
App shortcuts (long-press) Static Android shortcuts, first four ✅ App Shortcuts
Notifications Shown through the WebAPK, own channel and permission ✅ for app attribution Notifications API
Badges Launcher notification dots; setAppBadge() resolves but does nothing ✅ Badging API
Link capturing Scope-based VIEW intent filters ✅ Protocol Handlers & Launch Handling
Web Share (sending) Android share sheet ❌ Web Share API
Background Sync, Periodic Sync, Background Fetch Chrome's scheduler ❌ (Periodic Sync needs installation) Background & Engagement
File handling, protocol handlers, WCO Not supported on Android – File Handling

A few Android details beyond those pages:

  • Share target quirks. Android never fills the url parameter; links arrive in text. Files are matched against the WebAPK's stored accept lists, and many apps share content URIs without an extension. List MIME types first.
  • Shortcut icons. Chrome for Android uses purpose: "any" icons for shortcuts and doesn't use maskable shortcut icons. Provide 96 px and 192 px PNGs.
  • The notification badge icon. MDN describes the badge option as the image shown "when there is not enough space to display the notification itself such as for example, the Android Notification Bar". Android renders it as a monochrome silhouette, so use a transparent PNG with a single-color shape. A full-color logo becomes a white square.
sw.js
// Push handler tuned for Android: monochrome badge, tag-based replacement, deep link data.
self.addEventListener("push", (event) => {
  let data = {};
  try {
    data = event.data?.json() ?? {};
  } catch {
    // Non-JSON payload: show it as plain text rather than failing silently.
    data = { body: event.data?.text() ?? "" };
  }
  event.waitUntil(
    self.registration.showNotification(data.title ?? "New activity", {
      body: data.body ?? "",
      icon: "/icons/icon-192.png",          // large icon beside the text
      badge: "/icons/badge-mono-96.png",    // status bar silhouette: transparent PNG, one color
      tag: data.threadId ?? "general",      // replaces an older notification for the same thread
      renotify: Boolean(data.threadId),     // vibrate again when replacing
      data: { url: data.url ?? "/app/" },
    }),
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const target = new URL(event.notification.data.url, self.location.origin).href;
  event.waitUntil(
    (async () => {
      const windows = await clients.matchAll({ type: "window", includeUncontrolled: true });
      // Reuse an existing app window instead of stacking a second task.
      const existing = windows.find((c) => new URL(c.url).pathname.startsWith("/app/"));
      if (existing) {
        try {
          const focused = await existing.focus();
          // navigate() rejects for clients this worker doesn't control.
          return await focused.navigate(target);
        } catch {
          // Fall through and open a fresh window at the target.
        }
      }
      return clients.openWindow(target);
    })(),
  );
});

The Push Notifications page covers subscription and delivery.

Trusted Web Activity and the Play Store

A Trusted Web Activity (TWA) is an Android app you build that opens your PWA full screen in the user's browser through the Custom Tabs protocol. Chrome supports it from Chrome 72. The browser verifies with Digital Asset Links that your app and your site belong to the same owner: your site serves /.well-known/assetlinks.json listing your package name and signing certificate fingerprints, and your Android app declares the origin. If verification fails, the browser falls back to a Custom Tab with a visible URL bar.

.well-known/assetlinks.json
[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": [
        "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
      ]
    }
  }
]

List the fingerprint of the key that signs the APK users actually install. With Play App Signing, that's Google's app signing key from the Play Console, not only your upload key; a missing fingerprint is the most common cause of a URL bar appearing in production.

How a TWA differs from a WebAPK:

WebAPK TWA
Created by The browser, from your manifest You, from an Android project
Launcher metadata From the web manifest; updated automatically From your Android project; updated by shipping a new APK
Store listing No Google Play and other Android stores
Runs in The browser that minted it The user's default browser if it supports TWAs, otherwise a Custom Tab or WebView fallback, depending on your launcher configuration
Native code None Your own activities, services, widgets
Notification permission Delegated from the WebAPK Delegated from your app via Chrome's permission delegation
Play Billing Not available Through the Digital Goods API

Bubblewrap (Google's CLI) and PWABuilder generate TWA projects from your manifest. Trusted Web Activity covers the project setup, Digital Asset Links, notification delegation, splash screens and Play Billing, and Publishing to App Stores covers store policies.

WebView wrappers are not a PWA runtime

Many "PWA to APK" tools wrap your site in Android's WebView. WebView is Blink, and service workers, Cache Storage and IndexedDB work in it (Android exposes ServiceWorkerController for apps that need to intercept service worker requests). But a WebView isn't a browser:

  • No install, no beforeinstallprompt. The native app is the install.
  • No Web Push and no Notifications API. MDN lists PushManager and Notification as unsupported in WebView Android. Push has to go through Firebase Cloud Messaging in native code.
  • No navigator.share(), and no Background Sync, Periodic Background Sync or Background Fetch beyond what the host app implements natively.
  • Separate storage. Each app's WebView has its own cookies and storage, so the user signs in again, and your origin's service worker is registered separately inside the app.
  • Your responsibility for updates and security. WebView is updated through Google Play, but everything around it (navigation policy, JavaScript bridges, file access, SSL error handling) is your native code.

WebView is the right tool when you're building a native app with some web screens. When the app is your PWA, a TWA gives you the full browser, shared storage, push, and the same code as the web.

Debugging PWAs on Android

Remote DevTools with chrome://inspect

  1. On the device, enable Developer options and USB debugging.
  2. Connect it to your computer and accept the RSA key prompt.
  3. Open chrome://inspect/#devices in desktop Chrome. Installed WebAPKs appear as separate targets under the Chrome instance that hosts them, labeled with the page URL.
  4. Click inspect to get full DevTools: console, network, Application > Manifest (with the installability section), service workers, storage.

Other Chromium-based browsers may appear there too, depending on whether the vendor enables remote debugging. Firefox for Android is debugged from desktop Firefox's about:debugging.

Serving your local dev server to the device

adb reverse makes http://localhost:<port> on the device reach your computer, and localhost is a secure context, so service workers and installation work without certificates:

android-dev.sh
#!/usr/bin/env bash
# Tunnel the dev server to a USB-connected Android device and open it in Chrome.
set -euo pipefail
PORT="${1:-5173}"

adb devices                                    # confirm the device is authorized
adb reverse "tcp:${PORT}" "tcp:${PORT}"        # device localhost:PORT -> host localhost:PORT
adb shell am start -a android.intent.action.VIEW \
  -d "http://localhost:${PORT}/" com.android.chrome

# Useful while testing installs and updates:
adb shell pm list packages | grep org.chromium.webapk     # installed Chrome WebAPKs
adb logcat | grep -i webapk                              # WebAPK-related log lines from Chrome and the shell

# Remove a test WebAPK completely before re-testing a fresh install:
# adb uninstall org.chromium.webapk.<hash>

A WebAPK minted for http://localhost works for testing, but it belongs to that origin; install from your staging HTTPS origin to test link capturing and share targets as users see them.

Device-side pages

Page What it shows
about://webapks Every WebAPK Chrome minted: package name, shell version, manifest URL, start_url, scope, display, orientation, colors, last update check and result, and an Update button
chrome://serviceworker-internals Service worker registrations and their state
chrome://flags Experimental flags. Enable command line on non-rooted devices lets you pass Chrome switches through /data/local/tmp/chrome-command-line
App info for the WebAPK Notification permission and channels, link handling (Open supported links), storage

Testing on emulators

WebAPK minting needs Google Play services, so use an Android Virtual Device image that includes Google Play. On images without it, Chrome installs a plain shortcut and you can't test share targets or link capturing. The Chromium team's own testing guide uses an emulator plus adb reverse for local servers, the same setup as above.

A pre-release checklist for Android

  • Manifest has id, name, short_name, start_url and scope you intend to keep for years.
  • Icons at 192 px and 512 px, plus a maskable icon; badge icon is monochrome and transparent.
  • background_color matches your first paint in both light and dark themes.
  • theme-color meta tags with media for light and dark.
  • viewport-fit=cover with safe-area padding tested on a gesture-navigation device.
  • Every modal is a <dialog>, a popover or uses CloseWatcher; back never exits unexpectedly.
  • Start URL and an offline fallback served from the service worker cache.
  • about://webapks shows the expected scope, share target and shortcuts after install.
  • Link capturing tested with adb shell am start on the oldest and newest Android versions you support.

Browser support

Support data as of September 2026. For live data, see MDN's Web App Manifest compatibility tables and caniuse.

Capability Chrome Samsung Internet Firefox Edge WebView
Install result ✅ WebAPK1 ✅ WebAPK on Samsung devices, shortcut elsewhere ⚠️ shortcut ⚠️ shortcut ❌
beforeinstallprompt / appinstalled ✅ ✅ 5.0 / 7.0 ❌ ⚠️2 ❌
Link capturing from other apps ✅ ✅ with WebAPK ❌ ❌ ❌
share_target ✅ 71 (files 76) ✅ 12 ❌ ❌ ❌
shortcuts ✅ 84 (max 4) ✅ 14.0 ❌ ❌ ❌
Web Push ✅ ✅ ✅ ⚠️2 ❌
setAppBadge() visible badge ⚠️ resolves, no badge ⚠️ ❌ ⚠️ ❌
CloseWatcher ✅ 126 ✅ ✅ 149 ✅ ✅
screen.orientation.lock() ✅ fullscreen only ✅ fullscreen only ✅ 144 ✅ fullscreen only ⚠️ depends on the host app

Common pitfalls

  • Expecting instant manifest changes. Scope, shortcuts, share target and colors change only after a WebAPK update, which takes at least a day and a charger. Redesigned icons may never reach existing installs.
  • Moving or renaming the manifest. Older WebAPKs without a recorded id match update checks by manifest URL or start_url. Keep both stable.
  • <div> modals. They don't receive close requests, so the back gesture navigates away underneath them.
  • Replacing all history entries. The first back press closes the app.
  • Assuming the default browser hosts the app. A WebAPK always opens in the browser that minted it.
  • Testing on an emulator without Google Play. You get a shortcut and conclude that share targets don't work.
  • Relying on setAppBadge(). It resolves on Android and shows nothing. Leave notifications in the tray if you want the dot.
  • Wrapping the site in a WebView and expecting push. Use a TWA, or implement FCM natively.

Further reading

On this site

External references


  1. Requires Google Play services. Without them, or when minting fails, Chrome creates a home screen shortcut. ↩

  2. Chromium-based, but Microsoft doesn't document these for Edge on Android. Test before relying on them. ↩↩