Skip to content

Advanced and Integration Manifest Members

Integration members are the Web App Manifest keys that wire an installed PWA into the operating system: they register it as a handler for URL schemes and file types, add it to the share sheet, control which window a launch lands in, stretch its scope across several origins, and describe its relationship to native apps. Unlike name, icons or display, almost all of them are specified outside the core W3C manifest spec, implemented only in Chromium-based browsers, and applied at install or update time rather than on every page load. This page documents every one of them at the level of processing rules, validation edge cases, OS behavior and current support, and links to the capability pages that cover each API end to end.

Key takeaways

  • Every member on this page is Chromium-only (Chrome, Edge, Opera, Samsung Internet to varying degrees). Safari and Firefox ignore all of them, so each one must be a progressive enhancement over a working web app.
  • Most members are processed at install time and re-applied when the browser picks up a manifest update. Invalid entries are dropped one by one, silently, with only a DevTools manifest warning to tell you.
  • protocol_handlers (Chrome 96), file_handlers (Chrome 102) and launch_handler (Chrome 110) are the stable desktop trio. Files and captured launches arrive through window.launchQueue; do not rely on a reload re-delivering the last launch: Chrome 146 removed that undocumented behavior on desktop.
  • scope_extensions shipped in Chrome 139 on desktop (Chrome's release notes; MDN's data says 138) with a new syntax ({"type": "origin", "origin": …} plus a web-app-origin-association file keyed by manifest id). The origin-trial syntax with *. wildcards is ignored by current Chrome.
  • handle_links, url_handlers and capture_links never shipped. Link capturing into installed PWAs is on by default on Windows, macOS and Linux (Chrome 138 for apps that declare launch_handler.client_mode, Chrome 140 for all apps; available but off by default per app on ChromeOS), controlled by the user and by launch_handler.
  • tab_strip and the tabbed display mode are stable only on ChromeOS; note_taking is ChromeOS-only; edge_side_panel and widgets are Microsoft Edge-only, and Microsoft marked the sidebar feature as deprecated in July 2026.
  • related_applications plus prefer_related_applications change install promotion on Android and, when a chrome_web_store entry (or play on ChromeOS with Android apps) is listed, on desktop Chromium, and navigator.getInstalledRelatedApps() can detect a related Android app, Windows app or (since Chrome 140 on desktop) the PWA itself. iarc_rating_id is not read by any browser.

Why integration members behave differently from core members

Core members such as name, start_url and display are defined by the W3C Web Application Manifest spec and influence how the browser presents the app on every launch. Integration members add a second responsibility: the browser has to write something into the operating system (a registry key on Windows, a CFBundleDocumentTypes entry in the app shim's Info.plist on macOS, a .desktop file and a MIME database entry on Linux, a WebAPK manifest on Android) and keep it in sync with your manifest over the lifetime of the installation. That has consequences you need to plan for:

  • Nothing happens until the app is installed. A protocol_handlers entry on a site that the user has only bookmarked does nothing. The one exception is related_applications, which influences the install promotion that runs before installation.
  • Changes propagate through the manifest update process. Chromium re-checks an installed app's manifest when the app's pages load (with throttling) and, when an integration member changes, re-runs the matching OS integration step (for example unregistering removed protocol handlers). The rules that decide when an update is picked up are covered in App Identity & Updates.
  • Failures are per entry and silent. Chromium's manifest parser drops a single invalid file_handlers item, protocol handler or scope extension and keeps the rest. The only feedback is an error string in the Application > Manifest pane of DevTools and in the manifest parser output. Get into the habit of checking that pane after every manifest change (see Browser DevTools).
  • The user has the last word. Protocol and file handling show a consent prompt on first use; link capturing and file handling can be toggled per app on chrome://app-settings/<app-id>; the OS may already have a default handler for a file type or scheme and will not silently hand it to your app.

Where each member is specified

Member Specification Status of the document
protocol_handlers, file_handlers, note_taking, tab_strip, scope_extensions, related_applications, prefer_related_applications, display_override Manifest Incubations (WICG) Community Group draft
launch_handler, window.launchQueue Web App Launch Handler API (WICG) Community Group draft
share_target Web Share Target Editor's draft
navigator.getInstalledRelatedApps() Get Installed Related Apps API (WICG) Community Group draft
iarc_rating_id Manifest App Information registry Supplementary, advisory only
edge_side_panel, widgets Microsoft Edge documentation and explainers Proprietary to Edge
handle_links, url_handlers, capture_links Explainers only Never shipped; removed or abandoned

The core members, their defaults and their processing order are covered in the Members Reference; this page assumes you know them.

Shared processing rules: URL resolution and "within scope"

Almost every integration member contains a URL (action, url, new_note_url, new_tab_button.url). The processing rules are the same for all of them, and most bugs come from these three rules:

  1. Relative URLs resolve against the manifest URL, not the document URL. A manifest at /static/app.webmanifest with "action": "open" produces /static/open. Always write root-relative paths ("/open").
  2. The resolved URL must be within the app's scope or the entry is discarded. Chromium's check is "same origin, and the URL's path starts with the scope's path" as a plain string prefix. A scope of /app therefore also covers /apple and /app-old; end scopes with a slash (/app/) if you mean a directory.
  3. Only same-origin URLs pass, because being within scope implies same origin. Cross-origin handler pages are impossible, even on your own subdomains. scope_extensions does not change this for handler URLs; it only changes how navigations are treated.

The id member matters too: Chromium derives the installed app's identity, and therefore its OS registrations and per-app settings, from the manifest id. Changing id creates a different app with fresh registrations, so settle on an id before you add integration members.

Support at a glance

Member Chrome / Edge desktop Chrome Android ChromeOS Safari (macOS, iOS) Firefox Deeper coverage
protocol_handlers ✅ 96 ❌ ⚠️ ❌ ❌ Protocol Handlers & Launch Handling
file_handlers ✅ 102 ❌ ✅ ❌ ❌ File Handling
share_target ⚠️ ✅ 76 (GET since 71) ✅ 89 ❌ ❌ Web Share Target
launch_handler ✅ 110 ⚠️ ✅ ❌ ❌ Protocol Handlers & Launch Handling
handle_links ❌ never shipped ❌ ❌ ❌ ❌ This page
scope_extensions ✅ 139 ❌ ✅ 139 ❌ ❌ This page
note_taking ❌ (parsed only) ❌ ✅ ❌ ❌ This page
tab_strip / tabbed 🧪 flag ❌ ✅ ❌ ❌ Display Modes
edge_side_panel ⚠️ Edge only, deprecated ❌ ❌ ❌ ❌ This page
widgets ⚠️ Edge on Windows 11 only ❌ ❌ ❌ ❌ This page
related_applications ⚠️ ✅ ✅ ❌ ❌ Detecting Installed Apps
prefer_related_applications ⚠️ ✅ ✅ ❌ ❌ This page
iarc_rating_id ❌ ❌ ❌ ❌ ❌ This page

Support data as of September 2026. Version numbers are Chrome versions from MDN's browser compatibility data and chromestatus.com; Edge and Opera follow the same Chromium milestone. Check caniuse for live data. Partial support (⚠️) is explained in each member's section.

protocol_handlers: registering URL schemes

protocol_handlers is the declarative, install-time counterpart of navigator.registerProtocolHandler(). It lets an installed PWA become an OS-level handler for schemes such as mailto:, magnet: or your own web+ scheme, so a click on web+music:track/42 in any app (a native mail client, a chat app, a PDF) launches your PWA.

Syntax and processing rules

manifest.webmanifest
{
  "id": "/",
  "start_url": "/",
  "scope": "/",
  "protocol_handlers": [
    { "protocol": "web+music", "url": "/open?uri=%s" },
    { "protocol": "magnet", "url": "/downloads/new?link=%s" },
    { "protocol": "mailto", "url": "/compose?to=%s" }
  ]
}

Each entry is an object with two required string members. Chromium processes them as follows:

Rule What happens when violated
protocol_handlers must be an array Whole member ignored: "property 'protocol_handlers' ignored, type array expected."
Each entry must be an object Entry ignored
protocol must be present, a string, and either a safelisted scheme or web+ followed by one or more ASCII letters Entry ignored: "required property 'protocol' is invalid."
url must be present and resolve (against the manifest URL) to a URL within scope Entry ignored: "should be within scope of the manifest."
url must contain the %s token Entry ignored (the same SyntaxError condition that registerProtocolHandler() throws)
url must be http: or https: and potentially trustworthy Entry ignored

The scheme comparison is ASCII case-insensitive and the scheme is lowercased, so "Web+Music" registers web+music. web+ must be followed by letters only: web+my-app and web+app2 are invalid.

Allowed schemes

The HTML Standard defines the safelisted schemes that sites may claim. Current Chromium accepts that whole list (ftp, ftps and sftp only since Chrome 117), plus a set of Chromium-only schemes, most of them decentralized-web schemes added in Chrome 86. payto (RFC 8905) exists in the code behind a feature that is disabled by default:

Group Schemes
HTML safelist bitcoin, ftp, ftps, geo, im, irc, ircs, magnet, mailto, matrix, mms, news, nntp, openpgp4fpr, sftp, sip, sms, smsto, ssh, tel, urn, webcal, wtai, xmpp
Chromium-only additions cabal, dat, did, doi, dweb, ethereum, hyper, ipfs, ipns, ssb
Custom schemes web+ followed by one or more ASCII letters, for example web+music, web+todo
Rejected http, https, file, chrome, javascript, data, any unprefixed custom scheme such as myapp

Isolated Web Apps run with a relaxed security level: they can claim arbitrary schemes made of ASCII letters and dashes (minimum two characters) except a blocklist (http, https, file, ms-settings, chrome, chrome-extension, isolated-app), and their url must contain exactly one %s, placed in the query string. Normal PWAs cannot do this.

How %s is replaced

When the OS hands a URL to the browser, the browser applies the HTML Standard's handler algorithm: it strips any username and password from the invoked URL, serializes it, percent-encodes it with the component percent-encode set, and substitutes the result for the first %s in your handler URL. The component set encodes :, /, ?, #, +, &, =, @ and more, so the whole URL survives as a single query value:

Invoked URL Handler url Resulting launch URL
web+music:track/42?t=13 /open?uri=%s /open?uri=web%2Bmusic%3Atrack%2F42%3Ft%3D13
mailto:[email protected]?subject=Hi /compose?to=%s /compose?to=mailto%3Aada%40example.com%3Fsubject%3DHi
magnet:?xt=urn:btih:abc /downloads/new?link=%s /downloads/new?link=magnet%3A%3Fxt%3Durn%3Abtih%3Aabc

Because the whole invoked URL arrives, including the scheme, your handler must parse it again. URLSearchParams decodes the value for you; then parse it with new URL(), which accepts any syntactically valid scheme:

src/protocol-router.js
// Handles launches produced by manifest "protocol_handlers".
// The handler page is /open?uri=%s, so the invoked URL arrives
// percent-encoded in the "uri" query parameter.

const ALLOWED_SCHEMES = new Set(["web+music:", "magnet:", "mailto:"]);

export function parseProtocolLaunch(locationHref = window.location.href) {
  const page = new URL(locationHref);
  const raw = page.searchParams.get("uri"); // already percent-decoded
  if (!raw) return null;

  let invoked;
  try {
    invoked = new URL(raw);
  } catch {
    // Malformed input from another app: never trust it.
    console.warn("Ignoring malformed protocol launch", raw);
    return null;
  }

  if (!ALLOWED_SCHEMES.has(invoked.protocol)) return null;

  switch (invoked.protocol) {
    case "web+music:": {
      // "web+music:track/42?t=13" has an opaque path "track/42".
      const [kind, id] = invoked.pathname.split("/");
      if (kind !== "track" || !/^\d+$/.test(id)) return null;
      const t = Number(invoked.searchParams.get("t") ?? 0);
      return { view: "track", id, startAt: Number.isFinite(t) ? t : 0 };
    }
    case "magnet:":
      return { view: "download", magnet: invoked.href };
    case "mailto:":
      // mailto addresses live in the path; headers in the query.
      return {
        view: "compose",
        to: decodeURIComponent(invoked.pathname),
        subject: invoked.searchParams.get("subject") ?? "",
      };
    default:
      return null;
  }
}

Protocol launches carry untrusted input

Any application on the device, and any web page the user clicks through, can construct a web+music: or mailto: URL with arbitrary content and hand it to your app. Validate identifiers against strict patterns, never inject the value into HTML or build URLs from it without encoding, and never perform a state-changing action (sending, deleting, paying, granting access) straight from a launch: show the user what is about to happen and require a click inside your UI.

Chromium registers the schemes with the OS when the app is installed and whenever a manifest update adds or removes handlers. On Windows it writes per-app entries into the registry through Chrome's shell-integration layer; on Linux, scheme associations are x-scheme-handler/<scheme> pseudo-MIME types in the app's .desktop entry. If several apps (native or web) claim the same scheme, the OS picker decides, not the browser. ChromeOS is the exception, and the reason for the ⚠️ in the support table: ChromeOS routes schemes through its own app-intent system rather than a desktop-style registry, Chromium's integration there has focused on Isolated Web Apps, and ordinary PWAs should not count on other ChromeOS or Android (ARC) apps launching them through a custom scheme. Test the exact flow on a ChromeOS device before promising it.

The first time the PWA is launched through a scheme, Chromium shows a permission dialog naming the app and its origin and asking whether it may open links of that type, with a Remember my choice checkbox. If the user chooses Disallow with Remember my choice, Chromium unregisters the handler from the OS. The only other way to unregister is to uninstall the app or ship a manifest update without the entry. Registered handlers are never exposed to web content, so they cannot be used for fingerprinting.

Protocol launches only fire for top-level, user-initiated navigations: a web+music: URL used as an iframe src or navigated programmatically without a user gesture will not open the app.

Manifest registration compared with registerProtocolHandler()

Aspect protocol_handlers (manifest) navigator.registerProtocolHandler()
When it takes effect At install and on manifest update When called, after a browser prompt
Scope of the handler OS-wide: other native apps can launch your PWA Browser-only: links clicked inside the browser
Opens in The installed app window (subject to launch_handler) A browser tab
Handler URL Must be within the manifest scope Must be same origin as the calling page
Lifetime Tied to the installation Until the user removes it in browser settings
Engines Chromium desktop (Chrome 96+) Chromium desktop and Firefox (desktop and Android); not Safari, not Chrome Android

You can use both: registerProtocolHandler() for users who never install, and the manifest for installed users. The details of both APIs, including the browser settings UI and testing tricks, are on Protocol Handlers & Launch Handling.

file_handlers: becoming an "Open with" target

file_handlers registers an installed PWA with the OS as an application that can open specific file types. The user sees it in the file manager's Open with menu, can make it the default app for a type, and double-clicking such a file launches the PWA with a FileSystemFileHandle delivered through window.launchQueue.

Member-by-member reference

manifest.webmanifest
{
  "file_handlers": [
    {
      "action": "/open-csv",
      "accept": {
        "text/csv": [".csv"],
        "text/tab-separated-values": [".tsv"]
      }
    },
    {
      "action": "/open-graph",
      "name": "Grafr graph",
      "accept": {
        "application/vnd.grafr-graph+json": [".grafr", ".graf"]
      },
      "icons": [
        { "src": "/icons/grafr-file-256.png", "sizes": "256x256", "type": "image/png" }
      ],
      "launch_type": "multiple-clients"
    }
  ]
}
Member Type Required Default Notes
action URL string Yes none Resolved against the manifest URL; must be within scope, or the whole handler is dropped. This is the page the launch navigates to (or the targetURL of the LaunchParams).
accept Object mapping MIME type to extensions Yes none Must be a non-empty object with at least one valid entry after filtering, or the handler is dropped.
name String No Browser/OS default Human-readable file type name for OS surfaces, typically only shown when the app is the default handler. User agents may ignore it for privacy reasons.
icons Array of image resources No App icon File-type icons for the OS. Documented by Chrome, but Chromium has never applied them by default on any OS; expect the app icon or a generic icon.
launch_type "single-client" or "multiple-clients" No "single-client" Whether a multi-file open produces one launch with all files, or one launch per file.

Validation of accept: spec rules and Chromium's limits

The spec processes each accept entry independently and skips (rather than rejects) invalid ones:

  • The key must parse as a MIME type whose top-level type is registered with IANA (text, image, audio, video, application, font, model, multipart, message, and so on). image/* style wildcards are allowed as the subtype. (Chromium additionally refuses FreeDesktop x-scheme-handler/* pseudo-types here, because on Linux they denote protocol handlers.)
  • The value must be a non-empty list of strings, each starting with .. The spec also caps extensions at 16 characters.
  • Both MIME types and extensions are mandatory because operating systems disagree on which one they use: Windows only uses extensions and ignores MIME types, while Linux registers both with xdg-mime.

Chromium's parser is slightly different from the spec text, and the differences matter when you test:

Behavior Spec Chromium
Extension value given as a single string ("text/csv": ".csv") Invalid (list required) Accepted
Extension "." alone Invalid Rejected: must contain at least one character after the dot
Extension longer than 16 characters Invalid Not checked
Extensions containing control or format characters Not mentioned Rejected
Total extensions across all handlers "May truncate" Hard limit of 300; extensions past the limit are dropped with "too many total file extensions"
launch_type given as an array Not mentioned Accepted; first recognized value wins

launch_type and platform behavior

With "single-client", opening five selected .csv files in a file manager produces one launch whose LaunchParams.files contains five handles. With "multiple-clients", it produces five launches with one handle each, which, with the default launch_handler on desktop, means five windows. The spec forbids mixing files from different handlers in one launch, so selecting a .csv and a .grafr file always produces at least two launches.

The spec calls out a platform limitation: Windows never launches an application with several files at once. It starts the handler once per file, so on Windows every launch is effectively "multiple-clients" regardless of the manifest. If your app must merge multi-file opens into one window on every OS, set launch_handler.client_mode to focus-existing or navigate-existing and merge incoming files in your launchQueue consumer.

Consuming files with window.launchQueue

Registering file types only launches the app; your page must read the files. They arrive on the main thread, not in the service worker, as FileSystemFileHandle objects. Chromium grants read access only: calling createWritable() on a launched handle triggers the File System Access write-permission prompt the first time.

src/file-launch.js
// Receives files opened through manifest "file_handlers".
// Requires a secure context and an installed app in Chromium 102+.

const DB_NAME = "launched-files";
const STORE = "handles";

function openDb() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => req.result.createObjectStore(STORE);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

// FileSystemHandle objects are structured-cloneable, so they can be
// stored in IndexedDB and reopened after a reload or restart.
async function rememberHandle(key, handle) {
  const db = await openDb();
  await new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, "readwrite");
    tx.objectStore(STORE).put(handle, key);
    tx.oncomplete = resolve;
    tx.onerror = () => reject(tx.error);
  });
  db.close();
}

export async function ensureWritable(handle) {
  const opts = { mode: "readwrite" };
  if ((await handle.queryPermission(opts)) === "granted") return true;
  // requestPermission() needs transient user activation, so call this
  // from a click handler (for example the "Save" button), not on launch.
  return (await handle.requestPermission(opts)) === "granted";
}

// Processes the files of one launch. Exported separately so that an app
// with several launch types can call it from its single consumer.
export async function handleFileLaunch(launchParams, openDocument) {
  for (const handle of launchParams.files) {
    if (handle.kind !== "file") continue; // Directory handles are possible in theory.
    try {
      const file = await handle.getFile(); // Read access is pre-granted.
      await rememberHandle(`recent:${file.name}:${file.lastModified}`, handle);
      await openDocument({ handle, file });
    } catch (err) {
      // NotFoundError: file moved or deleted between launch and read.
      // NotAllowedError: file handling revoked in app settings.
      console.error("Could not open launched file", handle.name, err);
    }
  }
}

// For apps whose only launch type is file handling.
export function initFileLaunches(openDocument) {
  if (!("launchQueue" in window) || !("files" in LaunchParams.prototype)) {
    return false; // No File Handling: fall back to <input type="file">.
  }
  window.launchQueue.setConsumer((launchParams) => {
    if (launchParams.files?.length) {
      return handleFileLaunch(launchParams, openDocument);
    }
  });
  return true;
}

The consumer can be called several times during a page's lifetime (for example when a second file is opened into an existing window with focus-existing), so openDocument must cope with a document already being open. If the app also handles URL launches, call setConsumer() once and dispatch inside it, as shown in the launch consumer below.

Reloads no longer replay the last launch

Chromium has long re-delivered the last LaunchParams, including its file handles, to a newly registered consumer after the user reloads an app window. That behavior was never specified: it was a stop-gap from before file handles could be stored in IndexedDB. The chromestatus entry Stop re-queueing LaunchParams on reload removed it in Chrome 146 on desktop; a reload is now an ordinary navigation. If your app relies on reload to reopen the current file, persist the handle in IndexedDB as shown above and restore it yourself; that works on every Chromium version.

OS registration, permissions and user control

Chromium registers file handlers at install time and updates them when the file_handlers member changes:

  • Windows: file associations under per-app ProgIds in the current user's registry hive, with a small app-specific launcher executable; each handler gets its own ProgId so its name can appear separately in Open with.
  • macOS: document types in the Info.plist of the app shim bundle that Chromium generates for each installed web app.
  • Linux: an XML MIME-info file installed with xdg-mime install, the MIME types listed in the app's .desktop file, then update-desktop-database so file managers such as Nautilus pick up the association.
  • ChromeOS: the app appears in the Files app's Open with menu.

How installation itself differs across these systems is covered on Desktop Platforms.

The OS may already have a default app for .csv or .png; installing your PWA does not change the default. Users choose it once through Open with and may make it the default. When the PWA is launched with a file for the first time, Chromium asks the user to allow file handling for that app; the decision persists, the prompt resets when the file_handlers member changes, and users can revoke it with the Include this app as an option when opening files toggle on chrome://app-settings/<app-id>. Do not register common types (.json, .txt, .png) unless your app is genuinely a general-purpose editor for them; prefer your own extension for your own format.

The full API, including drag-and-drop and saving back to the same file, is on File Handling and File System Access.

share_target: receiving shares from other apps

share_target registers the installed PWA in the operating system's share sheet. When the user shares text, a link or files to it, the browser launches the app by navigating (GET) or submitting a form (POST) to the action URL.

Syntax

manifest.webmanifest
{
  "share_target": {
    "action": "/share-target/",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "title": "name",
      "text": "description",
      "url": "link",
      "files": [
        {
          "name": "media",
          "accept": ["image/*", "video/mp4", ".heic"]
        },
        {
          "name": "docs",
          "accept": ["application/pdf", ".pdf"]
        }
      ]
    }
  }
}
Member Type Default Rules (Chromium parser)
action URL string none Required; within scope, or the whole share_target is ignored.
method "GET" or "POST" "GET" (with a console warning if missing) Case-insensitive. Any other string invalidates the whole member.
enctype "application/x-www-form-urlencoded" or "multipart/form-data" "application/x-www-form-urlencoded" Case-insensitive. multipart/form-data with GET invalidates the member.
params Object none Required; missing or non-object invalidates the member.
params.title, params.text, params.url String none The names of the form fields (or query parameters) that carry the shared title, text and URL.
params.files Object or array of {name, accept} none Only allowed with POST + multipart/form-data. A file entry without a non-empty name or with an empty accept is dropped.
files[].accept String or array of strings none MIME types (image/*, */*) or extensions (.heic). One invalid MIME string invalidates the entire share_target.

Receiving the share

With GET, the shared values become query parameters on the action URL (for example /share-target/?name=…&description=…&link=…), which you read like any other query string. With POST, the browser sends a real multipart/form-data request, which your service worker should intercept so shares work offline and do not hit your server. Answer with a 303 redirect so a reload does not resubmit the form:

sw.js
self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (event.request.method !== "POST" || url.pathname !== "/share-target/") return;

  event.respondWith((async () => {
    try {
      const form = await event.request.formData();
      const shared = {
        title: form.get("name") ?? "",
        // Android puts shared URLs in the text field, not in "url".
        text: form.get("description") ?? "",
        url: form.get("link") ?? "",
        files: [...form.getAll("media"), ...form.getAll("docs")],
      };
      const id = await saveShareToIndexedDB(shared); // your persistence layer
      return Response.redirect(`/share-target/received?id=${encodeURIComponent(id)}`, 303);
    } catch (err) {
      console.error("Share target failed", err);
      return Response.redirect("/share-target/received?error=1", 303);
    }
  })());
});

Two platform behaviors catch everyone. On Android the system share intent has no URL field, so shared links usually arrive in text (occasionally in title), and your code must extract URLs from text. And the app must be installed: on Android that means a WebAPK minted by Chrome (Chrome 71 added GET targets, Chrome 76 POST and files); on ChromeOS the app appears in the sharesheet from Chrome 89. Chrome on Windows, macOS and Linux does not register share targets with the OS; Microsoft documents share-target registration for PWAs installed with Edge on Windows. Service worker parsing, IndexedDB hand-off and the full edge-case list are on Web Share Target, and the sending side is on Web Share API.

launch_handler and window.launchQueue

launch_handler controls what happens when something launches an already-installed app: whether the browser opens a new window, reuses an existing one and navigates it, or just focuses an existing one and hands it the launch URL to handle in script. Launches include clicking the app icon, app shortcuts, file handling, protocol handling and, on desktop, links that the browser captures into the app (on by default on Windows, macOS and Linux since Chrome 140).

client_mode values

manifest.webmanifest
{
  "launch_handler": {
    "client_mode": ["focus-existing", "navigate-existing", "auto"]
  }
}
client_mode An app window is already open No app window is open LaunchParams enqueued
auto (default) User agent decides: navigate-new on desktop, navigate-existing on Android New window Yes
navigate-new Opens a new app window (a new tab in the tabbed display mode) at the target URL New window Yes, in the new document
navigate-existing Focuses the most recently used app window and navigates it to the target URL New window Yes, in the newly loaded document
focus-existing Focuses the most recently used app window without navigating; the target URL is delivered only through launchQueue New window at the target URL Yes, in the existing document

focus-existing is the mode that makes launches feel native: a music player keeps playing when you open a shortcut, a chat app switches conversation without reloading, an editor opens a second file in a new tab of its own UI. The cost is that you must implement a launchQueue consumer, because the browser does not navigate. Without one, the launch just focuses the window and the user sees nothing change.

How client_mode is parsed: strings, arrays and fallbacks

client_mode accepts a string or an array of strings. Chromium and the spec process it the same way:

  1. If launch_handler is missing or not an object, it is ignored (default behavior applies).
  2. If client_mode is a string, it is used if the browser recognizes it; otherwise the result is auto, with the warning "client_mode value '…' ignored, unknown value."
  3. If client_mode is an array, each entry is tried in order; non-strings and unknown values are skipped with a warning, and the first recognized value wins.
  4. If nothing is recognized, the result is auto.

Arrays exist for forward compatibility: when a future value such as a hypothetical "focus-existing-tab" is added, you can list it first and older browsers will fall through to a value they understand. Chromium's parser reads only client_mode. Names from the origin-trial era, such as the route_to field (renamed to client_mode during the trial) and values like existing-client-retain, are ignored, so manifests copied from 2021-2022 articles silently fall back to auto.

The LaunchQueue and LaunchParams interfaces

Web App Launch Handler API (WICG)
[Exposed=Window] interface LaunchParams {
  readonly attribute DOMString? targetURL;
  readonly attribute FrozenArray<FileSystemHandle> files;
};

callback LaunchConsumer = any (LaunchParams params);

partial interface Window {
  readonly attribute LaunchQueue launchQueue;
};

[Exposed=Window] interface LaunchQueue {
  undefined setConsumer(LaunchConsumer consumer);
};

The queue exists to solve a race: the browser decides on a launch before your script runs, so it buffers LaunchParams in the document's queue until a consumer is set. setConsumer() then synchronously drains every buffered entry into the new consumer, and every later launch into that document calls the consumer directly. Calling setConsumer() again replaces the consumer; buffered entries that were already consumed are not replayed.

sequenceDiagram
    participant OS as OS or browser UI
    participant B as Browser process
    participant W as App window
    participant Q as window.launchQueue
    participant App as Your consumer
    OS->>B: Launch app (shortcut, file, protocol, captured link)
    B->>B: Resolve client_mode (focus-existing)
    alt An in-scope app window exists
        B->>W: Focus existing window, no navigation
        B->>Q: Enqueue LaunchParams(targetURL, files)
        Q->>App: consumer(params), immediately if set
    else No app window
        B->>W: Create new window and navigate to targetURL
        B->>Q: Enqueue LaunchParams after navigation commits
        Note over Q,App: Buffered until setConsumer() runs
        App->>Q: setConsumer(fn)
        Q->>App: fn(params) for each buffered entry
    end

Chromium-specific details that affect real apps:

  • window.launchQueue exists in every window in Chromium, including ordinary browser tabs, so 'launchQueue' in window tells you the API exists, not that you are in an installed app.
  • Since a change that landed at the end of 2022, Chromium enqueues LaunchParams for every launch into an app window, whether or not the manifest declares launch_handler. A consumer is therefore also a reliable way to see the URL of the launch that created the window.
  • The spec forbids enqueueing LaunchParams into a document whose current URL is out of scope, in both directions: launch URLs must be in scope, and a focus-existing launch must not hand its URL to a window that has wandered to another origin. In that case the launch falls back to a navigation (Chromium's Android implementation, for example, navigates the existing tab to the target URL).
  • For file launches routed into an existing window, targetURL was null before Chrome 146; since Chrome 146 it is populated. Older versions are still in use, so do not depend on targetURL for file launches: branch on files.length first.
  • Since Chrome 146 on desktop, a reload is an ordinary navigation that does not re-deliver the previous LaunchParams. Write code that works on older versions too: ignore duplicate deliveries of the same file and restore state from IndexedDB, not from a replayed launch.

A production launch consumer

This module routes URL launches (shortcuts, protocol handlers, captured links) and file launches from one consumer, and survives being invoked many times in a long-lived window.

src/launch.js
// One consumer for every launch type. Designed for client_mode
// "focus-existing": the browser does not navigate, so we route in-app.

import { parseProtocolLaunch } from "./protocol-router.js";
import { handleFileLaunch } from "./file-launch.js";

const SCOPE = new URL("/", window.location.origin);

function inScope(url) {
  return url.origin === SCOPE.origin && url.pathname.startsWith(SCOPE.pathname);
}

async function routeUrlLaunch(targetURL, router) {
  const url = new URL(targetURL);
  if (!inScope(url)) return; // Defensive: the browser already checks scope.

  // Protocol handler launches land on /open?uri=...
  if (url.pathname === "/open") {
    const route = parseProtocolLaunch(url.href);
    if (route) return router.show(route);
    return router.resolve(new URL("/", SCOPE)); // Invalid input: go home.
  }

  // Shortcuts and captured links: hand the path to the SPA router.
  // pushState instead of location.assign() keeps in-memory state
  // (audio playback, unsaved drafts, open sockets) alive.
  if (url.href !== window.location.href) {
    history.pushState({ launched: true }, "", url);
  }
  return router.resolve(url);
}

export function installLaunchHandling({ router, openDocument }) {
  if (!("launchQueue" in window)) return false; // Safari, Firefox.

  // Call setConsumer() exactly once: a second call REPLACES this consumer.
  window.launchQueue.setConsumer(async (params) => {
    try {
      if (params.files && params.files.length > 0) {
        await handleFileLaunch(params, openDocument); // (1)!
        return;
      }
      if (params.targetURL) {
        await routeUrlLaunch(params.targetURL, router);
      }
    } catch (err) {
      console.error("Launch handling failed", err);
      router.showError?.("This link could not be opened.");
    }
  });
  return true;
}
  1. File launches are checked first because their targetURL may be null (existing-window file launches) or equal to the handler's action URL, which is not a route you want to navigate to on its own.

Call installLaunchHandling() as early as possible in your boot sequence, but only after the router can render: buffered LaunchParams are delivered synchronously from inside setConsumer().

Android, ChromeOS and the tabbed display mode

  • Desktop (Windows, macOS, Linux, ChromeOS): full support since Chrome 110. With navigation capturing, links to an installed app's scope that would open a new tab or window (links clicked in other apps, target="_blank" links in the browser) open in the app by default on Windows, macOS and Linux: since Chrome 138 for apps that declare launch_handler.client_mode, since Chrome 140 for all apps. client_mode decides which window receives them. On ChromeOS, capturing is available but off by default per app.
  • Tabbed display mode: with "display_override": ["tabbed"], navigate-new opens a new tab in the existing app window instead of a new window.
  • Android: every installed web app is a single Android task, so auto resolves to navigate-existing. MDN's compatibility data lists launch_handler for Chrome on Android from version 110; recent Chromium versions also route client modes and LaunchParams (including file handles passed by Android intents) for Trusted Web Activities, and androidx.browser 1.9.0 added the LaunchHandlerClientMode constants for TWA wrappers. Test on real devices before relying on focus-existing there; the Android installation model (WebAPKs versus TWAs) is explained on Android.

The deeper walkthrough of launch handling, including navigation capturing flows and testing, lives on Protocol Handlers & Launch Handling.

Experimental proposal, never shipped

handle_links exists only in an archived explainer. No browser implements it, and none is expected to.

handle_links was a proposed top-level member with the values "auto" (default), "preferred" and "not-preferred", letting an app hint whether in-scope links clicked elsewhere should open in the installed app. It is not implemented: its chromestatus entry ("Web app handle links") has been "On hold" for years, and the WICG pwa-url-handler repository that hosted its explainer was archived in March 2026. Browsers ignore the member, so shipping it is harmless but pointless.

Link capturing ended up as browser and user policy plus launch_handler, after three manifest designs were tried and dropped:

Proposal Manifest shape Outcome
Declarative Link Capturing "capture_links": "none" \| "new-client" \| "existing-client-navigate" Origin trial up to Chrome 97 (expired March 2022); redesigned as launch_handler plus user opt-in
PWAs as URL Handlers "url_handlers": [{ "origin": "https://*.example.com" }] Experimental on desktop; removed from Chromium; split into scope_extensions and handle_links
Link handling preference "handle_links": "preferred" Never shipped; removed; explainer archived in 2026
Navigation capturing (current) No member: default browser behavior, user toggle, and launch_handler.client_mode Default on Windows, macOS and Linux (Chrome 138 with launch_handler.client_mode, Chrome 140 for all apps; rolled out in stages from Chrome 134); ChromeOS: available, off by default per app

Under navigation capturing, Chrome's rule is that a navigation is capturable when it "creates a new frame and does not open in an auxiliary browsing context". In practice, a user action that opens a URL inside an installed app's scope in a new top-level browsing context without an opener (a link clicked in another application, or a target="_blank" link in a browser tab, which gets noopener by default) launches the app. Ordinary same-tab navigations and window.open() popups that keep an opener (auxiliary browsing contexts) are not captured. Users opt out per app under Opening supported links on chrome://app-settings/<app-id> by choosing Open in Chrome browser. The app's client_mode then picks the window. Microsoft Edge documents an equivalent automatic behavior for PWAs installed from the Microsoft Store, or installed with Edge when Edge is the default browser, with an opt-out on edge://apps. See Protocol Handlers & Launch Handling for the full navigation-capturing decision tree.

scope_extensions: one app across several origins

A manifest's scope can only cover one origin. Products that span example.com, example.co.uk and help.example.com therefore show an "out of scope" bar with the foreign URL whenever the user crosses an origin inside the app window, and links to the other origins are not captured into the app. scope_extensions lets the app claim additional origins, provided each of those origins confirms the association with a well-known file.

Syntax (Chrome 139 and later)

https://example.com/manifest.webmanifest
{
  "id": "https://example.com/app",
  "name": "Example",
  "start_url": "/app/",
  "scope": "/app/",
  "display": "standalone",
  "scope_extensions": [
    { "type": "origin", "origin": "https://example.co.uk" },
    { "type": "origin", "origin": "https://help.example.com" }
  ]
}
https://example.co.uk/.well-known/web-app-origin-association
{
  "https://example.com/app": {
    "scope": "/app/"
  }
}
https://help.example.com/.well-known/web-app-origin-association
{
  "https://example.com/app": {}
}

With these three files, the app's extended navigation scope is https://example.com/app/, https://example.co.uk/app/ and all of https://help.example.com/.

Processing and validation rules

Manifest side (spec plus Chromium's limits):

Rule Detail
Entry shape Object with string type and string origin; entries missing either key are ignored.
type Only "origin" is defined. Chromium has an experimental "site"-style form behind a disabled flag; do not use it.
origin Must parse as an https: origin. Paths are ignored (only the origin is kept). The host must end in a known public suffix and be longer than it, so bare suffixes (com, co.uk) and localhost fail; IP addresses such as 127.0.0.1 pass, which matters for local testing.
Wildcards "https://*.example.com" (origin-trial syntax) is not supported by shipping Chrome; the subdomain wildcard lives behind the same disabled flag. List each origin.
Maximum entries Chromium parses the first 10 entries and ignores the rest with a warning.
Maximum length origin strings longer than 2,000 characters are ignored.

Association file side:

  1. The browser fetches https://<origin>/.well-known/web-app-origin-association for each listed origin. A failed fetch or invalid JSON means that origin is not added.
  2. The file is a JSON object whose keys are manifest ids (the fully resolved id of the app, such as https://example.com/app, not the manifest URL and not the origin alone).
  3. The value for your id must be an object. Its optional "scope" is resolved against the associated origin and defaults to "/", meaning the whole origin. A scope that resolves to another origin is ignored.
  4. The same file can list several apps, and the same key can carry "allow_migration": true, which the migrate_from handshake uses when an app moves between same-site origins (migrate_from and migrate_to reached desktop Chromium in Chrome 150 according to the Chrome release notes and chromestatus, while MDN's data lists 149; see App Identity & Updates).

Migrate from the origin-trial syntax

Early documentation (including Chrome's 2023 article) shows { "origin": "https://*.example.com" } in the manifest and { "web_apps": [{ "web_app_identity": "https://example.com" }] } in the association file. Neither shape is accepted by current Chrome: manifest entries without type are dropped, and association files must be keyed by the manifest id. Update both files together.

What the extension changes, and where

Scope extensions shipped in Chrome 139 on Windows, macOS, Linux and ChromeOS: the Intent to Ship targeted Chrome 138, the feature then appeared in the Chrome 139 release notes, and chromestatus lists 139 as the desktop shipping milestone (MDN's compatibility data still says 138). The Intent to Ship explicitly excluded mobile platforms, "where app identity is implemented differently"; Chrome on Android parses the member but does not apply it. With validated extensions:

  • Navigations inside the app window to an extended origin no longer show the out-of-scope bar, so the product feels like one app. The spec asks browsers to keep some indication that the origin changed, distinct from the out-of-scope UI.
  • Links to extended origins become eligible for capturing into the app, because Chromium's app-scope matching includes validated extensions; the same navigation-capturing rules and user settings apply as for in-scope links.
  • Nothing else changes: storage, service workers, cookies and permissions remain per origin. help.example.com still needs its own service worker for offline support, and handler URLs in protocol_handlers or file_handlers must still be on the manifest's origin.

note_taking: ChromeOS note-taking apps

note_taking marks the app as a note-taking app and gives the OS a URL for "new note" actions.

manifest.webmanifest
{
  "note_taking": {
    "new_note_url": "/notes/new?source=os"
  }
}

note_taking must be an object (otherwise it is ignored); new_note_url is resolved against the manifest URL and dropped if it is not within scope. The spec calls it advisory: the OS may ignore it or offer it as one choice among several.

Only ChromeOS acts on it. An installed app with note_taking appears in the list of note-taking apps under Settings > Device > Stylus, and once the user selects it there, the stylus palette's Create note action launches the app at new_note_url (the spec defines this as an ordinary app launch whose target URL is new_note_url). The member is parsed on all Chromium platforms (MDN lists Chrome 95; the Intent to Ship targeted Chrome 93) but has no effect outside ChromeOS. Make the new_note_url page open instantly into an empty, focused editor, and make it work offline, because a stylus user expects to write immediately. The related lock_screen member (a separate start_url for lock-screen note taking) is parsed by Chromium only behind a disabled flag.

tab_strip and the tabbed display mode

Tabbed application mode gives a standalone app window its own tab strip, so a document-centric PWA can keep several documents open in one window with real browser-grade tab isolation. You opt in through display_override, and optionally tune the tab strip with tab_strip:

manifest.webmanifest
{
  "start_url": "/",
  "display": "standalone",
  "display_override": ["tabbed"],
  "tab_strip": {
    "home_tab": {
      "scope_patterns": [
        { "pathname": "/" },
        { "pathname": "/index.html" },
        { "pathname": "/dashboard/*" }
      ]
    },
    "new_tab_button": {
      "url": "/documents/new"
    }
  }
}
Member Meaning Default and processing
tab_strip.home_tab Enables a pinned home tab that acts as the app's top-level menu. The home tab never navigates away: links from it to URLs outside the home tab scope open in a new app tab, and navigations in other tabs to URLs inside the home tab scope are redirected to the home tab, which gets focus. No home tab when absent. Chromium also parses an icons array (or "auto") for the home tab's icon.
home_tab.scope_patterns List of URL Pattern inputs, resolved against the manifest URL, defining which URLs belong to the home tab. The start_url (ignoring fragments) is always in the home tab scope. Empty list: only start_url is in the home tab scope.
tab_strip.new_tab_button.url URL loaded by the "new tab" button. Must be within the manifest scope. Defaults to start_url. The button is only shown if this URL is outside the home tab scope, so an app with a home tab and no explicit new_tab_button.url has no new tab button.

Detect the mode with @media (display-mode: tabbed) or matchMedia("(display-mode: tabbed)"). With launch_handler.client_mode set to navigate-new, launches open a new tab in the existing window rather than a new window.

Tabbed mode shipped only on ChromeOS. On Windows, macOS and Linux, Chromium treats "tabbed" as an unknown display mode unless the user enables chrome://flags/#enable-desktop-pwas-tab-strip (and #enable-desktop-pwas-tab-strip-customizations for tab_strip), so display_override falls through to your next entry and then to display. Always follow "tabbed" with a mode you can live with:

manifest.webmanifest
{
  "display_override": ["tabbed", "window-controls-overlay", "standalone"],
  "display": "standalone"
}

The window-controls-overlay mode in that fallback chain is covered on Window Controls Overlay, and the full display_override algorithm on Display Modes.

edge_side_panel: Microsoft Edge sidebar apps

Deprecated

Microsoft's documentation carries the notice "Update July 2026: This feature is being deprecated; it will soon no longer be supported." Do not start new work on sidebar integration, and treat existing integrations as temporary.

edge_side_panel is an Edge-only member that signals that the app may be pinned to the Edge sidebar, a panel docked next to the user's tabs:

manifest.webmanifest
{
  "name": "PWAmp music player",
  "short_name": "PWAmp",
  "description": "A skinnable music player",
  "display": "standalone",
  "edge_side_panel": {
    "preferred_width": 480
  }
}
  • An empty object ("edge_side_panel": {}) is enough to opt in. The sidebar's default minimum width is 376 CSS pixels, and users can resize it.
  • preferred_width (a number of CSS pixels) makes Edge open the sidebar at that width. Users can still resize it down to the 376-pixel minimum, so your layout must work at 376 pixels regardless.
  • Setting display to browser (or omitting it) produces a sidebar-only app that can be pinned to the sidebar but not installed as a standalone app.
  • Inside the sidebar, Edge adds an Edge Side Panel brand to User-Agent Client Hints and a matching token to the User-Agent string, and sends the desktop hint Sec-CH-UA-Mobile: ?0:
src/sidebar-detect.js
export function isEdgeSidebar() {
  const brands = navigator.userAgentData?.brands ?? [];
  if (brands.some((b) => b.brand === "Edge Side Panel")) return true;
  // Fallback for contexts without UA-CH (not recommended as primary signal).
  return navigator.userAgent.includes("Edge Side Panel");
}

The member is ignored by every other browser.

widgets: Windows 11 Widgets Board (Edge)

Experimental

PWA widgets are a Microsoft Edge feature for the Windows 11 Widgets Board, defined in Edge documentation and explainers rather than a cross-browser spec, and Microsoft's page for it has not been substantively revised since 2023. No other browser or OS implements widgets or the service worker widgets API. Verify the current state in Edge before investing.

The widgets member is an array of widget definitions. Widgets are not HTML: Windows 11 renders them from Adaptive Cards templates, filled with JSON data that your service worker supplies.

manifest.webmanifest
{
  "widgets": [
    {
      "name": "PWAmp mini player",
      "description": "Control the PWAmp music player",
      "tag": "pwamp",
      "template": "pwamp-template",
      "ms_ac_template": "/widgets/mini-player-template.json",
      "data": "/widgets/mini-player-data.json",
      "type": "application/json",
      "screenshots": [
        { "src": "/widgets/screenshot.png", "sizes": "600x400", "label": "The mini player widget" }
      ],
      "icons": [{ "src": "/icons/icon-16.png", "sizes": "16x16" }],
      "auth": false,
      "update": 86400
    }
  ]
}
Field Required Meaning
name Yes Widget title shown to users.
short_name No Shorter alternative name.
description Yes What the widget does, shown in the widget picker.
tag Yes Identifier used by the service worker API (getByTag, updateByTag).
template No (documented as informational) Name of a generic template; not used by Windows today, but keep it.
ms_ac_template Yes URL of the Adaptive Cards template JSON.
data No URL returning the JSON bound into the template (${song} placeholders).
type No MIME type of data.
screenshots Yes Picker previews; images larger than 1024x1024 are ignored; platform may be Windows or any.
icons No Widget icons (falls back to the app's icons); images larger than 1024x1024 are ignored.
auth No Whether the widget requires authentication.
update No Desired refresh interval in seconds. Nothing refreshes automatically: your service worker must update the widget.
multiple No Allow several instances of the widget; defaults to true.

Installing the PWA only adds its widgets to the picker; a widget instance is created when the user adds it, which fires widgetinstall in your service worker. You render and refresh instances through self.widgets:

sw.js
// Microsoft Edge on Windows 11 only. Guard every call.
async function fetchText(url) {
  const response = await fetch(url, { cache: "no-store" });
  if (!response.ok) throw new Error(`${url} answered ${response.status}`);
  return response.text();
}

async function renderWidget(widget) {
  const { msAcTemplate, data, tag } = widget.definition;
  try {
    const [template, payload] = await Promise.all([
      fetchText(msAcTemplate),
      // "data" is optional in the manifest: bind an empty object without it.
      data ? fetchText(data) : Promise.resolve("{}"),
    ]);
    await self.widgets.updateByTag(tag, { template, data: payload });
  } catch (err) {
    // Offline or server error: keep the last rendered card instead of
    // replacing it with an empty one.
    console.warn("Widget refresh failed", tag, err);
  }
}

self.addEventListener("widgetinstall", (event) => {
  event.waitUntil((async () => {
    await renderWidget(event.widget);
    const tag = event.widget.definition.tag;
    const interval = event.widget.definition.update;
    // Periodic Background Sync keeps the widget fresh while the app is closed.
    if (interval && self.registration.periodicSync) {
      const tags = await self.registration.periodicSync.getTags();
      if (!tags.includes(tag)) {
        await self.registration.periodicSync.register(tag, { minInterval: interval * 1000 });
      }
    }
  })());
});

self.addEventListener("widgetuninstall", (event) => {
  event.waitUntil((async () => {
    // Unregister the sync only when the last instance goes away.
    if (event.widget.instances.length === 1 && self.registration.periodicSync) {
      await self.registration.periodicSync.unregister(event.widget.definition.tag);
    }
  })());
});

self.addEventListener("periodicsync", (event) => {
  // Fired for the tag registered in widgetinstall.
  event.waitUntil((async () => {
    if (!self.widgets) return;
    const widget = await self.widgets.getByTag(event.tag);
    if (widget) await renderWidget(widget);
  })());
});

self.addEventListener("widgetresume", (event) => {
  // The host suspended rendering to save resources; repaint now.
  event.waitUntil(renderWidget(event.widget));
});

self.addEventListener("widgetclick", (event) => {
  // event.action is the "verb" of the Action.Execute in the template.
  if (event.action === "next-song" || event.action === "previous-song") {
    event.waitUntil(forwardToClients({ type: event.action }));
  }
});

self.addEventListener("activate", (event) => {
  // Widgets may exist before this worker activates: repaint them.
  event.waitUntil((async () => {
    if (!self.widgets) return;
    const widget = await self.widgets.getByTag("pwamp");
    if (widget) await renderWidget(widget);
  })());
});

async function forwardToClients(message) {
  const windows = await self.clients.matchAll({ type: "window" });
  for (const client of windows) client.postMessage(message);
}

Note that Periodic Background Sync's minInterval is in milliseconds while the manifest's update is in seconds; Microsoft's own sample passes update unconverted, which requests a far shorter interval than intended (the browser still enforces its own minimum). The self.widgets object also offers getByInstanceId(), getByHostId(), matchAll(options) and updateByInstanceId(). For local development Microsoft requires Windows 11 with Developer Mode enabled and the Windows App SDK 1.2 runtime; for distribution, it recommends packaging the PWA for the Microsoft Store with PWABuilder. The underlying periodic update mechanism is covered on Periodic Background Sync.

These three pieces describe and detect the relationship between your web app and other apps you publish: an Android app on Google Play, a Windows app, a Chrome extension, or the PWA itself on another scope.

The related_applications entry format

manifest.webmanifest
{
  "related_applications": [
    {
      "platform": "play",
      "url": "https://play.google.com/store/apps/details?id=com.example.app",
      "id": "com.example.app"
    },
    { "platform": "windows", "id": "ExampleCorp.ExampleApp_9jmtgj1pbbz6e!App" },
    { "platform": "webapp", "url": "/manifest.webmanifest", "id": "https://example.com/" }
  ],
  "prefer_related_applications": false
}
Field Rule
platform Required, non-empty string; entries without it are ignored ("'platform' is a required field").
url Optional; resolved against the manifest URL with no scope or origin restriction.
id Optional platform-specific identifier. At least one of url or id is required.
min_version, fingerprints Defined in the spec (with fingerprints entries of {type, value}), but not implemented in any browser. A lower installed version or a mismatched signing certificate does not stop detection.

The relationship declared here is one-directional. The spec forbids browsers from assuming an endorsement unless the other app claims the same relationship, which is why detection always requires a verification file on the other side.

Platform strings that Chromium acts on:

platform id format Used by
play Android package name (com.example.app) Android: native app install banner, install suppression, getInstalledRelatedApps(). ChromeOS: install suppression when Android apps (ARC) are enabled.
windows Package Family Name plus !App (for example MyApp_9jmtgj1pbbz6e!App) getInstalledRelatedApps() on Windows.
webapp Manifest id (required on desktop) getInstalledRelatedApps() for PWAs.
chrome_web_store Extension id Desktop Chromium: install-promotion suppression when the extension is installed or preferred.

Other strings (itunes, f-droid, amazon, …) appear in older examples; no browser acts on them.

prefer_related_applications is a boolean (default false) that tells the browser to promote a related app instead of the web app. Chromium's install pipeline implements it as follows:

Android (Chrome). If prefer_related_applications is true and a play entry has an id, Chrome queries Google Play for that package and offers the native app install banner in place of the PWA install flow: beforeinstallprompt still fires, but with event.platforms set to ["play"], and prompt() shows the Play install UI. If the play entry's url contains an id= query parameter, it must equal id, or the entry is rejected ("IDs do not match"); a referrer= parameter in that URL is forwarded to Play. This Play lookup only works in Chrome Beta and Stable, so testing in Canary or Dev shows the status "prefer_related_applications is only supported on Chrome Beta and Stable channels on Android".

Desktop Chromium. If prefer_related_applications is true and any entry has a platform the desktop can install (chrome_web_store, or play on ChromeOS with Android apps enabled), Chromium stops install promotion with the status "Manifest specifies prefer_related_applications: true". The app remains installable from the browser menu, but it is not promoted and beforeinstallprompt does not fire.

Independent of the flag. Chromium also suppresses PWA install promotion when a related non-web app is already installed: the play package on Android (and on ChromeOS through ARC), or an enabled chrome_web_store extension on desktop.

Leave prefer_related_applications at false unless you really want Android users sent to the Play Store; the PWA-first alternatives are covered in Install Prompts & Custom UI and Publishing to App Stores.

Get Installed Related Apps API (WICG)
dictionary RelatedApplication {
  required USVString platform;
  USVString url;
  DOMString id;
  USVString version;
};

[Exposed=Window]
partial interface Navigator {
  [SecureContext] Promise<sequence<RelatedApplication>> getInstalledRelatedApps();
};

The method reads the current document's manifest, takes its related_applications, and resolves with the entries that are installed and verified. It is only available in secure contexts; called from a document that is not in a top-level browsing context (an iframe), it rejects with an InvalidStateError. Chrome only checks the first three entries of related_applications, to prevent sites from probing for a long list of apps, and browsers must return nothing in private or incognito-style modes.

What you check Verification on the other side Where it works
Android app asset_statements in the app's AndroidManifest.xml with delegate_permission/common.handle_all_urls for your site (Bubblewrap and PWABuilder add this) Chrome on Android 80+
Windows app (UWP / packaged) windows.appUriHandler extension in the app manifest, plus /.well-known/windows-app-web-link listing the Package Family Name Chrome and Edge on Windows 85+
The PWA itself, same origin and within its scope None: platform: "webapp", url of the manifest, id of the app Chrome on Android 84+; Chrome and Edge 140+ on Windows, macOS, Linux and ChromeOS
A PWA on another scope or origin /.well-known/assetlinks.json on the PWA's origin with delegate_permission/common.query_webapk naming the calling page's manifest URL Chrome on Android 84+ only

Returned objects copy platform, url and id from your manifest and add version when the platform reports one (Android apps do):

src/related-apps.js
// Returns details of related apps that are installed and verified.
// Resolves to [] where the API is missing or detection is impossible.
export async function getRelatedApps() {
  if (!("getInstalledRelatedApps" in navigator)) return [];
  if (window.top !== window) return []; // Rejects with InvalidStateError in iframes.
  try {
    return await navigator.getInstalledRelatedApps();
  } catch (err) {
    console.warn("getInstalledRelatedApps failed", err);
    return [];
  }
}

export async function shouldShowInstallPromo() {
  const apps = await getRelatedApps();
  // Hide the PWA install promo if the Android app or the PWA is present.
  return !apps.some((app) => app.platform === "play" || app.platform === "webapp");
}

Use the result to hide your install UI or deep-link users into the app they already have; never make functionality depend on it, because every non-Chromium browser returns nothing. The complete set of installation-detection techniques (display-mode queries, appinstalled, getInstalledRelatedApps(), server-side hints) is on Detecting Installed Apps, and the Android side of verified relationships on Trusted Web Activity.

iarc_rating_id: age rating metadata

iarc_rating_id is a string holding an International Age Rating Coalition certification code for the app, defined in the W3C Manifest App Information registry alongside categories, description and screenshots:

manifest.webmanifest
{
  "iarc_rating_id": "e84b072d-71b3-4d3e-86ae-31a8ce4e53b7"
}

The registry classifies it as supplementary: purely advisory, never applied at runtime. IARC certificates are issued through participating storefronts, and one code may be shared across storefronts as long as the distributed product is the same. No browser reads the member, MDN has removed its reference page, and Chromium's manifest parser does not parse it. Tooling such as PWABuilder's manifest validator recognizes it as an optional field. Include it only if a storefront or internal tooling you use consumes it; age ratings for store listings are otherwise handled in each store's own submission process (see Publishing to App Stores).

A complete integration manifest

The following manifest combines the members above in the way a desktop-focused productivity PWA would ship them. Every entry degrades cleanly: a browser that does not understand a member ignores it.

manifest.webmanifest
{
  "id": "/",
  "name": "Grafr",
  "short_name": "Grafr",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "display_override": ["tabbed", "window-controls-overlay", "standalone"],
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "launch_handler": {
    "client_mode": ["focus-existing", "auto"]
  },
  "protocol_handlers": [
    { "protocol": "web+grafr", "url": "/open?uri=%s" }
  ],
  "file_handlers": [
    {
      "action": "/open-file",
      "name": "Grafr graph",
      "accept": {
        "application/vnd.grafr-graph+json": [".grafr"],
        "text/csv": [".csv"]
      },
      "launch_type": "single-client"
    }
  ],
  "share_target": {
    "action": "/share-target/",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url",
      "files": [{ "name": "data", "accept": ["text/csv", ".csv"] }]
    }
  },
  "tab_strip": {
    "home_tab": { "scope_patterns": [{ "pathname": "/" }] },
    "new_tab_button": { "url": "/graphs/new" }
  },
  "note_taking": { "new_note_url": "/notes/new" },
  "scope_extensions": [
    { "type": "origin", "origin": "https://docs.grafr.example" }
  ],
  "related_applications": [
    { "platform": "webapp", "url": "/manifest.webmanifest", "id": "https://grafr.example/" },
    { "platform": "play", "id": "example.grafr.twa" }
  ],
  "prefer_related_applications": false
}

A one-page summary of every member, including the core ones, is on the Manifest Cheat Sheet.

Browser support in detail

Feature Chrome desktop Edge desktop Chrome Android Samsung Internet Safari / iOS Firefox
protocol_handlers ✅ 96 ✅ 96 ❌ ❌ ❌ ❌
file_handlers ✅ 102 ✅ 102 ❌ ❌ ❌ ❌
file_handlers[].launch_type ✅ ✅ ❌ ❌ ❌ ❌
file_handlers[].icons ❌ ❌ ❌ ❌ ❌ ❌
share_target ⚠️ 89 (ChromeOS only) ⚠️ Windows1 ✅ 76 ✅ 12.0 ❌ ❌
launch_handler ✅ 110 ✅ 110 ⚠️ 1102 ⚠️ 21.02 ❌ ❌
window.launchQueue / LaunchParams.files ✅ 102 ✅ 102 ⚠️ ❌ ❌ ❌
LaunchParams.targetURL ✅ 110 ✅ 110 ⚠️ ❌ ❌ ❌
handle_links ❌ ❌ ❌ ❌ ❌ ❌
scope_extensions ✅ 1393 ✅ 139 ❌4 ❌ ❌ ❌
note_taking ⚠️ ChromeOS only ❌ ❌ ❌ ❌ ❌
tabbed + tab_strip ⚠️ ChromeOS; 🧪 flag elsewhere ❌ ❌ ❌ ❌ ❌
edge_side_panel ❌ ⚠️ deprecated ❌ ❌ ❌ ❌
widgets ❌ ⚠️ Windows 11 ❌ ❌ ❌ ❌
related_applications / prefer_related_applications ⚠️ install suppression only ⚠️ ✅ 44 ✅ 4.0 ❌ ❌
getInstalledRelatedApps() (webapp, same scope) ✅ 140 ✅ 140 ✅ 84 ✅ 14.0 ❌ ❌
getInstalledRelatedApps() (Windows app) ✅ 85 (Windows) ✅ 85 (Windows) n/a n/a ❌ ❌
iarc_rating_id ❌ ❌ ❌ ❌ ❌ ❌

Support data as of September 2026. For live data, check MDN's Web App Manifest reference (each member page has a compatibility table), MDN's Launch Handler API page and caniuse.

Common pitfalls

  1. Expecting integration without installation. None of protocol_handlers, file_handlers, share_target, launch_handler, note_taking or tab_strip does anything in a browser tab. Test with the app installed, and remember that an Android share target needs a WebAPK, which Chrome mints only for apps installed through its own install flow.
  2. Handler URLs outside scope. "action": "/open" with "scope": "/app/" silently drops the file handler. So does a manifest served from a subdirectory with relative action values.
  3. Scope without a trailing slash. "scope": "/app" makes /apple-pay-help part of your app, including for link capturing and scope extensions.
  4. One bad MIME type killing share_target. A typo such as "image/" or "images/*" in any files[].accept removes the entire share target, not just that entry.
  5. Registering generic file types. Claiming .json, .txt or image/* competes with the user's editors and viewers, and Chrome automatically blocks file handling for an app after the user ignores its first-launch prompt three times. Register your own extension first.
  6. Relying on multi-file launches on Windows. Windows starts one launch per file; handle merging in your launchQueue consumer.
  7. Two setConsumer() calls. The second silently replaces the first. Route everything through one consumer.
  8. Assuming reload replays the launch. The replay was an unspecified stop-gap that Chrome 146 removed on desktop; persist file handles in IndexedDB.
  9. focus-existing without a consumer. The launch focuses the window and nothing else happens; users think the link is broken.
  10. Shipping origin-trial syntax. url_handlers, capture_links, handle_links, route_to, wildcard scope_extensions origins and web_apps association files are all ignored by current browsers.
  11. Forgetting the association file. A scope_extensions entry without a matching, manifest-id-keyed /.well-known/web-app-origin-association on the target origin is dropped, with only a DevTools warning.
  12. Setting prefer_related_applications: true by accident. Copy-pasted manifests with this flag send Android users to Play and, when a chrome_web_store entry is listed, lose PWA install promotion on desktop.

Debugging integration members

  • DevTools > Application > Manifest. Shows parse errors and warnings (every "ignored" message quoted on this page), the installability status including prefer_related_applications, and a Protocol Handlers section where you can enter a web+ URL and test the handler without an OS round trip. See Browser DevTools.
  • chrome://web-app-internals. Dumps every installed app as Chromium stores it, including the parsed file handlers, protocol handlers, launch_handler client mode, validated scope extensions and the recorded OS integration state. Use it to confirm what the browser actually registered, which is not always what your current manifest says.
  • chrome://app-settings/<app-id>. The per-app Opening supported links choice (link capturing), the file handling toggle, the app's site permissions and a link to its full site settings. Change these to reproduce first-run and opted-out states.
  • The OS itself. On Windows, check Default apps and Open with; on Linux, xdg-mime query default <mime/type> and the generated .desktop file; on macOS, Get Info > Open with for a file of the registered type.
  • Test launches from outside the browser. Launch protocol URLs from a terminal (start web+grafr:demo on Windows, open "web+grafr:demo" on macOS, xdg-open "web+grafr:demo" on Linux) and open files from the file manager, so you exercise the real OS registration rather than the DevTools shortcut.
  • Reinstall during development. Installed apps only pick up integration changes through the manifest update process (see App Identity & Updates), so uninstall and reinstall after editing these members, then recheck chrome://web-app-internals. Automated Testing covers scripting these checks.

Further reading

On this site

External references


  1. Microsoft documents registering PWAs installed with Edge as share targets in the Windows share dialog. Chrome itself does not register share targets on Windows, macOS or Linux. ↩

  2. MDN lists the launch_handler member for Chrome Android 110 but marks window.launchQueue, LaunchParams.files and LaunchParams.targetURL as unsupported on Chrome Android, so do not assume a consumer is called there. Android apps are single-task, auto means navigate-existing, and Chromium's recent Android work on client modes and launch parameters targets Trusted Web Activities; verify behavior on devices. ↩↩

  3. Chrome's release notes and chromestatus give Chrome 139 as the shipping milestone; MDN's compatibility data lists 138, the milestone the Intent to Ship originally targeted. ↩

  4. MDN's data lists Chrome Android 138 and Samsung Internet 30 because the member is parsed there, but the Intent to Ship limited the feature to desktop platforms, where it has an effect. ↩