Skip to content

App Identity & Updates

An installed PWA's identity is a single URL: the processed value of the manifest's id member, which defaults to the start_url when you don't set it. Every browser that supports id uses that value to decide whether a manifest describes an app the user already installed (and should be updated) or a new app (and should be installed alongside the old one). Getting identity, start_url and scope right on day one is what lets you rename the app, move its entry point, change icons or even move it to another origin later without stranding users on a dead copy. This page covers the processing algorithms at spec level, the exact update pipelines in Chromium on desktop and Android, what Safari and Firefox do, and how to migrate an installed app safely.

Key takeaways

  • The identity is id resolved against the origin of start_url, with the fragment removed. If id is missing, empty, unparsable or cross-origin, the identity is the start_url itself, query string included.
  • Without an explicit id, changing start_url (even just a tracking parameter) creates a different app: existing installs stop receiving updates and users can end up with two icons. Set id to the currently computed value before you change anything.
  • scope matching is a plain string-prefix test on the path. /app also matches /application, and a start_url of /app is not within a scope of /app/, which makes the browser silently discard your scope.
  • Since Chrome 144 on desktop, every page load that links a manifest with a matching id triggers an update check with no daily throttle. Non-security members apply silently; name and icon changes wait for the user to review them from the app menu. Icons are compared by manifest entries (URL, size, purpose), not by bytes.
  • Chrome on Android checks at most once a day, only while the WebAPK is open on an in-scope page that links the matching manifest, and ships the new WebAPK in a background job that needs an unmetered network and a charging device.
  • Safari and Firefox treat install-time metadata as largely fixed. Design so that the start_url, scope and icons captured at install keep working for years.
  • Chrome 150 added same-site origin migration on desktop (migrate_from, migrate_to and a .well-known/web-app-origin-association handshake). Storage and permissions do not move with the app.

How the browser identifies an installed app

The W3C Web Application Manifest (Working Draft, 13 August 2026) defines the id member as "a string that represents the identity for the application", taking "the form of a URL, which is same origin as the start URL". Two rules follow from the specification's text:

  • When a user agent sees a manifest whose identity doesn't correspond to an installed app, it SHOULD treat it as a distinct application, even if it is served from the same URL as another app's manifest.
  • When it sees a manifest whose id equals the identity of an installed app, it SHOULD treat the manifest as a replacement for that app's manifest, even if it is served from a different URL than before.

In other words, identity is not the manifest file's URL, not the page you installed from, and not the name. It is the processed id.

The id processing algorithm, step by step

Processing depends on start_url, so the browser always processes start_url first. The specification's steps for id are:

  1. Set manifest["id"] to manifest["start_url"].
  2. If the type of json["id"] is not string, return.
  3. If json["id"] is the empty string, return.
  4. Let base origin be manifest["start_url"]'s origin.
  5. Let id be the result of parsing json["id"] with base origin as the base URL.
  6. If id is failure, return.
  7. If id is not same origin as manifest["start_url"], return.
  8. Set id's fragment to null.
  9. Set manifest["id"] to id.

Step 4 is the one that surprises people: id is resolved against the origin, not against the manifest URL and not against start_url's directory. "foo", "./foo", "../foo" and "/foo" all resolve to https://example.com/foo, which is why the specification recommends writing id with a leading /.

flowchart TD
    A["Process start_url first"] --> B{"json.id is a non-empty string?"}
    B -- No --> F["id = start_url"]
    B -- Yes --> C["Parse json.id against start_url's origin"]
    C --> D{"Parse OK and same origin as start_url?"}
    D -- No --> F
    D -- Yes --> E["id = parsed URL"]
    E --> G["Drop the fragment"]
    F --> G
    G --> H["Identity used to match installed apps"]

The specification's own resolution table, extended with the cases that matter in production:

json.id processed start_url processed id Why
(absent) https://example.com/my-app/start https://example.com/my-app/start Falls back to start_url
(absent) https://example.com/my-app/#here https://example.com/my-app/ Fragment removed
"" https://example.com/my-app/start https://example.com/my-app/start Empty string is ignored
"/" https://example.com/my-app/start https://example.com/ Resolved against the origin
"foo" https://example.com/my-app/start https://example.com/foo Relative paths resolve from the origin root, not from /my-app/
"foo?x=y" https://example.com/my-app/start https://example.com/foo?x=y The query is kept and is part of identity
"foo#heading" https://example.com/my-app/start https://example.com/foo Fragment removed
"https://anothersite.com/foo" https://example.com/my-app/start https://example.com/my-app/start Cross-origin id is ignored
"😀" https://example.com/my-app/start https://example.com/%F0%9F%98%80 Standard URL percent-encoding
42 https://example.com/my-app/start https://example.com/my-app/start Not a string, ignored

Chromium's parser (ManifestParser::ParseId in Blink) implements exactly this: it resolves id against start_url's origin, rejects anything that isn't same-origin with the document, falls back to start_url otherwise, and strips the fragment in both branches. Identity comparisons are exact URL comparisons, so these are all different apps: https://example.com/app, https://example.com/app/, https://example.com/App/ and https://example.com/app/?v=2.

The id is a URL, not a navigable page

The specification notes that the identity "is processed like a URL but it doesn't point to a resource that can be navigated to, so it's not required to be within scope." "id": "/" is valid for an app whose scope is /app/, and the browser never fetches the id URL. It exists only to be compared.

What happens when id is absent

With no id, identity equals the processed start_url, and start_url itself has a fallback: if it is missing, empty, unparsable or cross-origin with the document, it becomes the document URL, the page the manifest was linked from. That chain produces the worst identity bugs:

  • A manifest without start_url and without id produces a different identity for every page it's linked from. A user who installs from /pricing and later from /docs gets two apps.
  • A manifest served from a CDN origin with "start_url": "/" resolves / against the manifest URL (https://cdn.example.net/), which is cross-origin with the document, so start_url falls back to the document URL, and identity again depends on the install page.
  • A manifest with "start_url": "/?utm_source=homescreen" and no id has identity https://example.com/?utm_source=homescreen. Changing the campaign parameter changes the app.

Chrome DevTools shows the computed identity in Application > Manifest > Identity as Computed app ID, and when id is absent it adds a note ("id isn't specified in the manifest, start_url is used instead. To specify an app ID that matches the current identity, set the id field to …") with the exact value and a copy button. Chrome's own guidance on the manifest id is that the value computed by the browser is the safest source for the id you add later.

Identity is not the manifest URL, the name or the install page

Several things feel like identity but are not:

  • The manifest URL. Per spec it plays no role. Chromium on Android is the historical exception: the chromestatus entry that shipped id on desktop in Chrome 96 records that Android Chromium browsers identified PWAs by manifest URL. Current Chromium code on Android matches a WebAPK against the fetched manifest's id when the WebAPK has one recorded, and falls back to "same manifest URL or same start_url" for older WebAPKs without a recorded id (see the Android pipeline). Treat the manifest URL as permanent anyway.
  • The name. Two apps can share a name; one app can be renamed.
  • The OS-level identifier. Chromium derives an internal app ID from the manifest id and uses it for profile directories, shortcuts and chrome://web-app-internals. Android WebAPKs get a package name from the minting server. These are implementation details you can't control directly; you control them through id.
  • iOS and iPadOS installs. WebKit added id support in Safari 16.4 specifically "to support notifications and badging for multiple installs of the same web app". Users can deliberately add the same site more than once, for example once per account, and each Home Screen web app is a separate container.

How Chromium turns the id into an internal app ID

What DevTools labels Computed app ID is the processed id URL. Internally, desktop Chromium keys every installed app on a second value derived from it: a 32-character string over the letters a to p, the same shape as a Chrome extension ID. You'll meet it in chrome://web-app-internals, in crash and support reports, and in every OS entry point the browser creates, so it's worth knowing how to compute it. The algorithm (GenerateAppIdFromManifestId in Chromium's app_id_helpers.cc, plus crx_file::id_util::GenerateId) is:

  1. Serialize the processed manifest id (fragment already removed).
  2. Take its SHA-256 digest. Chromium's source notes the value is "hashed twice" for historical reasons (the input once had to be formatted like an extension public key) and must stay that way for backward compatibility.
  3. Take the SHA-256 digest of those 32 raw bytes.
  4. Hex-encode the first 16 bytes and map each hex digit 0–f to the letters a–p.
scripts/chromium-app-id.mjs
#!/usr/bin/env node
// Usage: node scripts/chromium-app-id.mjs "https://example.com/"
// Prints the ID desktop Chromium uses internally for an app whose processed
// manifest id is the given URL (resolve relative ids first; see check-manifest-identity.mjs).
import { createHash } from "node:crypto";
import process from "node:process";

export function chromiumAppId(manifestId) {
  const url = new URL(manifestId);
  url.hash = ""; // The processed id never has a fragment.
  const inner = createHash("sha256").update(url.href, "utf8").digest(); // 32 raw bytes
  const outer = createHash("sha256").update(inner).digest();
  return [...outer.subarray(0, 16).toString("hex")]
    .map((digit) => String.fromCharCode(97 + Number.parseInt(digit, 16))) // 0-f -> a-p
    .join("");
}

const input = process.argv[2];
if (!input) {
  console.error('usage: chromium-app-id "<processed manifest id URL>"');
  process.exit(2);
}
console.log(chromiumAppId(input));

Chromium's own source provides test vectors in ash/constants/web_app_id_constants.h, where preinstalled apps list the URL each ID was generated from. The script reproduces them: https://www.youtube.com/?feature=ytca gives agimnkijcaahngcdmfeangaknmldooml, and https://music.youtube.com/?source=pwa gives cinhimbnkkaeohfgghhklpknlkffjgod. Both are start_url-derived identities with a query string, which is a useful reminder that the query really is part of identity.

Where the app ID surfaces:

Place How the app ID appears
chrome://web-app-internals Each installed app's entry, with its manifest id, stored manifest data and update-check history
Windows shortcuts and jump list entries Command lines that launch the browser with --app-id=<app ID>; jump list items add --app-launch-url-for-shortcuts-menu-item=<url> (see App Shortcuts)
Linux The launcher file chrome-<app ID>-<profile directory>.desktop and its Exec lines
macOS The app shim's bundle identifier, built from the browser's bundle ID, .app. and the app ID (with the profile directory name prepended for per-profile shims)
Android Not used: a WebAPK is an APK whose package name is assigned by the WebAPK minting server

Two practical uses: confirming that two manifests really map to the same installed app (if the app IDs differ, so do the apps, which is also why https://example.com/app and https://example.com/app/ fork: different strings, different hashes), and scripting support workflows that need to find an app's OS artifacts. Treat the derivation as an implementation detail of Chromium, not a web standard; other browsers don't use it.

The duplicate-install problem

Most identity bugs come from one sequence: an app ships without id, is installed by users, and later changes start_url.

flowchart LR
    subgraph v1["Release 1"]
        M1["start_url: /?source=pwa<br/>no id"] --> I1["identity:<br/>https://example.com/?source=pwa"]
    end
    subgraph v2["Release 2"]
        M2["start_url: /app/<br/>no id"] --> I2["identity:<br/>https://example.com/app/"]
    end
    I1 -. "no match" .- I2
    U1["Installed copy A"] --> I1
    I2 --> U2["Browser offers install again: copy B"]

After release 2, the browser sees a manifest whose identity matches no installed app. From its point of view this is a new app:

  • Chromium offers installation again (the install icon reappears in the address bar and beforeinstallprompt fires, subject to the usual criteria), and a user who accepts ends up with two icons.
  • Copy A never matches a manifest again, so it stops receiving any update: new icons, new display, new shortcuts, new share_target, nothing. It still launches its old start_url, which may now 404 or redirect.
  • On Android, copy A's WebAPK keeps its old intent filters, so links may keep opening in a stale app.

The fix for the future is simple, but it has to be applied before the change, not after it.

Adopting an explicit id without forking existing installs

If your app is already in users' hands without id, the identity those users have is the old processed start_url. Freeze it:

  1. Open the app in Chrome, then DevTools > Application > Manifest. Read Computed app ID in the Identity section and copy the path-and-query suggested in the note.
  2. Add that exact value as id, without changing anything else, and deploy.
  3. Wait until the new manifest has been seen by your installed base. On desktop Chromium that happens on the next load; on Android it happens during the next daily check while the app is open (details below). Chrome's analytics can't tell you this directly, so give it weeks, not hours.
  4. Only then change start_url.
manifest.webmanifest (step 2: freeze identity, change nothing else)
{
  "id": "/?source=pwa",
  "name": "Example Notes",
  "short_name": "Notes",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "icons": [
    { "src": "/icons/notes-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/notes-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}
manifest.webmanifest (step 4: now start_url can move)
{
  "id": "/?source=pwa",
  "name": "Example Notes",
  "short_name": "Notes",
  "start_url": "/app/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "icons": [
    { "src": "/icons/notes-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/notes-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

The id looks odd, but it is only a string to compare. Resist the urge to "clean it up" to /: that is another identity change.

If the fork already happened and users have two copies, same-origin origin migration is the only mechanism that merges them, and only in Chromium on desktop.

Choosing an id for a new app

For an app that has never shipped, choose an id that you will never need to change:

  • Use a root-relative path with a leading slash, per the specification's recommendation. "/" is fine for the only app on an origin. For several apps on one origin, use stable paths such as "/mail/" and "/calendar/", or opaque but stable values like "/?app=calendar".
  • Don't reuse start_url just because it's convenient. The whole point of id is that start_url can change.
  • Don't encode versions, environments or campaigns. "/v2/" guarantees a future fork.
  • Don't encode users or tenants. Per-tenant identities ("/?tenant=acme") are legitimate only if you really want each tenant to be a separate installable app with separate icons.
  • Staging and production are different origins, therefore different apps. That's usually what you want; don't try to share identity across them.

start_url in depth

start_url is the URL the browser loads when the user launches the app from its icon. The specification calls it "purely advisory": a user agent may ignore it or let the user edit it, and macOS Safari's Add to Dock sheet lets users adjust the URL. The launch algorithm reflects that: it navigates the new application context to the start URL, with a note that this "is not necessarily the value of the start_url member: the user or user agent could have changed it when the application was installed". When the app is opened through a deep link instead (a shortcut, a captured link, a notification click), the user agent navigates straight to that link, with history handling set to "replace", and start_url isn't loaded at all. Two consequences: never assume every session begins at start_url (initialize state on every route), and never assume the value in your current manifest is the one an installed copy uses.

How start_url is resolved

  1. manifest["start_url"] is set to the document URL first (the fallback).
  2. If json["start_url"] doesn't exist, isn't a string, or is empty, stop.
  3. Parse it with the manifest URL as base. On failure, stop.
  4. If the result isn't same origin as the document URL, stop.
  5. Otherwise use it.

Three consequences:

  • Relative start_url values resolve against the manifest's location. With the manifest at /assets/manifest.webmanifest, "start_url": "./" means /assets/, which is almost never what you want. Use root-relative values ("/app/") or keep the manifest at the root.
  • A manifest served from another origin must use absolute start_url values on the document's origin, or every relative URL fails the same-origin check. Cross-origin manifests also need CORS; see Members Reference.
  • The fallback to the document URL is silent apart from a console/DevTools warning ("property 'start_url' ignored, should be same origin as document"). Test the processed value, not the JSON.

Tracking parameters: safe with id, dangerous without

A query parameter on start_url is the standard way to attribute launches from the installed app in analytics:

manifest.webmanifest
{
  "id": "/",
  "start_url": "/?utm_source=pwa&utm_medium=homescreen",
  "scope": "/"
}

With an explicit id, you can change the parameters at any time; the change is an ordinary non-security update. Without id, the parameters are part of identity. Two more details:

  • Your server, CDN cache keys and service worker must treat the tracked URL as the same page as /. Otherwise the offline fallback misses (see below) and the CDN stores a second copy of the HTML.
  • start_url only covers icon launches. Shortcuts, share targets, file handlers and protocol handlers launch other URLs, so tag those separately (see App Shortcuts). For measuring installed usage in general, including display-mode media queries, see Analytics for PWAs.

start_url must be within scope

scope processing checks that start_url is within the declared scope. If it isn't, the declared scope is thrown away and the default scope (the directory of start_url) is used instead. This interaction is covered in When your scope is silently ignored.

start_url must work offline

The launch is the first impression of an installed app and the most common offline scenario: someone taps the icon on a train. The browser navigates to start_url exactly, tracking parameters included, so the service worker's navigation handling must answer that URL without the network.

sw.js
const SHELL_CACHE = "shell-v12";
const APP_PREFIX = "/app/";
const START_PATH = "/app/";
const OFFLINE_PAGE = "/app/offline.html";
// Parameters that only exist for attribution and never change the HTML.
const ATTRIBUTION_PARAMS = ["utm_source", "utm_medium", "utm_campaign", "source"];

self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(SHELL_CACHE);
      // Store the canonical start URL, not the tracked one: the fetch handler
      // strips attribution parameters before looking it up.
      await cache.addAll([START_PATH, OFFLINE_PAGE]);
      // Activating immediately is safe here only because this worker caches
      // nothing but the shell and offline page: open pages never request
      // assets that a new version removed. See the caveat below the sample.
      await self.skipWaiting();
    })(),
  );
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      if (self.registration.navigationPreload) {
        await self.registration.navigationPreload.enable();
      }
      // Delete shell caches from previous versions.
      const keys = await caches.keys();
      await Promise.all(
        keys
          .filter((key) => key.startsWith("shell-") && key !== SHELL_CACHE)
          .map((key) => caches.delete(key)),
      );
      await self.clients.claim();
    })(),
  );
});

function canonicalNavigationUrl(requestUrl) {
  const url = new URL(requestUrl);
  for (const param of ATTRIBUTION_PARAMS) url.searchParams.delete(param);
  url.hash = "";
  return url.href;
}

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.mode !== "navigate") return;

  const url = new URL(request.url);
  // Only handle navigations inside the app; let the rest of the site hit the network.
  if (url.origin !== self.location.origin || !url.pathname.startsWith(APP_PREFIX)) return;

  event.respondWith(
    (async () => {
      try {
        // Navigation preload response if enabled, otherwise a normal fetch.
        const preloaded = await event.preloadResponse;
        if (preloaded) return preloaded;
        return await fetch(request);
      } catch {
        const cache = await caches.open(SHELL_CACHE);
        return (
          (await cache.match(canonicalNavigationUrl(request.url))) ??
          (await cache.match(START_PATH)) ??
          (await cache.match(OFFLINE_PAGE)) ??
          Response.error()
        );
      }
    })(),
  );
});

The unconditional skipWaiting() is a deliberate shortcut for this minimal worker. If your worker also caches versioned JavaScript or CSS, open pages running the old version can request files the new version deleted; in that case let the new worker wait, or activate it on a user action, as described in Lifecycle and skipWaiting() breaking lazy-loaded chunks.

cache.match() compares the full URL including the query, so looking up the tracked URL directly would miss. Stripping known attribution parameters is safer than { ignoreSearch: true }, which would also conflate /app/?doc=1 and /app/?doc=2. For fuller treatments of navigation strategies and offline fallbacks, see Caching Strategies, Offline UX & Fallbacks and Navigation Preload.

Never put user identifiers in start_url

The specification has a privacy section on exactly this: a start_url such as /?user=123 or https://user123.example.com/ is a fingerprint "that is not cleared when the user clears site data". It recommends that user agents let users inspect and modify the start URL, and allows them to offer uninstalling apps when the user clears an origin's data. Keep per-user state in storage or cookies, where the user's privacy controls reach it. See Privacy & Storage Partitioning.

Scope semantics

scope defines the navigation scope: the set of URLs that belong to the app. It drives whether a navigation stays in the standalone window, which URLs shortcuts and other manifest members may point to, which links an installed app captures on Android, and (in Safari on macOS Sequoia and later) which links open in a Dock web app.

How scope is processed

  1. Set manifest["scope"] to the result of parsing "." with start_url as base, which is start_url with its last path segment, query and fragment removed. This is the default scope.
  2. If json["scope"] is the empty string, return.
  3. Parse json["scope"] with the manifest URL as base. On failure, return.
  4. Set the parsed scope's query and fragment to null.
  5. If start_url is not within scope of the parsed scope, return (keep the default).
  6. Otherwise use the parsed scope.

The specification's informative text recommends always declaring scope, "preferably set to /", because the default depends on start_url and, if that falls back, on whichever page the user installed from.

Prefix matching and the trailing-slash trap

Within scope is defined as: same origin, and the target's path (segments joined with /) starts with the scope's path. It's a string prefix test, "intentional for consistency with Service Workers", and the specification explicitly warns: "To avoid unexpected behavior, use a scope ending in a /."

scope Target URL Within scope? Note
/app/ /app/ ✅
/app/ /app/inbox?id=1#top ✅ Query and fragment don't take part
/app/ /app ❌ /app doesn't start with /app/
/app/ /APP/inbox ❌ Paths are case-sensitive
/app /app/inbox ✅
/app /application/pricing ✅ The prefix trap: probably not what you meant
/app /app.css ✅ Also matches files
/ anything on the origin ✅
/app/ https://www.example.com/app/ from https://example.com ❌ Different origin
/app/%7Euser/ /app/~user/ ❌ Compared after URL parsing, and ~ is not re-encoded to %7E

The last row is a reminder that comparison happens on the serialized, parsed path. Write scope, start_url and links with identical encoding.

When your scope is silently ignored

Step 5 above throws away a scope that doesn't contain start_url, and the replacement is the default scope derived from start_url. The classic trap:

manifest.webmanifest (broken)
{
  "id": "/",
  "start_url": "/app",
  "scope": "/app/"
}

/app is not within /app/, so the scope is discarded. The default scope is new URL(".", "https://example.com/app"), which is https://example.com/: the entire origin, the opposite of what was intended. Chromium logs "property 'scope' ignored. Start url should be within scope of scope URL." and DevTools shows the processed scope. Fix it by making start_url /app/ (and making the server answer that URL).

The same step bites when start_url falls back to the document URL: a scope of /app/ with an invalid start_url is kept when the user installs from /app/settings and discarded when they install from / (the document URL / isn't within /app/).

Both are string-prefix scopes on the same origin, and both default to "the directory of something", but they're independent:

Manifest scope Service worker scope
Defined by scope member, default is start_url's directory register(url, { scope }), default is the script's directory
Upper bound Must contain start_url Can't be above the script's directory unless the response sends Service-Worker-Allowed
Controls App window membership, link capturing, which URLs shortcuts, share targets and handlers may use Which clients the worker controls and whose fetch events it receives
Changes take effect Through a manifest update (see below) On the next registration

What you want is for the service worker's scope to contain the manifest scope, so every page of the app, starting with start_url, is controlled and can load offline. The common failure is a worker served from /app/js/sw.js with the default scope /app/js/, which controls nothing the user navigates to. Register it from the root or send Service-Worker-Allowed: /app/. Details in Registration & Scope.

What happens when the app leaves its scope

Current specification text says user agents are "no longer required or allowed to block off-scope navigations, or open them in a new top-level traversable", because blocking broke third-party sign-in flows. Instead, when the active document is out of scope the user agent SHOULD show prominent UI with the URL or at least its origin and whether the connection is secure. In practice:

  • Chromium on desktop keeps the navigation in the app window and shows a toolbar with the origin and a close button.
  • Chrome on Android keeps the navigation in the WebAPK's activity and shows a Custom-Tab-style toolbar with the origin.
  • Safari handles off-scope navigation differently on iOS and macOS and has changed it across releases, so test sign-in redirects and payment flows on the Apple platforms you support rather than assuming Chromium's behavior. See iOS & iPadOS.

Scope therefore isn't a security boundary. It is a presentation and link-routing boundary.

A WebAPK's Android intent filters are generated from the scope. web.dev's WebAPK article gives the example that "scope": "/app/" produces an intent filter with android:pathPrefix="/app/", so links to https://example.com/app/... from other apps open in the installed app. A too-wide scope captures links that aren't app pages (marketing, docs, logout); a too-narrow one sends app links to the browser. Scope changes on Android only take effect after a WebAPK update, which is slow (see below). On desktop, link handling is a separate set of features; see Protocol Handlers & Launch Handling.

When one prefix isn't enough

The standard scope is a single path prefix on a single origin. Two Chromium proposals extend it: scope_extensions, which lets an app claim other origins that confirm the association in a .well-known/web-app-origin-association file, and scope inclusions/exclusions based on URL patterns. Both are covered in Advanced & Integration Members. Neither is cross-browser, so design your primary scope as if they didn't exist.

How manifest updates are detected and applied

An installed app's metadata is a snapshot: the browser (or on Android, a generated APK) stores the name, icons, start_url, scope, colors, shortcuts and handlers at install time. An update is the browser noticing that the live manifest differs from the snapshot and replacing it. Nothing in the web platform lets your code trigger or observe this; you influence it only through what the manifest contains and which pages link it.

What the specification requires

The specification's Updating the manifest section is short but sets the model every implementation now follows:

  • The manifest "is fetched and processed on every page load". When processing succeeds, user agents MAY apply the updated manifest to current and future application contexts.
  • name, short_name and icons, plus their localized variants name_localized, short_name_localized and icons_localized, are security-sensitive members, "as they are presented during installation and on launch surfaces". Every other member is non-security-sensitive.
  • The user agent SHOULD apply non-security-sensitive updates immediately, and SHOULD present security-sensitive updates to the user and "require express permission" first. The suggested choices are accept, uninstall, or ignore.
  • The user agent SHOULD consider an image resource updated if its src changed, which the specification likens to Cache-Control: immutable (RFC 8246). If src didn't change, it MAY still download the image and check for visual differences, and it MAY treat an icon that isn't "significantly visually different" as a non-security update.
  • Security-sensitive values SHOULD be displayed bidirectionally isolated, and if the user changes locale the user agent MAY switch launch surfaces to the matching *_localized values.

Why the split exists: a compromised or sold domain could otherwise turn a trusted "Bank" icon into something else without the user noticing. Everything that only changes behavior inside the app's own origin is safe to apply silently.

Chromium on desktop (Chrome 144 and later)

Chrome rebuilt its desktop update pipeline around that model. The Chrome team's announcement (January 2026) describes it as applying from Chrome 144; the feature ("Web App Manifest: specify update eligibility") is listed in the Chrome 143 release notes, and its intent-to-ship scoped it to desktop only. The Chromium source (ManifestUpdateManager, ManifestSilentUpdateCommand, ManifestUpdateJob, WebAppComparison) shows the mechanics described here.

When a check runs. Whenever a page becomes the primary page of a tab or an app window and links a manifest, Chrome computes the app ID from that manifest's id. If an installed app has that ID, an update check is scheduled. There is no daily throttle, no requirement that the app window be the one loading the page, and no requirement that the page be start_url. Any page, in any tab, that links the manifest with the matching id triggers it.

What must be true for the check to succeed. The manifest has to pass Chromium's installability criteria for a manifest (valid, with a usable icon; display is ignored for this check) and must have a non-empty icons list. A manifest that fails those checks never updates anything, silently. If the user navigates away before the check finishes, it's abandoned (kUserNavigated) and retried on a later load.

How the comparison works. Chrome compares four groups: the name, the icon metadata, the shortcut menu items, and "other fields". Icons are compared by their manifest entries (URL, size, purpose), not by downloading them, so an unchanged icons array costs no network requests.

sequenceDiagram
    participant Page as Page (tab or app window)
    participant WA as Chromium web app system
    participant Origin as Your server
    participant User
    Page->>WA: Primary page loads and links a manifest
    WA->>WA: App ID from manifest id matches an installed app?
    WA->>WA: Validate manifest (installable, non-empty icons)
    WA->>WA: Compare name, icon entries, shortcuts, other fields
    alt Nothing changed
        WA-->>Page: No-op
    else Only non-security fields changed
        WA->>WA: Apply immediately (silent)
    else Icon entries changed
        WA->>Origin: Download the new icons
        WA->>WA: Pixel-compare old and new icon at 96 px
        alt Under 10 percent different and not throttled
            WA->>WA: Apply icons silently (at most once per 24 h)
        else Larger difference
            WA->>WA: Store as pending update
        end
    end
    opt Name changed or icon pending
        WA-->>User: "Review app update" suggestion in the app menu
        User->>WA: Accept, ignore, or uninstall
    end
Member(s) Desktop Chromium 144+ behavior
name (or short_name when name is absent) Stored as a pending update; the user reviews it from the app window's menu and can ignore it
icons with unchanged entries Not downloaded, not compared: treated as unchanged even if the bytes behind the URL changed
icons with changed entries Downloaded and compared. Less than 10% pixel difference is applied silently (throttled to once per 24 hours); otherwise pending user review
shortcuts Silent. Changed shortcut icons are downloaded; unchanged ones are reused from disk
start_url, scope, display, display_override, theme_color, background_color and their dark-scheme variants, share_target, protocol_handlers, file_handlers, launch_handler, note_taking, scope_extensions, related_applications, tab_strip, migrate_from Silent, applied immediately
id Can't change: a different id is a different app

Some consequences:

  • Replacing an icon file in place does nothing on desktop. Publish new icons under new URLs (/icons/notes-v3-512.png) and update the manifest. Content-hashed filenames from your build tool do this automatically.
  • Accidental icon churn is cheap. CDN re-encoding or a lossless optimization pass that changes bytes but not URLs never reaches users. If you do change URLs for a cosmetically identical icon, the under-10% path applies it without bothering the user.
  • Users may decline. An ignored name or icon change leaves the old identity in place indefinitely while every other field keeps updating. Don't ship a rename that requires the new name to be accurate (for example, a legal rebrand) without also communicating it in the app.
  • Some installs are trusted. Chromium lets installs from trusted sources (preinstalled apps, apps installed by enterprise policy or kiosk configuration, and OEM installs) update name and icons silently.
  • The pixel comparison can be exercised immediately in testing with the --bypass-small-icon-diff-throttle command-line switch.
Before Chrome 144: the throttled model

Earlier Chrome versions, still found on managed desktops that pin old releases, behaved differently. web.dev's manifest update article (last updated September 2024) documents it: on launch or when the app was opened in a tab, Chrome fetched the manifest only if it hadn't been checked since the browser started or in the last 24 hours; changes were queued and installed after all the app's windows closed; start_url changes required an id; and desktop icon changes weren't applied at all at the time. Name and icon changes were later gated by a blocking dialog that forced the user to accept or uninstall. If a meaningful share of your users run old Chromium builds, expect updates to lag by a day or more and icons to lag indefinitely.

Chrome on Android: WebAPK updates

On Android, an installed PWA is a WebAPK: a real APK minted by Google's WebAPK server and installed through Google Play services, whose Android manifest bakes in the name, icons, colors, scope-based intent filters and shortcuts. Updating means minting and installing a new APK, which is why the Android pipeline is slower and more conditional. The following is from the Chromium source (WebApkUpdateManager, WebApkUpdateDataFetcher, WebappDataStorage, ShortcutInfo).

flowchart TD
    L["User launches the WebAPK"] --> Q{"Last manifest check at least 1 day ago?<br/>(30 days if the WebAPK server relaxed updates)"}
    Q -- No --> X["No check this launch"]
    Q -- Yes --> W["Watch page loads in the WebAPK window"]
    W --> S{"Committed URL starts with the WebAPK scope?"}
    S -- No --> W
    S -- Yes --> M{"Page links an installable manifest<br/>with the same id?"}
    M -- No --> W
    M -- Yes --> R["Compute update reasons"]
    W -. "30 s timeout" .-> T["Check recorded with no manifest"]
    R --> N{"Any reasons?"}
    N -- No --> X
    N -- Yes --> D{"name or short_name changed?"}
    D -- Yes --> DLG["Confirmation dialog"]
    D -- No --> J
    DLG -- Declined --> X
    DLG -- Accepted --> J["Write update request to disk"]
    J --> B["Background job: unmetered network + charging, 1 to 23 h window"]
    B --> SV["WebAPK server mints new APK"]
    SV --> P["Google Play services installs it while the app isn't running"]

When a check runs. Only when the WebAPK itself is launched, and only if at least UPDATE_INTERVAL (one day) has passed since the last check. If the WebAPK server's response to the last update request set its relax_updates flag, Chrome stores that and the interval becomes 30 days (RELAXED_UPDATE_INTERVAL in WebappDataStorage); web.dev's summary of this is that "if Chrome is unable to get an updated manifest from the server, it may increase the time between checks to 30 days". about://webapks has an Update button that forces the next check. A WebAPK whose shell APK is outdated (or more than 360 days since its last update) also requests an update.

Which page is checked. Chrome observes loads in the WebAPK's window. For each committed URL, it only proceeds if the URL string starts with the WebAPK's scope, and only if the page links a manifest that passes the installability check with icons. If the WebAPK has a recorded manifest id, the fetched manifest's id must equal it. If it doesn't (older WebAPKs), the fetched manifest must have the same manifest URL or the same start_url. Non-matching pages are skipped and Chrome keeps watching. If no matching manifest turns up within 30 seconds, the check is recorded without a manifest.

What counts as a change. The update reasons Chromium computes are:

Reason Compared how
Primary icon Murmur2 hash of the best-matching icon bitmap versus the stored hash; if different, a pixel difference percentage is computed
Splash icon Hash comparison, only considered together with a primary icon change
Maskable vs. non-maskable primary icon Flag comparison
scope, start_url URL comparison ignoring fragments
name, short_name String comparison
background_color, theme_color and their dark variants Color comparison
orientation, display Enum comparison
share_target Structural comparison
shortcuts Count, then name, short_name, url and icon hash of each item, in order

How identity changes are handled. A name or short_name change shows a dialog comparing the old and new name and icon. Dismissing it with Back or by tapping outside counts as acceptance (the source's comment: otherwise users "can be left in a state where they always press Back and are stuck on an old version of the app forever"); only an explicit negative choice cancels. An accepted change is remembered by hash, so the same change isn't asked about twice.

Icons are stricter. If the new primary icon differs from the installed one by less than 11% (WEB_APK_ICON_UPDATE_BLOCKED_AT_PERCENTAGE is 11 and is compared against a floored percentage), it's applied silently. A larger icon change needs the icon confirmation dialog, which in current Chromium sits behind the PwaUpdateDialogForIcon feature that is disabled by default. With that feature off, a substantially different icon is not applied on Android while every other changed field is. Treat this as implementation behavior that can change in any release, and verify on a current device before planning an icon redesign.

How the new WebAPK arrives. Chrome serializes the request to disk and schedules a background task with an unmetered network requirement, a charging requirement, and an execution window between 1 and 23 hours (forced updates run immediately). The request goes to the WebAPK server while the app isn't running, and the resulting APK is installed by Google Play services.

Practical rules for Android:

  • A user who never opens the installed app never gets an update. A user who opens it on mobile data only, or never charges on Wi-Fi, gets it late.
  • Put the manifest <link> on start_url and on every in-scope page. A launch that redirects to an out-of-scope login page and back delays detection until an in-scope page with the manifest loads within the 30-second window.
  • Scope and shortcut changes land with the WebAPK update, often days later. Server-side redirects are the only instant tool.

Safari and WebKit

Safari on iOS and iPadOS has supported the manifest since 2018 (iOS 11.3 in MDN's compatibility data) and the id member since 16.4. Since iOS 26 and iPadOS 26, every site the user adds to the Home Screen opens as a web app by default, with or without a manifest, and the user can switch that off in the Add to Home Screen sheet. On macOS, Safari 17 (macOS Sonoma) introduced Add to Dock, where the user can adjust the name, the icon and, as WebKit's Safari 18 announcement describes, the URL. When a Dock web app is created, Safari copies the site's cookies into it and nothing else. From then on the web app has its own storage.

WebKit has not documented any mechanism that applies later manifest changes to an already-added Home Screen or Dock web app, and the user can override name, icon and URL at install time in any case. chromestatus records WebKit's position on the Chromium update-eligibility work as positive, but a position isn't an implementation. Plan on the name, icon, start_url and scope captured at install persisting until the user removes and re-adds the app, and keep old start_url values working for as long as you have Safari users. iOS & iPadOS and Web Push on iOS & Safari cover the rest of the Apple-specific model.

Firefox

Firefox 143 (September 2025) added web apps on Windows: sites pinned to the taskbar as simplified windows, called Taskbar Tabs in Firefox's source. They're keyed by a Firefox-generated UUID, stored in taskbartabs/taskbartabs.json in the profile, not by the manifest id (MDN's compatibility data lists id in desktop Firefox as parsed but without effect). At creation, Firefox takes the scope's host and path prefix, the start_url and the name from the manifest when present, and otherwise uses the page's origin. Navigation to a URL with the same registrable domain as the stored scope counts as in scope. The Firefox source documents no manifest update pipeline for these apps. The feature is enabled by default on Windows, behind a preference on Linux, and unavailable on macOS. At launch it excluded Firefox installed from the Microsoft Store; the Firefox 150 release notes extend web apps to those installs too.

Firefox for Android can install sites that have a manifest and honors start_url and scope; it does not support id according to MDN's compatibility data.

Other Chromium-based browsers

Microsoft Edge, Opera and Samsung Internet use Blink's manifest parser, so id, start_url and scope resolve identically. Desktop Edge shares Chromium's web app system, but each vendor ships on its own schedule and can change install and update UI, so verify update behavior in the browsers your users actually run. Samsung Internet supports id from version 17.0 per MDN's data.

Change Risk Do this
Add id to a shipped app Fork if the value differs from the computed identity Copy Computed app ID from DevTools exactly; change nothing else in that release
Change start_url Fork without id; stale launches on Safari/Firefox Ship id first; keep the old URL working (redirect or serve it) for years
Widen scope Captures unintended links on Android; more pages in the app window Check which paths fall inside; update service worker scope to match
Narrow scope Old installs launch into pages now out of scope; shortcuts outside the new scope are dropped Move start_url and shortcut URLs inside first; keep old paths serving redirects
Change name Users can decline; slow on Android Also communicate the rename in-app
Change icons Ignored on desktop unless URLs change; large changes may not apply on Android New URLs per icon version; keep changes incremental where possible
Move or rename the manifest file Older Android WebAPKs match by manifest URL; Safari and others may cache the old one Don't. If you must, keep serving the same manifest at the old URL too
Change origin Different origin, different app, different storage See the next section

Changing start_url

With id in place, a start_url change is a silent update in Chromium, but Safari, Firefox, and Chromium users who haven't loaded a page since the change keep launching the old URL. Keep old start URLs answering forever, ideally with a server-side redirect to the new one. Preserve the query string when redirecting (tracking parameters and any state), and make sure the service worker doesn't serve a cached copy of the old URL that bypasses the redirect.

Changing scope safely

Scope determines which URLs later manifest members may reference, so order matters:

  1. Make sure the new start_url is inside the new scope, or the new scope is discarded.
  2. Make sure every shortcuts[].url, share_target.action, file handler action and protocol handler URL is inside the new scope. Out-of-scope shortcuts are dropped by the parser, not flagged as errors.
  3. Update the service worker registration so its scope still contains the new manifest scope.
  4. Ship the manifest, and keep serving the old paths with redirects. Android devices will run the old scope and intent filters until their WebAPK update lands.

Renaming the app or changing icons

Use content-addressed icon URLs so an icon change is always a URL change and nothing else is. Change icons and name in the same release if you're rebranding, so the user reviews one update instead of two. Keep the old icon files available: Android computes hashes on the icon it downloads for comparison, and other browsers may refetch old URLs. Icons & Maskable Icons covers producing the icon set.

Migrating an installed app across URLs or domains

Identity is bound to an origin because id must be same-origin with start_url, which must be same-origin with the document. Moving between paths on one origin is an ordinary update; moving between origins historically meant "uninstall and reinstall".

Moves within the same origin

With a stable id, moving the app from /app/ to /workspace/ is just a start_url and scope change plus redirects. Without a stable id, or if a fork already happened, Chromium's migration mechanism also supports same-origin migrations: the new manifest names the old identity in migrate_from. Because the security context doesn't change, no .well-known file is needed, and the explainer says the browser can migrate silently.

Same-site origin migration in Chrome 150 (desktop)

Chrome 150 shipped PWA origin migration on desktop: an installed app can move to another origin on the same site (same eTLD+1), for example from https://www.example.com/social/ to https://social.example.com/. It is a two-way handshake:

https://social.example.com/manifest.webmanifest (new app, required)
{
  "name": "SocialApp",
  "id": "/",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "icons": [{ "src": "/icons/social-512.png", "sizes": "512x512", "type": "image/png" }],
  "migrate_from": [
    {
      "id": "https://www.example.com/social/",
      "behavior": "suggest",
      "install_url": "https://www.example.com/social/install.html?noredirect=true"
    }
  ]
}
https://www.example.com/.well-known/web-app-origin-association (old origin, required)
{
  "https://social.example.com/": {
    "allow_migration": true
  }
}
https://www.example.com/social/manifest.webmanifest (old app, optional)
{
  "name": "SocialApp",
  "id": "/social/",
  "start_url": "/social/start",
  "scope": "/social/",
  "display": "standalone",
  "icons": [{ "src": "/social/icons/social-512.png", "sizes": "512x512", "type": "image/png" }],
  "migrate_to": {
    "id": "https://social.example.com/",
    "install_url": "https://social.example.com/download?usp=migrate"
  }
}

The rules, from the Chrome announcement and the explainer:

  • migrate_from (on the new app) is required, and the new manifest must contain an explicit id. Entries can be strings (the old app's id) or objects with id, an optional behavior and an optional install_url.
  • The .well-known/web-app-origin-association file on the old origin confirms the move by listing the new app's id with "allow_migration": true. It authorizes any app on the old origin to migrate to that id, and it's the same file format scope extensions use.
  • migrate_to (on the old app) is optional. It lets Chrome discover the migration during an update check of the old app, without the user ever visiting the new origin.
  • behavior: "suggest" (the default) shows a passive, ignorable notification. "force" shows a blocking dialog on the next launch of the old app that only allows migrating or uninstalling. During a forced migration, Chrome applies only the URL change and defers any name or icon change to a normal, ignorable update afterwards.
  • install_url points to a page on the old origin that doesn't redirect, so Chrome can still fetch and update the old manifest after you've started redirecting everything else to the new origin.
  • Same-site only. Cross-site migration is an explicit non-goal because a compromised site could otherwise move its users to a phishing domain.
  • Enterprise: apps force-installed with the WebAppInstallForceList policy are not migrated; Chrome shows a banner instead.
  • Desktop only. The intent to ship states Android isn't targeted because its install and update mechanisms are very different.

Chromium's ManifestUpdateManager shows there are two discovery paths, both hooked into the same "a page linking a manifest just loaded" event that drives ordinary update checks:

  1. A page links the new manifest with migrate_from. Chrome schedules a migration task that, for installed apps named in migrate_from (and confirmed by the .well-known file when the origin differs), prepares the migration the user is then offered. If that page is being shown inside the old app's window (typically because you've started redirecting the old URLs), and the matching migrate_from entry has an install_url, Chrome also fetches the old app's manifest from install_url and runs a normal update on the old app. That's what keeps the old app updatable after the redirects start.
  2. A page links the old manifest with migrate_to. Chrome schedules the target app's installation as a migration, without the user ever visiting the new origin.

Matching is by app ID, computed from each id with the algorithm described above, so the id values in migrate_from and migrate_to must be the exact processed identities, fragment-free and with the same trailing slash and query as the installed apps have.

sequenceDiagram
    participant User
    participant Old as Installed app (www.example.com/social/)
    participant Chrome
    participant New as social.example.com
    User->>Old: Launch (manifest has migrate_to)
    Old->>Chrome: Manifest seen, update check
    Chrome->>New: Fetch install_url and the new manifest
    New-->>Chrome: Manifest with id and migrate_from naming the old id
    Chrome->>Old: GET /.well-known/web-app-origin-association
    Old-->>Chrome: allow_migration true for social.example.com
    Chrome-->>User: "A new version of this app is available" (suggest) or blocking dialog (force)
    User->>Chrome: Relaunch to update
    Chrome->>New: App now launches at the new start_url

Storage and permissions don't migrate

The explainer is explicit that local data (IndexedDB, Cache Storage and other storage) is out of scope, and that "in the initial version of this proposal, permissions are not migrated": the new origin starts with whatever permissions the user has granted that origin, so notification permission must be requested again. (The chromestatus summary's phrase "preserving user trust and permissions" describes the goal, not the shipped scope; the explainer lists permission migration as a possible future extension.) On the blink-dev intent-to-ship thread, the feature owner confirmed that migration leaves the state of all storage untouched, so a site that wants to carry over cookies or other state has to do it itself. Plan a server-side data handoff before you flip behavior to "force".

Cross-site moves

Moving to a different registrable domain (example.com to example.app) has no migration mechanism in any browser. The robust procedure:

  1. Launch the new origin with its own manifest and a stable id.
  2. On the old origin, keep serving the app, but detect installed launches (matchMedia("(display-mode: standalone)"), or a start_url parameter) and show an in-app notice with a link to the new origin and install instructions. Detecting Installed Apps and Install Prompts & Custom UI cover the mechanics.
  3. Move user data through your backend, keyed by account, since storage can't cross origins.
  4. After a long overlap, turn the old origin into redirects. Old installs will still launch, then land on the new origin in the app window's out-of-scope UI, which is ugly but functional.

Data doesn't move with the app

Even for same-site moves, storage is per origin: Cache Storage, IndexedDB, OPFS, localStorage and service worker registrations stay on the old origin, and storage partitioning prevents simply reading them from the new one. If you rely on local-first data, sync it to the server before migrating. See Storage Quotas & Persistence and Offline-First Data & Sync.

Testing identity and updates

Inspect the processed manifest in DevTools

In Chromium, Application > Manifest shows the processed values: the Identity section with the computed app ID (and a suggested id if you have none), the resolved start_url and scope, and any parser warnings, which are where silently dropped members show up. Firefox and Safari have far less tooling; see Browser DevTools.

Resolve identity in CI before you deploy

This script implements the specification's start_url, id and scope algorithms with the WHATWG URL parser that Node shares with browsers, and fails the build if the identity changes. Pass the manifest file, its public URL, and one or more document URLs it's linked from.

scripts/check-manifest-identity.mjs
#!/usr/bin/env node
// Usage:
//   node scripts/check-manifest-identity.mjs public/manifest.webmanifest \
//     https://example.com/manifest.webmanifest https://example.com/ https://example.com/app/settings \
//     --expect-id https://example.com/
import { readFile } from "node:fs/promises";
import process from "node:process";
import { pathToFileURL } from "node:url";

function parseUrl(input, base) {
  try {
    return new URL(input, base);
  } catch {
    return null;
  }
}

// Spec: "process the start_url member". Falls back to the document URL.
export function processStartUrl(json, manifestUrl, documentUrl) {
  const fallback = new URL(documentUrl);
  if (typeof json.start_url !== "string" || json.start_url === "") return fallback;
  const url = parseUrl(json.start_url, manifestUrl);
  if (!url || url.origin !== fallback.origin) return fallback;
  return url;
}

// Spec: "process the id member". Resolved against start_url's ORIGIN; fragment removed.
export function processId(json, startUrl) {
  let id = new URL(startUrl.href);
  if (typeof json.id === "string" && json.id !== "") {
    const parsed = parseUrl(json.id, startUrl.origin);
    if (parsed && parsed.origin === startUrl.origin) id = parsed;
  }
  id.hash = ""; // Chromium strips the fragment in both branches, as the spec's examples do.
  return id;
}

// Spec: "within scope" is a same-origin string-prefix test on the path.
export function isWithinScope(target, scope) {
  return target.origin === scope.origin && target.pathname.startsWith(scope.pathname);
}

// Spec: "process the scope member". Default is start_url's directory.
export function processScope(json, manifestUrl, startUrl) {
  const fallback = new URL(".", startUrl);
  if (typeof json.scope !== "string" || json.scope === "") return fallback;
  const scope = parseUrl(json.scope, manifestUrl);
  if (!scope) return fallback;
  scope.search = "";
  scope.hash = "";
  return isWithinScope(startUrl, scope) ? scope : fallback;
}

async function main() {
  const args = process.argv.slice(2);
  const expectIndex = args.indexOf("--expect-id");
  const expectedId = expectIndex >= 0 ? args.splice(expectIndex, 2)[1] : null;
  const [manifestPath, manifestUrl, ...documentUrls] = args;
  if (!manifestPath || !manifestUrl || documentUrls.length === 0) {
    console.error("usage: check-manifest-identity <file> <manifest-url> <document-url>... [--expect-id <url>]");
    process.exit(2);
  }

  const json = JSON.parse(await readFile(manifestPath, "utf8"));
  const results = documentUrls.map((documentUrl) => {
    const startUrl = processStartUrl(json, manifestUrl, documentUrl);
    const id = processId(json, startUrl);
    const scope = processScope(json, manifestUrl, startUrl);
    const scopeIgnored = typeof json.scope === "string" && json.scope !== "" &&
      scope.href !== parseUrl(json.scope, manifestUrl)?.href.split(/[?#]/)[0];
    return { documentUrl, startUrl: startUrl.href, id: id.href, scope: scope.href, scopeIgnored };
  });
  console.table(results);

  const problems = [];
  const ids = new Set(results.map((r) => r.id));
  if (ids.size > 1) problems.push(`identity depends on the install page: ${[...ids].join(", ")}`);
  if (expectedId && !ids.has(expectedId)) problems.push(`expected id ${expectedId}`);
  if (results.some((r) => r.scopeIgnored)) problems.push("declared scope is ignored (start_url not within it)");
  if (typeof json.id !== "string" || json.id === "") problems.push("no explicit id: start_url changes will fork installs");

  if (problems.length) {
    for (const p of problems) console.error(`✖ ${p}`);
    process.exit(1);
  }
  console.log("✔ manifest identity is stable");
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  main().catch((error) => {
    console.error(error);
    process.exit(1);
  });
}

Assert processed values in Playwright through the DevTools Protocol

The Chrome DevTools Protocol's Page.getAppManifest returns the manifest URL, the raw text, parser errors (with a critical flag) and, as an experimental field, the processed manifest including id, startUrl, scope, shortcuts and screenshots. The experimental Page.getAppId returns appId (the identity, "either from manifest's id attribute or computed from start_url") and recommendedId (the value to put in id to keep that identity), which is exactly what the DevTools Identity section displays. Because both come from Chromium's own parser, they catch everything the CI script can't, such as a manifest that fails to fetch.

tests/manifest-identity.spec.js
import { test, expect } from "@playwright/test";

// CDP sessions only exist in Chromium.
test.skip(({ browserName }) => browserName !== "chromium", "Uses the Chrome DevTools Protocol");

// The id path your installed users already have; resolved against the test origin
// so the same test runs against localhost, staging and production.
const EXPECTED_ID_PATH = "/";
const PAGES = ["/", "/app/", "/app/settings", "/pricing"];

for (const path of PAGES) {
  test(`manifest linked from ${path} keeps the same identity`, async ({ page, baseURL }) => {
    const expectedId = new URL(EXPECTED_ID_PATH, baseURL).href;
    await page.goto(new URL(path, baseURL).href);
    const cdp = await page.context().newCDPSession(page);
    const { url, errors, manifest } = await cdp.send("Page.getAppManifest");

    expect(url, "page must link a manifest").not.toBe("");
    // Any parser message means a member was dropped or ignored.
    expect(errors.map((e) => e.message)).toEqual([]);
    expect(manifest.id).toBe(expectedId);

    // Page.getAppId is what DevTools shows as "Computed app ID". recommendedId is
    // the value it suggests for the manifest's id member.
    const { appId, recommendedId } = await cdp.send("Page.getAppId");
    expect(appId).toBe(expectedId);
    if (recommendedId) {
      // Both fields are optional in the protocol; compare when present.
      expect(new URL(recommendedId, baseURL).href).toBe(expectedId);
    }

    const start = new URL(manifest.startUrl);
    const scope = new URL(manifest.scope);
    expect(start.pathname.startsWith(scope.pathname)).toBe(true);
    for (const shortcut of manifest.shortcuts ?? []) {
      expect(new URL(shortcut.url).pathname.startsWith(scope.pathname)).toBe(true);
    }
  });
}

More patterns for PWA test suites are in Automated Testing.

Observe and force update checks

  • Desktop Chromium: chrome://web-app-internals shows each installed app's stored manifest data, pending update information and recent update-check results with their debug values. Since there is no throttle in Chrome 144+, reloading any page that links the manifest triggers a check; add --bypass-small-icon-diff-throttle when testing the silent small-icon path repeatedly.
  • Android: about://webapks lists each WebAPK with its manifest data, last update check and last update result, plus an Update button that forces a check on the next launch. Remember the background job still needs Wi-Fi and a charger; plug the test device in.
  • Safari and Firefox: remove and re-add the app to test changed install-time metadata.

A release checklist for manifest changes

  • id is explicit and equals the identity your installed users already have
  • The CI identity check passes for every page that links the manifest
  • start_url is inside scope, and both end with / where they denote directories
  • Every shortcut, share target and handler URL is inside scope
  • Service worker scope contains the manifest scope, and start_url loads offline with its query parameters
  • Changed icons have new URLs; old icon and manifest URLs still respond
  • Old start_url and old in-scope paths redirect instead of returning 404
  • Rename or icon changes are communicated in-app, because users can ignore them

Common pitfalls

  • Shipping without id, then "tidying up" start_url. The single most common cause of duplicate installs. Add id equal to the computed identity first.
  • Setting id to a "nicer" value on an existing app. "id": "/" when the computed identity was https://example.com/?source=pwa is itself an identity change.
  • Relative start_url in a manifest stored in a subdirectory. ./ resolves against the manifest's folder, not the site root.
  • scope: "/app/" with start_url: "/app". The scope is discarded and the whole origin becomes the scope.
  • Scope without a trailing slash. /app captures /application and /app.css.
  • Assuming the manifest's icons update in place. Same URL, new bytes: ignored on desktop Chromium, and big changes may be skipped on Android.
  • Manifest only on the landing page. Chrome on Android only checks pages in the WebAPK window that link the manifest, and desktop Chromium only checks pages that link it. Put the <link rel="manifest"> in the shared layout.
  • Serving a manifest that fails installability checks (no usable icon, broken JSON) during an incident. Updates stop silently until it's fixed.
  • Expecting instant rollout. Android updates take at least a day and require launch, Wi-Fi and charging; Safari and Firefox don't update at all.
  • Migrating origins and assuming data follows. Storage and permissions stay behind.

Browser support

Capability Chrome / Edge desktop Chrome Android Safari macOS Safari iOS / iPadOS Firefox desktop Firefox Android Samsung Internet
id member ✅ 96 ✅ 96 ✅ 17 ✅ 16.4 ❌1 ❌ ✅ 17.0
start_url / scope ✅ ✅ ✅ 17 ✅ 11.3 ⚠️2 ✅ 79 ✅
Non-security updates applied to installed apps ✅ immediately (144+) ✅ via WebAPK update, at most daily ⚠️3 ⚠️3 ❌ ⚠️4 ⚠️4
name / icon updates with user review ✅ app menu (144+) ⚠️ name dialog; large icon changes skipped by default ❌ ❌ ❌ ❌ ⚠️4
Origin migration (migrate_from, migrate_to) ✅ 1505 ❌ ❌ ❌ ❌ ❌ ❌

Support data as of September 2026. For live data see MDN's pages for id, scope and start_url, and caniuse.

Further reading

On this site

External references


  1. Parsed but has no effect. Firefox 143+ web apps on Windows use a Firefox-generated UUID as identity. ↩

  2. MDN lists scope and start_url as unsupported in desktop Firefox, but Firefox's Windows web apps read both from the manifest when the app is created. ↩

  3. No documented mechanism applies later manifest changes to an existing Home Screen or Dock web app. ↩↩

  4. Not documented by the vendor; test on real devices. ↩↩↩

  5. Chrome's announcement and chromestatus give Chrome 150 for the shipped feature; MDN's compatibility data lists migrate_from and migrate_to in Chrome and Edge from 149. Test on the exact versions you support. ↩