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
idresolved against the origin ofstart_url, with the fragment removed. Ifidis missing, empty, unparsable or cross-origin, the identity is thestart_urlitself, query string included. - Without an explicit
id, changingstart_url(even just a tracking parameter) creates a different app: existing installs stop receiving updates and users can end up with two icons. Setidto the currently computed value before you change anything. scopematching is a plain string-prefix test on the path./appalso matches/application, and astart_urlof/appis not within a scope of/app/, which makes the browser silently discard yourscope.- Since Chrome 144 on desktop, every page load that links a manifest with a matching
idtriggers an update check with no daily throttle. Non-security members apply silently;nameand 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,scopeand icons captured at install keep working for years. - Chrome 150 added same-site origin migration on desktop (
migrate_from,migrate_toand a.well-known/web-app-origin-associationhandshake). 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
idequals 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:
- Set
manifest["id"]tomanifest["start_url"]. - If the type of
json["id"]is not string, return. - If
json["id"]is the empty string, return. - Let base origin be
manifest["start_url"]'s origin. - Let id be the result of parsing
json["id"]with base origin as the base URL. - If id is failure, return.
- If id is not same origin as
manifest["start_url"], return. - Set id's fragment to null.
- 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_urland withoutidproduces a different identity for every page it's linked from. A user who installs from/pricingand later from/docsgets 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, sostart_urlfalls back to the document URL, and identity again depends on the install page. - A manifest with
"start_url": "/?utm_source=homescreen"and noidhas identityhttps://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
idon 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'sidwhen the WebAPK has one recorded, and falls back to "same manifest URL or samestart_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 throughid. - iOS and iPadOS installs. WebKit added
idsupport 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:
- Serialize the processed manifest id (fragment already removed).
- 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.
- Take the SHA-256 digest of those 32 raw bytes.
- Hex-encode the first 16 bytes and map each hex digit
0–fto the lettersa–p.
#!/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
beforeinstallpromptfires, 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, newshortcuts, newshare_target, nothing. It still launches its oldstart_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:
- 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.
- Add that exact value as
id, without changing anything else, and deploy. - 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.
- Only then change
start_url.
{
"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" }
]
}
{
"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_urljust because it's convenient. The whole point ofidis thatstart_urlcan 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¶
manifest["start_url"]is set to the document URL first (the fallback).- If
json["start_url"]doesn't exist, isn't a string, or is empty, stop. - Parse it with the manifest URL as base. On failure, stop.
- If the result isn't same origin as the document URL, stop.
- Otherwise use it.
Three consequences:
- Relative
start_urlvalues 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_urlvalues 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:
{
"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_urlonly 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, includingdisplay-modemedia 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.
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¶
- Set
manifest["scope"]to the result of parsing"."withstart_urlas base, which isstart_urlwith its last path segment, query and fragment removed. This is the default scope. - If
json["scope"]is the empty string, return. - Parse
json["scope"]with the manifest URL as base. On failure, return. - Set the parsed scope's query and fragment to null.
- If
start_urlis not within scope of the parsed scope, return (keep the default). - 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:
/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/).
Navigation scope versus service worker scope¶
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.
Scope and Android link capturing¶
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_nameandicons, plus their localized variantsname_localized,short_name_localizedandicons_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
srcchanged, which the specification likens toCache-Control: immutable(RFC 8246). Ifsrcdidn'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
*_localizedvalues.
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-throttlecommand-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>onstart_urland 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.
Changing identity-related members safely¶
| 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:
- Make sure the new
start_urlis inside the new scope, or the new scope is discarded. - Make sure every
shortcuts[].url,share_target.action, file handleractionand protocol handler URL is inside the new scope. Out-of-scope shortcuts are dropped by the parser, not flagged as errors. - Update the service worker registration so its scope still contains the new manifest scope.
- 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:
{
"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://social.example.com/": {
"allow_migration": true
}
}
{
"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 explicitid. Entries can be strings (the old app'sid) or objects withid, an optionalbehaviorand an optionalinstall_url.- The
.well-known/web-app-origin-associationfile on the old origin confirms the move by listing the new app'sidwith"allow_migration": true. It authorizes any app on the old origin to migrate to thatid, 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 anynameor icon change to a normal, ignorable update afterwards.install_urlpoints 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
WebAppInstallForceListpolicy 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:
- A page links the new manifest with
migrate_from. Chrome schedules a migration task that, for installed apps named inmigrate_from(and confirmed by the.well-knownfile 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 matchingmigrate_fromentry has aninstall_url, Chrome also fetches the old app's manifest frominstall_urland runs a normal update on the old app. That's what keeps the old app updatable after the redirects start. - 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:
- Launch the new origin with its own manifest and a stable
id. - On the old origin, keep serving the app, but detect installed launches (
matchMedia("(display-mode: standalone)"), or astart_urlparameter) 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. - Move user data through your backend, keyed by account, since storage can't cross origins.
- 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.
#!/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.
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-internalsshows 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-throttlewhen testing the silent small-icon path repeatedly. - Android:
about://webapkslists 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¶
-
idis explicit and equals the identity your installed users already have - The CI identity check passes for every page that links the manifest
-
start_urlis insidescope, 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_urlloads offline with its query parameters - Changed icons have new URLs; old icon and manifest URLs still respond
- Old
start_urland 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. Addidequal to the computed identity first. - Setting
idto a "nicer" value on an existing app."id": "/"when the computed identity washttps://example.com/?source=pwais itself an identity change. - Relative
start_urlin a manifest stored in a subdirectory../resolves against the manifest's folder, not the site root. scope: "/app/"withstart_url: "/app". The scope is discarded and the whole origin becomes the scope.- Scope without a trailing slash.
/appcaptures/applicationand/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
- Members Reference: every manifest member and its processing rules
- Icons & Maskable Icons: building an icon set whose URLs version cleanly
- Installability Criteria: what Chromium checks before offering install, and during updates
- Registration & Scope: service worker scope and
Service-Worker-Allowed - Advanced & Integration Members:
scope_extensions,launch_handlerand handlers - App Shortcuts: shortcut URLs must stay inside
scope - Installation by Platform: how each platform installs and stores apps
- Android: WebAPKs in depth
External references
- Web Application Manifest (W3C Working Draft):
id,start_url,scope, navigation scope and updating - Uniquely identifying PWAs with the manifest id: Chrome's guidance on computing and adopting
id - A better way to update your web apps: the Chrome 144 desktop update model
- How Chrome handles updates to the web app manifest: the earlier throttled model and Android fields
- Seamless PWA origin migration:
migrate_from,migrate_toin Chrome 150 - PWA origin migration explainer and predictable app updating explainer
- WebKit Features in Safari 16.4 (
idon iOS) and Safari 17.0 (web apps on Mac) - Firefox 143 release notes: web apps pinned to the Windows taskbar
-
Parsed but has no effect. Firefox 143+ web apps on Windows use a Firefox-generated UUID as identity. ↩
-
MDN lists
scopeandstart_urlas unsupported in desktop Firefox, but Firefox's Windows web apps read both from the manifest when the app is created. ↩ -
No documented mechanism applies later manifest changes to an existing Home Screen or Dock web app. ↩↩
-
Chrome's announcement and chromestatus give Chrome 150 for the shipped feature; MDN's compatibility data lists
migrate_fromandmigrate_toin Chrome and Edge from 149. Test on the exact versions you support. ↩