Skip to content

Protocol Handlers and Launch Handling

Protocol handling and launch handling decide what happens when a URL outside your page points at your app: a mailto: or web+music: link clicked in another program, an https: link to your domain clicked in a chat app, or a shortcut, file or notification that relaunches an app that is already open. The web platform splits this into three mechanisms: navigator.registerProtocolHandler() and the manifest's protocol_handlers member route custom schemes to your pages, link capturing (Chromium's navigation capturing on desktop, WebAPK intent filters on Android) routes ordinary https: links into the installed app, and launch_handler plus window.launchQueue decide which window receives each launch. Getting them right is what makes an installed PWA feel like it belongs to the operating system instead of being a website in a frame.

Key takeaways

  • navigator.registerProtocolHandler(scheme, url) is an HTML Standard API supported in Chromium desktop browsers and Firefox. It only accepts safelisted schemes or web+ plus lowercase letters, requires %s in a same-origin https: handler URL, and never tells you whether the user accepted.
  • The manifest's protocol_handlers member (Chrome and Edge 96+, desktop only) registers the same kind of handler with the operating system at install time, so other native applications can launch your installed app. The first launch per scheme shows an "Allow app to open … links?" dialog.
  • The invoked URL arrives in your handler URL encoded exactly like encodeURIComponent(), including its scheme. Treat it as untrusted input from any program on the device.
  • launch_handler.client_mode (auto, navigate-new, navigate-existing, focus-existing; Chrome 110+) picks the window for every launch. With focus-existing the browser does not navigate: you must consume LaunchParams.targetURL from window.launchQueue. Since Chrome 146 a reload no longer re-delivers the last LaunchParams.
  • Chromium's source enables navigation capturing on Windows, macOS and Linux by default: from Chrome 138 for apps that declare a valid client_mode, from Chrome 140 for every installed app. Only new-context link clicks (such as target="_blank") are captured, never same-tab navigations, and users can switch it off per app.
  • On Android, the WebAPK declares https intent filters generated from your scope; on iOS and iPadOS nothing captures links or schemes, and out-of-scope navigations open in an in-app Safari view.

Three ways a URL reaches an installed app

Before diving into each API it helps to see how they relate. A URL can reach your code through a custom scheme or through an ordinary https: link, and after the browser decides that your app should handle it, launch handling decides which window gets it.

flowchart TD
    A["User activates a URL"] --> B{"Scheme?"}
    B -->|"web+music: mailto: etc."| C{"Who registered it?"}
    C -->|"registerProtocolHandler()"| D["Browser navigates a tab to the handler URL"]
    C -->|"manifest protocol_handlers"| E["OS launches the installed app with the handler URL"]
    B -->|"https: inside app scope"| F{"Platform"}
    F -->|"Chromium desktop"| G["Navigation capturing rules"]
    F -->|"Android"| H["WebAPK intent filter"]
    F -->|"iOS, iPadOS, Safari"| I["Opens in the browser"]
    E --> J["launch_handler client_mode"]
    G --> J
    H --> J
    J --> K["Window chosen and LaunchParams enqueued"]
Mechanism What it routes Where it is registered Opens in Engines
navigator.registerProtocolHandler() Custom schemes clicked inside the browser Browser profile, after a prompt A browser tab Chromium desktop, Firefox
Manifest protocol_handlers Custom schemes activated anywhere in the OS Operating system, at install The installed app window Chromium desktop
Navigation capturing https: links to the app's scope, clicked in the browser Per-app browser setting The installed app window Chromium desktop
WebAPK intent filters https: links to the app's scope from Android apps and Chrome The generated APK's manifest The WebAPK activity Chrome on Android
launch_handler Every launch of an installed app Web App Manifest New or existing window, per client_mode Chromium

Each mechanism is an enhancement: a page that only works when a launch arrives through launchQueue, or only when a web+ link resolves, is broken for most of your users. Keep every handler URL a real, directly loadable page.

registerProtocolHandler() is the oldest of these APIs. It lives in the HTML Standard's custom scheme handlers section, on the NavigatorContentUtils mixin that Navigator includes, and it lets any secure page ask to become the handler for a scheme within the browser. It does not require installation, which makes it the right tool for users who never install your PWA.

HTML Standard
interface mixin NavigatorContentUtils {
  [SecureContext] undefined registerProtocolHandler(DOMString scheme, USVString url);
  [SecureContext] undefined unregisterProtocolHandler(DOMString scheme, USVString url);
};

Older tutorials pass a third title argument (registerProtocolHandler("web+music", url, "Tunebox")). The HTML Standard dropped it years ago and Chromium's IDL declares only two parameters, so WebIDL silently ignores the extra argument. Chrome Platform Status still tracks "Remove title argument from registerProtocolHandler()" as a cleanup item, which only makes sense for engine internals; your code should simply stop passing it.

The normalization algorithm and the exceptions it throws

Both methods run the same normalize protocol handler parameters algorithm synchronously, then do the real work "in parallel". That split explains the API's shape: invalid arguments throw immediately, but everything the user decides happens later and is never reported back.

Step Check Failure
1 Lowercase scheme. It must be a safelisted scheme, or web+ followed by one or more ASCII lowercase letters. SecurityError DOMException
2 url must contain the literal token %s. SyntaxError DOMException
3 Parse url relative to the current document's base URL. SyntaxError if parsing fails
4 The parsed URL's scheme must be http or https, and its origin must be same origin with the calling document. SecurityError
5 Return the normalized (scheme, url) pair. —

Two more gates sit in front of the algorithm. The methods are [SecureContext], so on an insecure page they do not exist at all (Chrome enforces this since version 80, Firefox since 62), and a secure context means the handler URL is in practice https: or http://localhost. Chrome also restricted the url argument to http:/https: in Chrome 77, before the spec step existed.

The return value is always undefined. Registration continues in parallel, where the HTML Standard lets the user agent prompt the user or record the request silently, and asks it to remember sites that were declined "so that the user is not repeatedly prompted with the same request". There is no promise, no event, no permission state in the Permissions API, and no isProtocolHandlerRegistered(): that query method existed in older drafts and was removed, because a site could use it to fingerprint which handlers a user has. Design the UI so that it does not depend on knowing the outcome.

Which schemes you can claim

The HTML Standard's safelisted schemes are the only standard schemes a site may claim. Engines add their own on top:

Group Schemes Notes
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 matrix arrived in Chrome 92 and Firefox 90. Firefox accepts ftp, ftps and sftp since Firefox 98. MDN still lists them as unsupported in Chrome, but the current Chromium safelist code accepts them.
Chromium additions cabal, dat, did, doi, dweb, ethereum, hyper, ipfs, ipns, ssb The decentralized-web schemes were safelisted in Chrome 86. doi is in the current Chromium safelist too, although MDN's data does not track it. Not standard, not in Firefox.
Behind a flag payto Chromium has it behind a feature that is disabled by default.
Custom web+ followed by one or more letters, for example web+music After lowercasing, only a–z may follow the prefix: web+my-app, web+app2 and web+ alone are rejected.
Always rejected http, https, file, data, javascript, about, blob, any unprefixed custom scheme such as myapp SecurityError.

Because the scheme is lowercased first, registerProtocolHandler("Web+Music", …) registers web+music. Links are matched case-insensitively too, because URL parsing lowercases the scheme.

The web+ prefix exists so that the web cannot hijack schemes that native applications rely on (zoommtg:, slack:, vscode: and so on). If you need a scheme that other platforms already use, you must use its standard name only if it is safelisted; otherwise choose a web+ name and, if you also ship native apps, register both.

When the user follows a URL whose scheme has a registered handler, the browser runs the HTML Standard's handler steps on the whole invoked URL:

  1. Clear the URL's username and password.
  2. Serialize the URL.
  3. UTF-8 percent-encode the serialization with the component percent-encode set. The URL Standard notes that this "gives identical results to JavaScript's encodeURIComponent()".
  4. Replace the first %s in the handler URL with the encoded string. Later %s tokens stay literal.
  5. Parse the result and navigate to it.
Invoked URL Handler URL Navigation target
web+music:track/42?t=13 https://tunes.example/open?uri=%s https://tunes.example/open?uri=web%2Bmusic%3Atrack%2F42%3Ft%3D13
mailto:[email protected]?subject=Hi%20there https://mail.example/compose?to=%s https://mail.example/compose?to=mailto%3Aada%40example.com%3Fsubject%3DHi%2520there
web+music://user:pw@host/x https://tunes.example/open?uri=%s https://tunes.example/open?uri=web%2Bmusic%3A%2F%2Fhost%2Fx (credentials stripped)
magnet:?xt=urn:btih:abc https://dl.example/add#%s https://dl.example/add#magnet%3A%3Fxt%3Durn%3Abtih%3Aabc

Three details from that table cause most parsing bugs:

  • The scheme is included. You receive web+music:track/42, not track/42. Parse it with new URL().
  • Existing escapes are escaped again. %20 in the invoked URL becomes %2520, because % is in the component set. One URLSearchParams.get() gives you back the original URL with its own %20; decode path segments a second time if you need the raw text.
  • The fragment is an option. Putting %s in the fragment (#%s) keeps the invoked URL out of server logs and Referer headers, at the cost of not seeing it on the server. For mailto: handlers that is often what you want.

Historically, browsers did not agree on every character of this escaping (a Chromium entry about percent-encoding spaces in custom handler URLs is still listed as not in active development), so decode defensively and test with spaces, +, #, &, non-ASCII text and already-escaped input.

What the user sees in each browser

Browser Prompt Where users manage handlers
Chrome, Edge, Opera (desktop) A permission request anchored to the address bar asking whether the site may open links of that type chrome://settings/handlers (Settings > Privacy and security > Site settings > Protocol handlers). The page also has a default toggle, "Sites can ask to handle protocols" versus "Don't allow sites to handle protocols".
Firefox (desktop) A notification bar asking whether to add the site as an application for that link type The Applications list in Firefox's General settings
Chrome on Android, Samsung Internet, WebView API missing ('registerProtocolHandler' in navigator is false) —
Safari (macOS, iOS, iPadOS) API missing —

Chrome's handler settings page also lists apps separately: installed PWAs that registered schemes through the manifest appear under an "Apps" heading with allowed and disallowed lists, so the same page is where a user undoes a "Don't allow" decision for an installed app.

Because a user who blocks the prompt, or has blocked the whole setting, is indistinguishable from one who accepted, offer registration behind an explicit button, explain what it does, and keep a working fallback (for example, show a copyable address instead of relying on mailto: routing).

unregisterProtocolHandler() and checking registration

unregisterProtocolHandler(scheme, url) takes the same arguments you registered with, runs the same normalization (so it throws the same exceptions), and removes the matching handler in parallel. Chromium supports it (Chrome 38+). Firefox does not implement it; users remove handlers from settings. Call it when the user turns the feature off in your app's settings, and pass exactly the handler URL you registered, because a handler is identified by the scheme and URL pair.

You cannot check whether a handler is registered. Two practical substitutes:

  • Record in localStorage that you asked, so you do not show the button again on every visit. Do not record that the user accepted, because you do not know.
  • Detect actual use: when your handler page loads with a valid uri parameter, you know the handler works, and you can hide the "Set as default" prompt from then on.

A production registration module

src/register-handler.js
// Offers "Open web+music links with Tunebox" behind an explicit button.
// registerProtocolHandler() gives no success signal, so this module only
// remembers that it asked and hides itself once a real launch is observed.

const SCHEME = "web+music";
// Resolved against document.baseURI; must stay same-origin and https.
const HANDLER = "/open?uri=%s";
const ASKED_KEY = "tunebox:rph-asked";
const WORKS_KEY = "tunebox:rph-works";

export function canRegisterProtocolHandler() {
  return (
    window.isSecureContext &&
    typeof navigator.registerProtocolHandler === "function"
  );
}

export function markHandlerWorking() {
  // Call this from the /open page after a valid launch was parsed.
  try {
    localStorage.setItem(WORKS_KEY, "1");
  } catch {
    // Storage can be unavailable (private mode, quota); not critical.
  }
}

function read(key) {
  try {
    return localStorage.getItem(key);
  } catch {
    return null;
  }
}

export function shouldOfferRegistration() {
  if (!canRegisterProtocolHandler()) return false;
  // An installed app with manifest protocol_handlers already has an
  // OS-level registration; the browser-level one would only add a tab.
  if (matchMedia("(display-mode: standalone)").matches) return false;
  return read(WORKS_KEY) !== "1";
}

export function registerMusicHandler() {
  if (!canRegisterProtocolHandler()) {
    return { ok: false, reason: "unsupported" };
  }
  try {
    navigator.registerProtocolHandler(SCHEME, HANDLER);
  } catch (err) {
    // SecurityError: scheme not allowed, or handler not same-origin/https.
    // SyntaxError: missing %s or unparsable URL.
    // Both are programming errors: surface them loudly in development.
    console.error("registerProtocolHandler failed", err);
    return { ok: false, reason: err.name };
  }
  try {
    localStorage.setItem(ASKED_KEY, String(Date.now()));
  } catch {
    /* ignore */
  }
  // "requested" is all we can truthfully say: the user may still decline,
  // or the browser may suppress the prompt for a previously declined site.
  return { ok: true, reason: "requested" };
}

export function unregisterMusicHandler() {
  if (typeof navigator.unregisterProtocolHandler !== "function") {
    // Firefox: removal only through browser settings.
    return false;
  }
  try {
    navigator.unregisterProtocolHandler(SCHEME, HANDLER);
  } catch (err) {
    console.error("unregisterProtocolHandler failed", err);
    return false;
  }
  try {
    // Forget both flags so the settings page offers registration again.
    localStorage.removeItem(WORKS_KEY);
    localStorage.removeItem(ASKED_KEY);
  } catch {
    /* Storage unavailable: the handler is still unregistered. */
  }
  return true;
}

Wire it to a button, not to page load. Neither Chrome nor the specification requires user activation for the call, but a prompt that appears unprompted is likely to be dismissed or blocked, and the HTML Standard lets browsers remember the refusal:

settings.html
<section id="link-handling" hidden>
  <h2>Open music links with Tunebox</h2>
  <p>
    Links such as <code>web+music:track/42</code> on other sites will open
    here. Your browser will ask you to confirm.
  </p>
  <button type="button" id="rph-button">Use Tunebox for music links</button>
  <p id="rph-status" role="status"></p>
</section>

<script type="module">
  import {
    shouldOfferRegistration,
    registerMusicHandler,
  } from "/src/register-handler.js";

  const section = document.getElementById("link-handling");
  const status = document.getElementById("rph-status");

  if (shouldOfferRegistration()) {
    section.hidden = false;
    document.getElementById("rph-button").addEventListener("click", () => {
      const result = registerMusicHandler();
      status.textContent = result.ok
        ? "Check your browser's prompt to finish."
        : "Your browser does not support this.";
    });
  }
</script>

Automating registration in tests

The HTML Standard defines a WebDriver extension command, Set RPH Registration Mode (POST /session/{session id}/custom-handlers/set-mode with {"mode": "autoAccept" | "autoReject" | "none"}), so that test runners can answer the prompt without UI. Chromium's implementation of that command is listed on chromestatus as in development, so today most end-to-end suites test the handler page directly (load /open?uri=web%2Bmusic%3Atrack%2F42) and treat the registration call as a unit concern: assert that it is called with the right arguments and that a thrown SecurityError is handled. See Automated Testing.

Manifest protocol_handlers: OS-level registration

The manifest member, specified in WICG's Manifest Incubations, reuses the same normalization algorithm with the manifest URL as the base URL and one extra rule: the handler URL must be within the app's scope. Duplicated handler URLs are skipped. Instead of registering with the browser, Chromium registers each entry with the operating system when the app is installed, so a web+music: link in an email client, a PDF viewer or a terminal launches the installed PWA.

manifest.webmanifest
{
  "id": "/",
  "name": "Tunebox",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "protocol_handlers": [
    { "protocol": "web+music", "url": "/open?uri=%s" },
    { "protocol": "magnet", "url": "/downloads/add?link=%s" }
  ],
  "launch_handler": {
    "client_mode": ["focus-existing", "auto"]
  }
}

Every processing rule, error message and scheme edge case for this member (including the relaxed rules for Isolated Web Apps) is documented in Advanced & Integration Members. The rest of this section covers what happens at run time.

Registration lifecycle

  1. Install. Chromium registers the schemes with the OS as part of OS integration. On desktop, other applications immediately see the PWA as a possible handler; if another application (native or web) already handles the scheme, the OS decides which one runs, and it may show its own chooser.
  2. First launch through a scheme. Chromium shows a dialog titled "Allow app to open web+music links?" with Allow and Don't allow buttons and a Remember this choice checkbox. Choosing Don't allow with the checkbox ticked records the app in the "disallowed" list shown on chrome://settings/handlers, and future launches for that scheme are refused.
  3. Launch. The browser computes the handler URL exactly like the HTML algorithm above, then, instead of navigating a tab, launches the web app with that URL as the launch's target URL. launch_handler then decides the window, and LaunchParams.targetURL carries the computed URL.
  4. Manifest update. Adding or removing entries re-runs OS integration when Chromium applies a manifest update (see App Identity & Updates).
  5. Uninstall. Registrations are removed.
sequenceDiagram
    participant Mail as Native mail client
    participant OS
    participant Chrome as Chromium browser process
    participant Win as Tunebox window
    participant Q as window.launchQueue
    Mail->>OS: Open "web+music:track/42"
    OS->>Chrome: Launch app with the URL
    Chrome->>Chrome: First use? Show "Allow app to open web+music links?"
    Chrome->>Chrome: Build /open?uri=web%2Bmusic%3Atrack%2F42
    alt client_mode focus-existing and a window is open
        Chrome->>Win: Focus, no navigation
        Chrome->>Q: Enqueue LaunchParams(targetURL)
        Q->>Win: consumer(params)
    else no window open
        Chrome->>Win: New window at /open?uri=...
        Chrome->>Q: Enqueue LaunchParams after load
    end

Browser-level and OS-level registration side by side

Aspect registerProtocolHandler() Manifest protocol_handlers
Needs installation No Yes
Who can trigger the handler Links followed inside that browser profile Any application on the device, through the OS
Consent Prompt at registration time Dialog at first launch per scheme
Result A tab navigates to the handler URL The app launches, subject to launch_handler
Handler URL constraint Same origin as the calling page Within the manifest scope (therefore same origin)
Removal unregisterProtocolHandler() (Chromium) or settings Manifest update without the entry, or uninstall
Support Chromium desktop (Chrome 13+), Firefox (2+) Chrome and Edge desktop 96+

Using both is common and safe: the browser-level handler serves visitors who never install; the manifest handler takes over for installed users, and the settings page shows both.

Handling the invoked URL safely

Whichever mechanism delivered it, your handler page receives a URL that any program or web page could have constructed. Any application on the device can open web+music: URLs, and any site the user visits can link to one. Treat it exactly like an unauthenticated request parameter.

A protocol launch is untrusted input

Never perform a state-changing action (send, delete, pay, share, grant access) directly from a launch. Validate against strict patterns, never insert the value into HTML or use it to build a redirect without an allowlist, and require an explicit click inside your own UI to confirm anything that matters. A web+pay: handler that sends money on load is a CSRF vulnerability that any website can trigger.

Parsing custom-scheme URLs

Non-special schemes (everything except http, https, ws, wss, ftp and file) parse differently from https: URLs. new URL("web+music:track/42?t=13") has an opaque path: pathname is "track/42", host is "", and the query is still available through searchParams. If your links use the web+music://host/path form instead, host and pathname behave more like a normal URL. Pick one form, document it, and accept only that one.

src/protocol-launch.js
// Parses /open?uri=<encoded invoked URL> into an app route.
// Returns null for anything unexpected; callers fall back to the home view.

const MAX_URI_LENGTH = 2048; // Defends against absurd inputs from other apps.
const TRACK_ID = /^[0-9]{1,12}$/;
const PLAYLIST_ID = /^[A-Za-z0-9_-]{8,32}$/;

export function parseProtocolLaunch(pageUrl) {
  const page = new URL(pageUrl, location.href);
  if (page.origin !== location.origin || page.pathname !== "/open") {
    return null;
  }

  const raw = page.searchParams.get("uri"); // One level of decoding applied.
  if (!raw || raw.length > MAX_URI_LENGTH) return null;

  let invoked;
  try {
    invoked = new URL(raw);
  } catch {
    return null; // Not a URL at all.
  }

  if (invoked.protocol !== "web+music:") return null;

  // Accept "web+music:track/42" and "web+music:playlist/abc123XYZ".
  // An opaque path has no leading slash; strip one in case another app
  // produced the "web+music:/track/42" form.
  const [kind, id, ...rest] = invoked.pathname.replace(/^\/+/, "").split("/");
  if (rest.length > 0) return null;

  if (kind === "track" && TRACK_ID.test(id)) {
    const t = Number.parseInt(invoked.searchParams.get("t") ?? "0", 10);
    return {
      view: "track",
      id,
      startAt: Number.isFinite(t) && t >= 0 && t < 86_400 ? t : 0,
    };
  }

  if (kind === "playlist" && PLAYLIST_ID.test(id)) {
    return { view: "playlist", id };
  }

  return null;
}

Handle the same parser from two entry points: page load (for new windows and browser tabs) and the launchQueue consumer (for existing windows). Both are wired together in the complete example at the end of this page.

launch_handler and window.launchQueue at run time

launch_handler is specified in WICG's Web App Launch Handler API. Its only member today, client_mode, takes one of four values or an array of them (the first value the browser recognizes wins; unknown values fall back to auto):

client_mode Spec definition, condensed Chromium desktop behavior
auto The user agent decides. Resolves to navigate-new.
navigate-new A new app client loads the target URL. New app window (a new tab in the tabbed display mode).
navigate-existing Focus an existing client and navigate it to the target URL; if none exists, behave like navigate-new. Most recently used app window is navigated.
focus-existing Focus an existing client without navigating; the target URL is delivered through LaunchParams. Most recently used window is focused and receives LaunchParams.

Chrome shipped launch_handler in Chrome 110 on desktop; LaunchQueue and LaunchParams.files already existed from Chrome 102 for File Handling. MDN's compatibility data also lists Chrome on Android from 110, where each installed app is a single task. The member's parsing rules and the full IDL are on Advanced & Integration Members; this section covers how launches flow at run time.

What counts as a launch

Trigger Target URL Goes through client_mode LaunchParams.files
App icon, dock, taskbar, Start menu start_url Yes Empty
App shortcut The shortcut's url Yes Empty
Manifest protocol handler The computed handler URL Yes Empty
File handler The handler's action (see the note below the table) Yes File handles
Captured link (Chromium desktop) The link URL Yes, with the overrides described below Empty
Link or window.location change inside the app window — No: ordinary navigation, no LaunchParams —
Reload — No. Before Chrome 146, Chromium re-delivered the previous LaunchParams on reload; Chrome 146 enabled LaunchQueueStopSendingOnReload by default. —

For file launches, targetURL has historically been unreliable: when Chromium routed a file launch into an existing window, LaunchParams.targetURL was null, and only files was populated. Chrome Platform Status lists "Populate targetURL during file handling" for Chrome 146 to fill in the handler's action URL in that case. Consumers that must also run on older builds should branch on files.length first and treat a missing targetURL as "use the file handler's route".

The asymmetry to remember: launches produce LaunchParams, navigations do not. A reload is a navigation. So is anything your own code does with location, history or links inside the window.

The queue, the consumer, and why order matters

The Launch Handler spec buffers LaunchParams in a per-document list of "unconsumed launch params" until a consumer exists. setConsumer(consumer) stores the consumer and immediately invokes it once per buffered entry, then empties the buffer; later launches into the same document call the consumer directly. Calling setConsumer() again replaces the consumer but does not replay entries that were already consumed.

The spec also forbids a launch from handing its URL to a document that is no longer within scope. If the user has navigated the app window to another origin (a sign-in provider, for example), a focus-existing launch cannot enqueue LaunchParams there; Chromium falls back to navigating. Design for that: the target URL must be loadable on its own.

Three rules for production consumers:

  1. Register the consumer once, as early as the router can render. Entries that are already buffered are delivered synchronously inside setConsumer(), but you cannot rely on them being buffered yet: Chromium sends the launch to the renderer over IPC after the navigation commits, independently of when your module scripts run, so the first LaunchParams can arrive before or after setConsumer().
  2. Do not process the same launch twice. For a launch into a new window, the document's URL already is the target URL, and the first LaunchParams repeats it. Render from location at startup (that also covers browser tabs and engines without the API), then ignore the first launch if it only repeats the URL you rendered.
  3. Protect in-progress work. A focus-existing launch can arrive while the user has a half-written message or unsaved edits. Queue the navigation behind a confirmation instead of discarding state.

A single-window launch router

src/launch-router.js
// Routes every launch into one long-lived window. Designed for
// "client_mode": ["focus-existing", "auto"], so the browser never reloads
// the page for shortcut, protocol or captured-link launches.

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

const hasLaunchQueue = "launchQueue" in window;

function sameOriginUrl(value) {
  try {
    const url = new URL(value, location.href);
    return url.origin === location.origin ? url : null;
  } catch {
    return null;
  }
}

function toRoute(url) {
  if (url.pathname === "/open") {
    return parseProtocolLaunch(url.href) ?? { view: "home" };
  }
  // Shortcuts and captured links: let the SPA router map the path.
  return { view: "path", url };
}

export function installLaunchRouter(app) {
  const deliver = async (url, source) => {
    const route = toRoute(url);
    // Ask before replacing unsaved work; the launch is not lost if the
    // user says no, it is simply not applied.
    if (app.hasUnsavedChanges() && !(await app.confirmLeave(route))) return;
    if (url.href !== location.href) {
      history.pushState({ launch: source }, "", url);
    }
    await app.show(route);
    app.focusMainHeading(); // Move focus for keyboard and screen reader users.
  };

  // Always render the document's own URL first. For a launch into a new
  // window it *is* the target URL; in a browser tab, in a window your code
  // opened, and in engines without launchQueue it is the only signal.
  const initialUrl = sameOriginUrl(location.href);
  if (initialUrl) void deliver(initialUrl, "page-load");

  if (!hasLaunchQueue) return; // Safari, Firefox: nothing more to do.

  // The LaunchParams for this document may arrive before or after this line
  // runs. Remember the URL rendered above so that the first launch, which
  // merely repeats it, is not applied a second time.
  let initialHref = initialUrl ? initialUrl.href : null;

  window.launchQueue.setConsumer((params) => {
    const repeatOfInitial = initialHref;
    initialHref = null; // Only the first launch can be the repeat.

    if (params.files && params.files.length > 0) {
      // File launches are handled by the file-handling module. targetURL
      // may be null here on older Chromium builds.
      void app.openFiles(params.files);
      return;
    }
    const url = params.targetURL ? sameOriginUrl(params.targetURL) : null;
    if (!url) return;

    // Skip only if nothing has changed since load: the launch URL equals the
    // URL rendered at startup and the user has not navigated in-app since.
    if (url.href === repeatOfInitial && location.href === repeatOfInitial) {
      return;
    }
    void deliver(url, "launch-queue");
  });
}

The ordering in this router is deliberate. A consumer-first design ("render from LaunchParams, fall back to location if no launch was seen by the end of setConsumer()") looks tidy but races: when the launch IPC lands after setConsumer() has returned, the fallback renders the page URL and then the consumer renders it again, which runs the unsaved-changes prompt, pushes a duplicate history entry and double-counts analytics. Rendering from location first and de-duplicating the first launch is correct in every order. It also covers /open?uri=… opened directly in a browser tab (the registerProtocolHandler() case), where no LaunchParams ever arrive.

"Link capturing" means that clicking an ordinary https: link to an installed app's scope opens the installed app instead of a browser tab, the way a Slack or Zoom link opens the native app. It took Chromium several attempts to get here. Declarative Link Capturing (capture_links) ran as an origin trial and was dropped; "PWAs as URL Handlers" (url_handlers) and the handle_links member never shipped. The history and the dead manifest members are covered in Advanced & Integration Members. What shipped is navigation capturing: browser behavior controlled by a per-app user setting, with launch_handler.client_mode deciding the window.

Status and rollout

Platform Behavior in current Chromium Source
Windows, macOS, Linux On by default. The PwaNavigationCapturing feature is enabled in code from Chrome 138, initially only for apps whose manifest has a valid launch_handler.client_mode; from Chrome 140 the default applies to every installed app. Chromium source; chromestatus lists the feature for Chrome 134 behind chrome://flags/#enable-user-navigation-capturing-pwa
ChromeOS The re-implemented capturing is active from Chrome 140, but each app's setting defaults to off Chromium source
Android Handled by WebAPK intent filters, not by this feature See the Android section below

Google can also adjust defaults server-side through field trials, and other Chromium browsers can configure the feature differently, so treat these defaults as a strong expectation, not a guarantee, and test in the browsers your users run.

Which navigations are captured

Capturing only applies to navigations that create a new top-level browsing context without an opener, started by a link click or form submission. The rule protects two things users rely on: a same-tab click never yanks them out of the page they were reading, and window.open() popups keep their relationship with the page that opened them.

User action Captured into the app?
Plain click on <a href="https://app.example/x" target="_blank"> in a browser tab (no opener: _blank implies noopener in current browsers) Yes, into the app selected by scope, using its client_mode
Plain click on a same-tab link (target absent or _self) No
<form target="_blank"> submission into scope Yes (form submissions count like link clicks)
Ctrl/Cmd-click, middle-click or Shift-click in a browser tab No: the user explicitly asked for a browser tab or window
Middle-click or Ctrl/Cmd-click on an in-scope link inside the app's own window Opens a new app window, or a new app tab if the app uses the tabbed display mode
Shift-click on an in-scope link inside an app window New app window
window.open() or <a target="_blank" rel="opener"> (an auxiliary browsing context) Not launched into another app; when opened from an app window it stays with that app
Typed URL, bookmark, history entry, back/forward, reload No
Service worker clients.openWindow() for an in-scope URL (for example on notification click) Eligible: Chromium explicitly allows this transition type
Right-click > Open link in App name Explicit launch through the context menu, not capturing
Link clicked in another native application while Chrome is the default browser No, in current Chromium source: URLs handed over by the OS open in a browser tab. The design notes list OS-originated launches as future work.
Server-side redirect chain that ends inside an app's scope Re-evaluated at every redirect, so a shortlink that redirects into scope can be captured, and a captured navigation that redirects out of scope returns to a browser tab

How the app and the window are chosen

flowchart TD
    A["Link click or form submit"] --> B{"New top-level context without opener?"}
    B -->|No| Z["Normal browser navigation"]
    B -->|Yes| C{"Modifier key used?"}
    C -->|"Yes, in a browser tab"| Z
    C -->|"Yes, in the app window"| M["New app window or app tab"]
    C -->|No| D{"URL in scope of an installed app?"}
    D -->|No| Z
    D -->|Yes| E["Pick the app with the most specific scope"]
    E --> F{"Opening supported links enabled for that app?"}
    F -->|No| Z
    F -->|Yes| G{"Link came from the same app's own pages?"}
    G -->|Yes| H["Force navigate-new"]
    G -->|No| I{"client_mode"}
    I -->|"auto or navigate-new"| H
    I -->|"navigate-existing"| J["Navigate most recent app window"]
    I -->|"focus-existing"| K["Focus it and enqueue LaunchParams"]
    H --> L["New app window, LaunchParams enqueued"]

The details behind the boxes, from Chromium's implementation:

  • Most specific scope wins. Every installed app with the URL in scope gets a score based on how much of the URL its scope matches, and the highest score wins. A nested app (/app/editor/ inside /app/) therefore owns its URL space. Validated scope_extensions count toward the score (by default from Chrome 143), so links to an associated origin can be captured.
  • Links from your own pages always create a new window. If the link's source tab or referrer belongs to the same app, Chromium forces navigate-new regardless of client_mode, so a target="_blank" link inside your own UI still opens a separate window, as the author evidently intended.
  • Apps that users or policy prevent from closing are always handled as focus-existing, so an existing window is never navigated away.
  • Installed without OS integration (for example, synced from another device but not yet integrated) means the app is treated as opening in a browser tab.
  • After the navigation commits, Chromium attaches launch data to the navigation, which is what enqueues LaunchParams and may show an in-product help bubble explaining that the link opened in the app.

The user's controls

Each installed app has its own setting, reachable from the app window's menu (App info > Settings) or directly at chrome://app-settings/<app-id>. Under the supported-links option the user chooses between Open in App name and Open in Chrome browser. If another installed app already captures an overlapping scope, turning it on for one app shows a "Change default app for supported links?" confirmation and turns it off for the other. There is no page-visible API that reports this setting, so do not build UI that assumes captures will happen.

Designing an app for capturing

  • Pick client_mode deliberately. A document editor usually wants navigate-new (one document per window); a chat, mail or music app wants focus-existing so that a captured link switches the conversation without killing audio or drafts.
  • Keep non-app pages out of scope. Marketing pages, help centers and sign-out URLs under your app's scope get captured too. Narrow scope (for example /app/) so captured links are always app views. The proposed scope inclusion/exclusion patterns (chromestatus "Web app manifest scope filtering", proposed in 2026) would help but are not shipped.
  • Make every in-scope URL deep-linkable. A captured link can arrive in a brand-new window. Server-render or route it without assuming prior in-memory state.
  • Test your own target="_blank" links. Inside the app window they open new app windows, which may surprise you if you use them for "open in new tab" affordances. Use rel="opener" only when you really need the opener, since auxiliary contexts are handled differently.
  • Test sign-in flows. An identity provider that redirects back into your scope after a target="_blank" login link can end up in the app window rather than the browser tab that started the flow.

A scope covers one origin. If your product spans example.com and example.co.uk, links to the second origin are not captured and navigating there from the app window shows the out-of-scope toolbar. scope_extensions lets the app claim additional origins, each of which confirms the association in /.well-known/web-app-origin-association:

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

Chrome's release notes list scope extensions in Chrome 139 (MDN's compatibility data says 138), on desktop platforms only. At launch, Chrome's documentation described cross-origin in-scope navigation on Windows, macOS, Linux and ChromeOS, but cross-origin link capturing only on ChromeOS. In Chromium source, the DesktopPWAsLinkCapturingWithScopeExtensions feature that feeds validated extensions into the capturing score is disabled by default through Chrome 142 and enabled by default from Chrome 143. On current desktop Chrome, capturing and client_mode therefore apply to the extended origins as well; on older builds, links to an extended origin open in a browser tab even though navigating there inside the app window stays in scope. Storage, service workers and permissions remain per origin. The complete syntax, validation limits (at most 10 entries, no wildcards in the shipped syntax) and migration from the origin-trial format are in Advanced & Integration Members.

Android: WebAPK intent filters

On Android, Chrome turns an installed PWA into a WebAPK: a real APK minted by Google's server, whose Android manifest is generated from your Web App Manifest. Link handling there is ordinary Android intent resolution, not a browser feature.

What the WebAPK declares

Chromium's WebAPK shell template gives the launcher activity one set of filters per scope:

WebAPK AndroidManifest.xml (Chromium template, simplified)
<intent-filter android:autoVerify="true">
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="https"
        android:host="tunes.example"
        android:pathPrefix="/app/" />
</intent-filter>
<intent-filter>
  <action android:name="android.nfc.action.NDEF_DISCOVERED" />
  <category android:name="android.intent.category.DEFAULT" />
  <data android:scheme="https"
        android:host="tunes.example"
        android:pathPrefix="/app/" />
</intent-filter>

The scheme, host and pathPrefix come from your manifest's scope (web.dev's WebAPK article shows "scope": "/app/" producing android:pathPrefix="/app/"). Two consequences follow:

  • Scope is your link filter. Everything under the prefix, including pages that are not app views, is a candidate for the WebAPK.
  • Changing scope requires a WebAPK update. Chrome regenerates the APK when it detects a manifest change, which happens on its own schedule. Until then the old filters apply. The update rules are covered in App Identity & Updates.

The WebAPK also has an NFC filter for the same URL space, so tapping an NFC tag that stores an in-scope URL can open the app, and share-target aliases when you declare share_target. WebAPKs do not declare custom-scheme filters: protocol_handlers is not supported on Android.

Where the user taps the link What happens
Another Android app, Android 11 and earlier Android resolves the VIEW intent; the WebAPK is a candidate handler for in-scope URLs, so the app opens (or a chooser appears if several apps match).
Another Android app, Android 12 and later Android 12 only routes generic web intents to apps with verified links, and a Chromium code comment notes that WebAPKs "aren't verified apps", so the intent goes to the default browser. If that browser is Chrome, Chrome recognizes that a valid WebAPK is the sole specialized handler and launches it. If the default browser is another browser, the link opens there.
A Chrome tab, link to the same host as the current page Stays in the tab.
A Chrome tab, link from another host into the WebAPK's scope Chrome's external-navigation logic can launch the WebAPK when it is the sole specialized handler.
Chrome's address bar Stays in Chrome: the user intended to visit the site.

Users can inspect and change link handling for the WebAPK in Android's per-app Open by default settings. When testing on Android 12 or later, adb shell pm get-app-links <package> shows the verification state Android recorded for a package, which helps explain why a link went to the browser.

Two related Android paths use different machinery:

  • Trusted Web Activities are native apps you publish; their link handling uses Android App Links verified through Digital Asset Links (assetlinks.json). See Trusted Web Activity and Publishing to App Stores.
  • Launch handling: every WebAPK is a single Android task, so auto behaves like navigate-existing. focus-existing is accepted, but test it on real devices before depending on it. Platform specifics are on Android.

iOS and iPadOS

Safari and every other iOS browser (all WebKit-based) implement none of the APIs on this page:

  • No navigator.registerProtocolHandler(), no protocol_handlers, no launch_handler, no launchQueue.
  • Nothing routes external links into a Home Screen web app. A link to your site tapped in Messages, Mail or another app opens in the user's default browser. iOS Universal Links exist only for native apps with an apple-app-site-association entitlement.
  • Out-of-scope navigations stay inside the app but in an in-app browser. Apple's WWDC23 session on web apps explains: "In Home Screen web apps on iOS, links outside the scope will open in Safari View Controller." In-scope navigations stay in the standalone view.

Since iOS 26 and iPadOS 26, every site added to the Home Screen opens as a web app by default (users can switch off Open as Web App), manifest or not, so these rules now apply to more sites than before. Practical consequences:

  • Make shared links work fully in the browser, and keep the user's state on your server so that it is there whichever context opens the link.
  • Do not ship web+ links to iOS users: they resolve to nothing. Generate https: links and route inside your app.
  • Web Push notifications from a Home Screen web app open that web app when tapped, which is the one reliable way to bring an iOS user from outside back into the installed app. See Web Push on iOS & Safari.

More on the platform's quirks in iOS & iPadOS.

Desktop behavior by platform and browser

Browser and platform registerProtocolHandler() Manifest protocol_handlers Links from the browser Links from other apps
Chrome, Edge on Windows, macOS, Linux ✅ (tab) ✅ OS registration, first-use dialog Captured by default (Chrome 138+/140+), user can opt out per app Custom schemes launch the app; https: links open in a tab
Chrome on ChromeOS ✅ (tab) ⚠️ see note Capturing available, off by default per app Varies with the ChromeOS app-intent system
Safari web apps on macOS (Sonoma and later) ❌ ❌ In-scope links stay in the web app; out-of-scope links open in the default browser; window.open() always opens in the web app Open in the default browser
Firefox on Windows, macOS, Linux ✅ (tab) ❌ No capturing Default browser behavior

Notes:

  • ChromeOS integrates apps through its own intent system; Advanced & Integration Members documents the current limits for ordinary PWAs versus Isolated Web Apps.
  • Safari's Mac web app behavior is quoted from Apple's WWDC23 session "What's new in web apps": "Links within the scope open within the web app", out-of-scope links open "in my default browser", and "Links loaded through window.open will always open in the web app regardless of scope."
  • Firefox 143 added web apps pinned to the Windows taskbar, enabled by default (Firefox 150 extended them to the Microsoft Store build; on Linux they sit behind browser.taskbarTabs.enabled, and macOS doesn't have them). MDN's data notes that these windows match display-mode: minimal-ui. Firefox does not implement protocol_handlers, launch_handler or capturing.

The OS-side registration details (Windows registry entries, macOS app shims, Linux .desktop files) and per-OS installation behavior are covered in Desktop Platforms.

A complete example: Tunebox

Tunebox is a music PWA that handles web+music: links in the browser and at the OS level, keeps playing when launched again, and falls back gracefully everywhere else. The manifest is the one shown in the protocol_handlers section.

The handler page

/open must work in every context: a new app window, an existing window reached through launchQueue, a browser tab reached through registerProtocolHandler(), and a browser without any of these APIs. The server renders the normal app shell for /open, and the client decides what to show.

src/main.js
import { installLaunchRouter } from "./launch-router.js";
import { markHandlerWorking } from "./register-handler.js";
import { createApp } from "./app.js";

const app = createApp(document.getElementById("app"));

// When a protocol launch parsed successfully, remember that the
// browser-level handler works so the settings page stops offering it.
app.on("route", (route, url) => {
  if (url.pathname === "/open" && route.view !== "home") {
    markHandlerWorking();
  }
});

installLaunchRouter(app);

A notification click that reuses the window

Notification clicks are not launch_handler launches, but they have the same problem: a window may already be open. Handle them in the service worker by focusing and messaging an existing client, and fall back to clients.openWindow() (which Chromium desktop may also capture into the app window):

sw.js
self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const target = new URL(
    event.notification.data?.url ?? "/",
    self.location.origin,
  );
  if (target.origin !== self.location.origin) return;

  event.waitUntil(
    (async () => {
      const windows = await self.clients.matchAll({
        type: "window",
        includeUncontrolled: true,
      });
      // Prefer a window that is already showing the app.
      const existing = windows.find(
        (client) => new URL(client.url).origin === self.location.origin,
      );
      if (existing) {
        try {
          // focus() needs the notification click's user activation and
          // rejects if the browser refuses; fall back to a new window.
          const focused = await existing.focus();
          focused.postMessage({ type: "open-url", url: target.href });
          return;
        } catch (err) {
          console.warn("WindowClient.focus() failed", err);
        }
      }
      await self.clients.openWindow(target.href);
    })(),
  );
});

On the page, route open-url messages through the same deliver() path as launches, so unsaved-work protection applies. The Clients API details are on Messaging & the Clients API, and notification specifics on Notifications API.

What each user gets

User Clicks web+music:track/42 in a native app Clicks https://tunes.example/app/track/42 in a browser tab with target="_blank"
Chrome desktop, app installed OS launches Tunebox; existing window is focused and switches track Captured; existing window focused (focus-existing)
Chrome desktop, not installed, handler registered Link opens nothing from native apps; inside Chrome a tab opens /open?uri=… New tab
Firefox desktop, handler registered Inside Firefox, a tab opens /open?uri=… New tab
Chrome on Android, WebAPK installed Scheme unsupported; nothing happens WebAPK opens if Chrome launches it (see the Android table)
iOS Home Screen web app Scheme unsupported Opens in the default browser

Browser support

Feature Chrome / Edge desktop Chrome Android Firefox desktop Firefox Android Safari macOS Safari iOS
registerProtocolHandler() ✅ 13 / 79 ❌ ✅ 2 ⚠️ ❌ ❌
unregisterProtocolHandler() ✅ 38 / 79 ❌ ❌ ❌ ❌ ❌
web+ custom schemes ✅ ❌ ✅ ⚠️ ❌ ❌
Decentralized schemes (ipfs, dweb, …) ✅ 86 ❌ ❌ ❌ ❌ ❌
Manifest protocol_handlers ✅ 96 ❌ ❌ ❌ ❌ ❌
launch_handler / client_mode ✅ 110 ⚠️ 110 ❌ ❌ ❌ ❌
window.launchQueue, LaunchParams.files ✅ 102 ❌ ❌ ❌ ❌ ❌
LaunchParams.targetURL ✅ 110 ❌ ❌ ❌ ❌ ❌
Navigation capturing (default on) ✅ 138 (client_mode apps), 140 (all) n/a (intent filters) ❌ ❌ ❌ ❌
scope_extensions ✅ 139 (capturing into extended origins 143+) ❌ ❌ ❌ ❌ ❌
In-scope link routing into installed app ✅ capturing ✅ WebAPK intent filters ❌ ❌ ❌ ❌

Support data as of September 2026. Version numbers come from MDN's browser compatibility data, chromestatus.com, Chrome release notes and Chromium source. ⚠️ for Firefox Android: MDN derives the Android entry by mirroring desktop Firefox rather than from Android-specific testing (and marks its secure-context sub-feature as unsupported there), so test on devices before relying on it. ⚠️ for Chrome Android launch_handler: MDN lists support, but single-task WebAPKs make navigate-existing the effective default; verify focus-existing on devices. Check caniuse for live data. Edge, Opera and other Chromium browsers follow the same engine versions but can configure capturing differently.

Common pitfalls

  • Registering on page load. The prompt appears without context and gets dismissed; some browsers then remember the refusal. Register from a button.
  • Assuming success. registerProtocolHandler() returns undefined whatever the user does. Never show "Done!" after calling it.
  • Using an unprefixed scheme. registerProtocolHandler("tunebox", …) and "protocol": "tunebox" are rejected. Use web+tunebox.
  • Digits or dashes in web+ names. web+music2 and web+my-music throw SecurityError.
  • Forgetting that the scheme is part of the payload. Handlers that expect track/42 receive web+music:track/42.
  • Double-decoding or not decoding. One URLSearchParams.get() returns the original invoked URL; its own escapes are still there.
  • Acting on the launch immediately. Deleting, sending or paying from a launch URL is a CSRF hole reachable from any website or app.
  • Using focus-existing without a consumer. The window is focused and nothing changes. Always install a launchQueue consumer when you choose focus-existing.
  • Processing a launch twice. A new window receives both a URL (its own location) and LaunchParams carrying the same URL, and the LaunchParams can arrive before or after your consumer is registered. Render from location once, then ignore a first launch that only repeats it.
  • Relying on reload re-delivery. Code written before Chrome 146 that expected files or URLs to come back after a reload no longer receives them. Persist what you need (file handles can go in IndexedDB).
  • Scope that captures non-app pages. On Android the WebAPK filter and on desktop navigation capturing both use scope. A scope of / captures your blog, docs and logout links.
  • Expecting links from native apps to be captured on desktop. Only custom schemes registered through protocol_handlers reach the app from other programs; https: links from other applications open in a browser tab in current Chromium.
  • Testing only one context. Test the handler page in an app window, a browser tab and a browser without the APIs.

Debugging

  • Chromium DevTools > Application > Manifest > Protocol Handlers reports whether valid registrations were found in the manifest and, once the app is installed, lets you pick a scheme, type the rest of the URL and press Test protocol to launch the app through the real OS path. Chrome then asks to open the app and shows the "Allow app to open … links?" dialog. See Browser DevTools.
  • chrome://settings/handlers lists sites registered through registerProtocolHandler() and installed apps with allowed or disallowed protocol handlers. Remove entries here to reset consent while testing.
  • chrome://app-settings/<app-id> shows the per-app link setting ("Open in App name" or "Open in Chrome browser"), protocol handler permissions and file handling. The app ID is visible in chrome://web-app-internals.
  • chrome://web-app-internals dumps installed apps, their parsed launch_handler, scope, validated scope extensions and OS integration state. Chromium also records navigation-capturing debug data there for captured navigations.
  • chrome://flags/#enable-user-navigation-capturing-pwa forces capturing on or off when you need to reproduce behavior from another configuration.
  • Log launches in your consumer (console.info("launch", params.targetURL, params.files.length)) and in the page-load fallback, and include the source in analytics so you can see how users reach the app. See Analytics for PWAs.
  • Android: adb shell pm get-app-links <package> (Android 12 and later) and the app's Open by default settings explain where a link went; chrome://webapks in Chrome for Android lists installed WebAPKs with their package names and scope.

Further reading

On this site

External references