App Shortcuts¶
App shortcuts are the static list of deep links a PWA declares in the shortcuts member of its manifest, which the operating system shows in the app icon's context menu: long-press on Android, the jump list on Windows, the Dock menu on macOS. They give an installed app one-tap entry points such as New message or Scan receipt, exactly like native apps. The member is simple, but each platform truncates, filters and renders the list differently, there is no API to change shortcuts at runtime, and every shortcut launch is a cold navigation your service worker must be able to answer offline. This page covers all of it with production code.
Key takeaways
- Each item needs a non-empty
nameand aurlthat resolves (against the manifest URL) to a location within the manifest'sscope. Items that fail are dropped silently, apart from a DevTools warning. - Chromium parses only the first ten array entries, counting invalid ones. Windows jump lists show up to 10, Chrome passes at most 4 to an Android WebAPK, and Android launchers generally display up to 4. Put the most important shortcut first.
- Support: Chrome for Android 84+, Chrome and Edge on Windows 85+, all Chromium desktop platforms from 96, Safari 17.4+ on macOS (File menu and Dock menu). No support in Safari on iOS/iPadOS or in Firefox.
- Shortcuts are static. Changing them requires a manifest update, which is near-instant on desktop Chromium and can take days on Android. Build "dynamic" shortcuts as stable URLs that resolve at launch time.
- Tag shortcut URLs with a query parameter for attribution, strip it after recording, and make sure the service worker serves every shortcut URL offline, parameters included.
- Give every shortcut a PNG icon of at least 96×96 with
purpose: "any"; DevTools flags shortcuts without one.
The shortcuts member¶
The Web Application Manifest defines shortcuts as "a list of shortcut items that provide access to key tasks within a web application", and leaves presentation to the platform: "How shortcuts are presented, and how many of them are shown to the user, is at the discretion of the user agent and/or operating system." It asks user agents to expose shortcuts consistently with the host OS's app-icon context menu (right-click, long press), to render them in manifest order, and allows them to truncate the list to match OS conventions or limits. Developers "are encouraged to order their shortcuts by priority, with the most critical shortcuts appearing first."
Shortcut item members¶
| Member | Required | Type | Meaning |
|---|---|---|---|
name | Yes | string | Label "as it is usually displayed to the user in a context menu". Localizable |
short_name | No | string | Label for places with "insufficient space to display the full name". Localizable |
description | No | string | Purpose of the shortcut; user agents "MAY expose this information to assistive technology". Localizable |
url | Yes | URL string | Opens when the shortcut is activated. Must be within scope |
icons | No | image resources | Same format as the top-level icons. Localizable |
Each localizable member has a *_localized counterpart (name_localized, short_name_localized, description_localized, icons_localized), covered in Localizing shortcuts.
How shortcut items are processed¶
The specification's process a shortcut algorithm returns failure, and the item is skipped, when:
- the item isn't an object;
nameis missing or the empty string;urlis missing or not a string;urlfails to parse with the manifest URL as base;- the parsed
urlisn't within scope of the processedscope.
Otherwise the item keeps url and name, plus short_name and description if they're strings, plus processed icons and localized variants. Relative URLs resolve against the manifest's location, not the page's, so "url": "new" in /static/manifest.webmanifest means /static/new. Use root-relative URLs.
Chromium's implementation (ManifestParser::ParseShortcuts in Blink) adds details the specification doesn't have:
- The first ten entries only. The loop stops at index 10 (
kMaxShortcutsSize = 10) and logs "property 'shortcuts' contains more than 10 valid elements, only the first 10 are parsed." The check is on the array index, so an invalid entry among the first ten still uses up a slot. - Names are trimmed, and a name that is empty after trimming invalidates the item.
- Warnings name the cause: "property 'url' of 'shortcut' not present." or "property 'url' ignored, should be within scope of the manifest." These appear in the console and in DevTools; nothing fails loudly.
- The within-scope test is the manifest's: a same-origin string prefix on the path. With
"scope": "/app/",/app/newqualifies and/appdoesn't (see App Identity & Updates for the trailing-slash trap).
A complete example¶
{
"id": "/",
"name": "Field Notes",
"short_name": "Notes",
"start_url": "/app/?source=pwa",
"scope": "/app/",
"display": "standalone",
"icons": [
{ "src": "/icons/notes-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/notes-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/notes-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"launch_handler": { "client_mode": ["focus-existing", "auto"] },
"shortcuts": [
{
"name": "New note",
"short_name": "New",
"description": "Start a blank note",
"url": "/app/new?shortcut=new-note",
"icons": [
{ "src": "/icons/shortcuts/new-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/icons/shortcuts/new-192.png", "sizes": "192x192", "type": "image/png" }
]
},
{
"name": "Continue last note",
"short_name": "Last note",
"description": "Open the note you edited most recently",
"url": "/app/go/recent?shortcut=recent",
"icons": [
{ "src": "/icons/shortcuts/recent-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/icons/shortcuts/recent-192.png", "sizes": "192x192", "type": "image/png" }
]
},
{
"name": "Search notes",
"short_name": "Search",
"description": "Search all notes and attachments",
"url": "/app/search?shortcut=search",
"icons": [
{ "src": "/icons/shortcuts/search-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/icons/shortcuts/search-192.png", "sizes": "192x192", "type": "image/png" }
]
},
{
"name": "Today's agenda",
"short_name": "Today",
"description": "Notes and tasks due today",
"url": "/app/today?shortcut=today",
"icons": [
{ "src": "/icons/shortcuts/today-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/icons/shortcuts/today-192.png", "sizes": "192x192", "type": "image/png" }
]
}
]
}
Four shortcuts, most important first, every url inside /app/, distinct names, a shortcut parameter for attribution, and 96 and 192 pixel PNG icons. The second shortcut is "dynamic" in the only way the web allows; see Workarounds for dynamic shortcuts.
How shortcuts surface on each platform¶
Shortcuts are rendered by the OS from data the browser registers at install (and at each manifest update). The browser decides how much of your list reaches the OS.
flowchart LR
M["manifest shortcuts[]"] --> P["Blink parser: first 10 entries,<br/>name + in-scope url required"]
P --> A["Android: first 4 baked into the WebAPK"]
P --> W["Windows: jump list Tasks, up to 10"]
P --> Mac["macOS: Dock menu (Chrome)"]
P --> L["Linux: .desktop Actions"]
P --> C["ChromeOS: shelf and launcher menu"]
S["Safari 17.4+ on macOS"] --> SM["File menu and Dock menu"] Android¶
Chrome for Android has supported shortcuts since Chrome 84. They're part of the WebAPK: Chrome copies the shortcut list into the WebAPK request, and the minted APK declares them as static Android app shortcuts. Users see them by long-pressing the app icon, and on most launchers they can drag one onto the home screen as a separate pinned shortcut.
From Chromium's source (ShortcutInfo):
- At most four.
kMaxShortcuts = 4, with a comment pointing to Android's documented limit. The list is truncated to the first four processed items. Android's own documentation says most launchers display up to four shortcuts at a time. An older web.dev article reported that Chrome for Android displayed three; plan for your first three to be the ones that matter most. - Labels. A shortcut without
short_namegetsnameas its short name, and both are sent to the WebAPK. - Icons. Chrome picks the best
purpose: "any"icon for an ideal size derived from the device's launcher shortcut icon size, and accepts icons down to half that size. The older web.dev guidance describes this as 48dp, which is 96 pixels at 2× density and 192 at 4×, hence the 96 and 192 pixel pair in the example. - Updates. Shortcut changes (count,
name,short_name,urlor icon hash, compared in order) are a reason to mint a new WebAPK, which follows the Android update pipeline: at most daily, only while the app is open on an in-scope page, and installed later in a background job that needs Wi-Fi and a charger. See App Identity & Updates.
Samsung Internet supports shortcuts from version 14.0, according to MDN's compatibility data.
Windows¶
Chrome and Edge on Windows have supported shortcuts since version 85, as jump list entries. Microsoft's documentation describes them appearing when the user right-clicks the app's taskbar icon or its Start menu tile.
Chromium's Windows implementation (web_app_shortcuts_menu_win.cc):
- Writes each shortcut's icon to disk as an
.icofile (<index>.ico) in the app's OS integration folder, because jump list items require.icoicons. - Adds up to
kMaxJumpListItems = 10entries to the jump list's Tasks section. Each entry is a shell link whose command line re-launches the browser with the same user data directory,--app-id=<app ID>(the hashed ID described in App Identity & Updates) and--app-launch-url-for-shortcuts-menu-item=<shortcut url>. The URL is baked into the jump list at registration time, which is why a changed shortcut URL needs the OS integration to be rewritten by a manifest update. - Uses
nameas the entry's title.short_nameanddescriptionaren't used on Windows. - Registers the jump list under the app's own AppUserModelID, so the entries belong to the app's taskbar button rather than the browser's.
Because the jump list is just a set of command lines, activating an entry while the app is closed starts the browser (if needed) and opens the app window at the shortcut URL; activating it while the app is open goes through the same launch path as any other app launch, including launch_handler (see Shortcuts and launch_handler on desktop).
macOS¶
In Chrome, shortcuts appear in the context menu of the app's Dock icon (MDN's data lists full desktop support, beyond Windows, from Chrome 96).
Safari added shortcuts support in Safari 17.4 on macOS Sonoma, for web apps added to the Dock. Shortcuts appear as commands in the web app's File menu and in the Dock context menu; activating one "opens the specified URL inside the web app". Because they're ordinary menu commands, users can bind keyboard shortcuts to them in System Settings > Keyboard > Keyboard Shortcuts > App Shortcuts; macOS assigns none by default.
Linux¶
Chrome on Linux writes shortcuts into the app's .desktop file as desktop entry actions, which desktop environments show in the launcher or dock context menu. The file lives in ~/.local/share/applications/ and is named chrome-<app ID>-<profile directory>.desktop in Google Chrome. From Chromium's web_app_shortcut.cc and shell_integration_linux.cc:
- Identifiers come from names. Each action's identifier is the shortcut's
namewith every character that isn't an ASCII letter, digit or-replaced by-. "Today's agenda" becomesToday-s-agenda. - Colliding names collapse. The actions are stored in a set keyed by that identifier, so two shortcuts whose names normalize to the same string ("New note" and "New-note", or two names in a non-Latin script of the same length, which both become runs of
-) produce a single action, and one of them is silently lost. - Order follows the identifier, not the manifest. Because of that set, the
Actions=key lists identifiers in sorted (byte) order. Desktop environments generally present actions in that order, so the Linux menu can differ from the manifest order you chose. - No icons. Chrome writes only
NameandExecfor each action; shortcut icons aren't used on Linux. - Each
Execline is the app's launch command plus--app-launch-url-for-shortcuts-menu-item=<url>.
For the four shortcuts in the example above, the generated file looks like this (abridged; the app ID, profile and binary path vary):
[Desktop Entry]
Version=1.0
Terminal=false
Type=Application
Name=Field Notes
Exec=/opt/google/chrome/google-chrome --profile-directory=Default --app-id=<app ID>
Icon=chrome-<app ID>-Default
StartupWMClass=crx_<app ID>
Actions=Continue-last-note;New-note;Search-notes;Today-s-agenda
[Desktop Action Continue-last-note]
Name=Continue last note
Exec=/opt/google/chrome/google-chrome --profile-directory=Default --app-id=<app ID> "--app-launch-url-for-shortcuts-menu-item=https://example.com/app/go/recent?shortcut=recent"
[Desktop Action New-note]
Name=New note
Exec=/opt/google/chrome/google-chrome --profile-directory=Default --app-id=<app ID> "--app-launch-url-for-shortcuts-menu-item=https://example.com/app/new?shortcut=new-note"
"Continue last note" sorts before "New note", although the manifest lists it second. If the order matters on Linux, choose names whose normalized forms sort in the order you want, or accept the difference.
ChromeOS¶
On ChromeOS, shortcuts appear in the context menu of the app's shelf icon and of its launcher tile (right-click, or long-press on touch). Because the browser is part of the OS shell there, no platform files such as jump lists or .desktop entries are written: the menus read the shortcut items from Chrome's own web app registry, so a desktop manifest update is visible the next time the menu opens. Two ChromeOS-specific cases:
- Apps installed from Google Play as a Trusted Web Activity are Android apps. Their long-press shortcuts come from the Android package (see Trusted Web Activity), not from the manifest the browser sees.
- Managed devices often install PWAs by policy. Those installs still read
shortcutsfrom the manifest, so test your list on a policy-installed copy if your customers are schools or enterprises.
Where shortcuts don't exist¶
- Safari on iOS and iPadOS doesn't support
shortcuts(Home Screen web apps have no long-press shortcuts). - Firefox doesn't support the member on desktop or Android, including Firefox's web apps on Windows.
- Browser tabs. Shortcuts only exist for installed apps. Users who haven't installed need an equivalent in your UI.
Limits and ordering¶
| Layer | Limit | Source |
|---|---|---|
| Specification | None; user agents may truncate | Web Application Manifest |
| Chromium parser | First 10 array entries (index-based) | kMaxShortcutsSize in Blink |
| Android WebAPK | First 4 processed shortcuts | kMaxShortcuts in ShortcutInfo |
| Android launchers | Up to 4 displayed, typically | Android developer documentation |
| Windows jump list | 10 | kMaxJumpListItems |
| Linux, macOS (Chrome) | 10 | kMaxApplicationDockMenuItems |
| Chrome DevTools | Warns above 4: "The maximum number of shortcuts is platform dependent. Some shortcuts may not be available." | DevTools manifest view |
Because every platform keeps a prefix of the list, order is your only lever. Four shortcuts is the practical cross-platform maximum; beyond that, the extra entries only appear on desktop. Never put an invalid or experimental entry near the top: in Chromium it consumes one of the ten parse slots even though it's dropped.
Scope requirements¶
A shortcut url outside the processed scope is removed during parsing, which has two practical effects:
- Scope changes can delete shortcuts. Narrowing
scopefrom/to/app/silently drops a/settingsshortcut. Check every shortcut URL wheneverscopeorstart_urlchanges (the CI script in App Identity & Updates and the test below both do). - Discarded scopes widen everything. If
start_urlisn't within your declared scope, the declared scope is ignored and the default (the directory ofstart_url) applies, which can make previously invalid shortcuts valid and vice versa.
Chromium's desktop launch code is slightly more lenient at launch time: its comments note that users and admins sometimes create custom shortcuts, so a launch URL that is out of scope but same-origin is still opened in the app, while a cross-origin one falls back to the app's start URL. Don't rely on that for manifest shortcuts, which never get that far.
Shortcuts are static¶
There is no JavaScript API for shortcuts. Native platforms have them (Android's ShortcutManager supports dynamic shortcuts pushed at runtime, and Windows jump lists support custom categories and recent items), but the web platform only has the manifest. The consequences:
- You can't add "Open Project Apollo" after the user opens that project.
- You can't reorder shortcuts by usage, show counts in labels, or hide a shortcut for logged-out users.
- Changing the list means changing the manifest and waiting for the update pipeline: on desktop Chromium the next page load that links the manifest (Chrome 144+); on Android the next daily check plus a WebAPK re-mint; on Safari, when the user re-adds the app.
Workarounds for dynamic shortcuts¶
Resolve at launch time. Keep the shortcut URL stable and let the app or service worker decide where it goes when it's opened. /app/go/recent in the example redirects to whatever the user edited last. This is the most robust pattern because nothing about the manifest changes.
Per-user manifests. You can generate the manifest on the server per user. It's fragile: manifests are cached by CDNs and browsers, the update latency on Android is days, and a mistake in the dynamic manifest (for example a missing id or icons) can stop updates entirely. Reserve it for coarse segments (plan tiers, enterprise tenants) rather than per-user content.
Complementary surfaces. Use the Badging API for counts on the app icon, notification actions for event-driven entry points, and in-app "recent" lists for personalized navigation.
Handling shortcut launches¶
A shortcut launch is a top-level navigation to the shortcut's URL in the app's window (a new one, or an existing one depending on launch_handler). From the page's perspective it's indistinguishable from any other navigation, unless you mark it.
Attributing shortcut launches¶
Add a query parameter that only shortcuts use, record it once, and remove it from the URL so reloads, bookmarks and shared links don't count as launches:
const SHORTCUT_PARAM = "shortcut";
const COLLECT_URL = "/analytics/collect";
function displayMode() {
for (const mode of ["window-controls-overlay", "standalone", "minimal-ui", "fullscreen"]) {
if (matchMedia(`(display-mode: ${mode})`).matches) return mode;
}
return "browser";
}
function send(payload) {
const body = JSON.stringify(payload);
// sendBeacon doesn't delay rendering and survives the page being closed quickly.
const queued = navigator.sendBeacon?.(COLLECT_URL, new Blob([body], { type: "application/json" }));
if (!queued) {
fetch(COLLECT_URL, {
method: "POST",
body,
keepalive: true,
headers: { "content-type": "application/json" },
}).catch(() => {
// Offline: drop it, or persist it and retry with Background Sync.
});
}
}
/** Returns a copy of `url` without the attribution marker. */
export function withoutShortcutMarker(url) {
const clean = new URL(url);
clean.searchParams.delete(SHORTCUT_PARAM);
return clean;
}
/** Records a shortcut launch for `url` and returns the URL without the marker. */
export function recordShortcutLaunch(url = new URL(location.href)) {
const shortcut = url.searchParams.get(SHORTCUT_PARAM);
if (!shortcut) return url;
send({
event: "app_launch",
source: "shortcut",
shortcut,
path: url.pathname,
displayMode: displayMode(),
timestamp: Date.now(),
});
return withoutShortcutMarker(url);
}
// The URL this document was loaded with, captured before the marker is stripped.
// launch-queue.js uses it to recognize the launch that created this document.
export const initialDocumentUrl = location.href;
// Cold launch: the shortcut URL is the document URL.
const cleaned = recordShortcutLaunch();
if (cleaned.href !== location.href) {
history.replaceState(history.state, "", cleaned);
}
If the user is offline when they launch from a shortcut, the beacon is lost. To count offline launches, store the event in IndexedDB and flush it later, for example with Background Sync where supported. Analytics for PWAs covers launch-source attribution for start_url, share targets and file handlers with the same approach.
Shortcuts and launch_handler on desktop¶
On desktop Chromium, shortcut launches go through the same launch process as other app launches, so the manifest's launch_handler applies. With "client_mode": "focus-existing", the existing window is focused instead of navigating, and the shortcut URL arrives only through window.launchQueue as LaunchParams.targetURL (Chromium populates targetURL for shortcut launches). Your page must route to it itself, and must also run attribution there, since no new document loads.
There's a trap: Chromium enqueues LaunchParams for every app launch, including the launch that created a new document (navigate-new, navigate-existing, or a cold start). On those launches the page-load code above has already recorded the shortcut, so a consumer that records unconditionally counts every cold shortcut launch twice. Skip the one launch whose targetURL is the URL this document was loaded with:
import {
initialDocumentUrl,
recordShortcutLaunch,
withoutShortcutMarker,
} from "./launch-attribution.js";
import { router } from "./router.js";
const navigationType = performance.getEntriesByType("navigation")[0]?.type;
if ("launchQueue" in window) {
let firstCall = true;
window.launchQueue.setConsumer((launchParams) => {
// Before Chrome 146, file-handler launches into an existing window had no targetURL.
if (!launchParams.targetURL) return;
if (firstCall) {
firstCall = false;
// The launch that created this document: already recorded on page load,
// and the router is already showing it.
if (launchParams.targetURL === initialDocumentUrl) return;
// Before Chrome 146, a reload re-queued the original LaunchParams. The page
// URL was cleaned by then, so compare without the marker.
if (
navigationType === "reload" &&
withoutShortcutMarker(launchParams.targetURL).href === initialDocumentUrl
) {
return;
}
}
const target = recordShortcutLaunch(new URL(launchParams.targetURL));
if (target.origin !== location.origin) return;
// Client-side navigation inside the already-open window (focus-existing).
router.navigate(`${target.pathname}${target.search}${target.hash}`);
});
}
The second guard exists because, before Chrome 146, reloading the page re-delivered the last LaunchParams to the consumer, so every reload of a window opened from a shortcut looked like a new shortcut launch. Chrome 146 stopped re-queueing on reload ("Stop re-queueing LaunchParams on reload" on chromestatus), but installed apps on managed desktops can run older versions for a long time.
launch_handler is Chromium-only; the full model, including navigate-existing and navigate-new, is covered in Protocol Handlers & Launch Handling and Advanced & Integration Members. Elsewhere, a shortcut launch is a navigation and recordShortcutLaunch() runs on page load.
Serving shortcut URLs offline¶
Shortcuts are most useful exactly when the app launches cold, and that includes launches without a connection. The browser navigates to the full shortcut URL, query string included, so the service worker must answer it from cache. For a client-rendered app, serving the cached app shell for every in-scope navigation is enough, because the router reads location and renders the right view. The "dynamic" shortcut gets its own handler.
const VERSION = "2026-09-25";
const SHELL_CACHE = `shell-${VERSION}`;
const STATE_CACHE = "app-state"; // Written by the page, read here. Not versioned.
const APP_SCOPE = "/app/";
const SHELL_URL = "/app/";
const OFFLINE_URL = "/app/offline.html";
const LAST_OPENED_KEY = "/app/__state/last-opened";
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL_CACHE);
await cache.addAll([
SHELL_URL,
OFFLINE_URL,
"/app/assets/app.js",
"/app/assets/app.css",
// Shortcut icons are fetched by the browser at install/update time,
// not by the page, so they don't need to be precached.
]);
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names
.filter((name) => name.startsWith("shell-") && name !== SHELL_CACHE)
.map((name) => caches.delete(name)),
);
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
await self.clients.claim();
})(),
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.mode !== "navigate") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || !url.pathname.startsWith(APP_SCOPE)) return;
if (url.pathname === "/app/go/recent") {
event.respondWith(resolveRecent(url));
return;
}
event.respondWith(appShellNavigation(event));
});
// "Continue last note": a stable shortcut URL that resolves at launch time.
async function resolveRecent(url) {
let noteId = null;
try {
const state = await caches.open(STATE_CACHE);
const stored = await state.match(LAST_OPENED_KEY);
if (stored) ({ noteId = null } = await stored.json());
} catch {
// Corrupt or missing state: fall through to the app home.
}
const target = new URL(noteId ? `/app/notes/${encodeURIComponent(noteId)}` : "/app/", url);
// Carry the attribution parameter (and anything else) through the redirect.
for (const [key, value] of url.searchParams) target.searchParams.set(key, value);
// A redirect response is allowed for navigation requests (redirect mode "manual").
return Response.redirect(target.href, 302);
}
// Network first for in-scope navigations, falling back to the cached shell.
async function appShellNavigation(event) {
try {
const preloaded = await event.preloadResponse;
if (preloaded) return preloaded;
return await fetch(event.request);
} catch {
const cache = await caches.open(SHELL_CACHE);
return (
(await cache.match(SHELL_URL)) ??
(await cache.match(OFFLINE_URL)) ??
Response.error()
);
}
}
The page records the last-opened note in the same cache whenever the user opens one, which keeps the service worker free of IndexedDB schema coupling:
const STATE_CACHE = "app-state";
const LAST_OPENED_KEY = "/app/__state/last-opened";
export async function rememberLastOpenedNote(noteId) {
if (!("caches" in self)) return;
try {
const cache = await caches.open(STATE_CACHE);
await cache.put(
LAST_OPENED_KEY,
new Response(JSON.stringify({ noteId, at: Date.now() }), {
headers: { "content-type": "application/json" },
}),
);
} catch (error) {
// Storage full or blocked: the shortcut falls back to the app home.
console.warn("Could not persist last opened note", error);
}
}
A few details that matter here:
cache.match()compares full URLs including the query. The shell handler above ignores the request URL entirely when falling back, so every shortcut URL, parameters and all, gets the shell. If you precache per-route HTML instead, strip the attribution parameter before lookup, as shown in App Identity & Updates.Response.redirect()works for navigations because navigation requests use redirect modemanual; the browser follows the redirect as a new navigation, which the service worker also handles.- For server-rendered apps, precache the HTML of each shortcut target (without the attribution parameter) or a generic offline page per target. Offline UX & Fallbacks and Caching Strategies cover the options.
Shortcut icons¶
The icons member of a shortcut item has the same format as the top-level icons, and each platform picks from it differently:
- Android selects a
purpose: "any"icon near the launcher's ideal shortcut icon size, accepting down to half of it. Provide 96×96 and 192×192 PNGs. - Windows converts the chosen icon to
.icofor the jump list, where it renders at small sizes. Simple, high-contrast glyphs survive; detailed illustrations don't. - Chrome DevTools reports "Shortcut #N should include a 96×96 pixel icon" when none of a shortcut's icons declares
sizesof at least 96×96. - Maskable shortcut icons aren't used by Chrome for Android, which requests
purpose: "any". If you want a consistent shape, draw it into the PNG. - SVG: the specification's own example uses an SVG shortcut icon, but platform support is inconsistent (an older web.dev article states SVG shortcut icons weren't supported and recommends PNG). Ship PNG.
- Missing icons are allowed. Platforms fall back to the app icon or show a text-only entry.
- Changing icons: on desktop Chromium a changed shortcut entry triggers download of its icons during the silent update; on Android a changed icon hash triggers a WebAPK update. As with app icons, publish changed icons under new URLs.
Localizing shortcuts¶
The current specification makes a shortcut's name, short_name, description and icons localizable. Chrome 148 shipped manifest localization (chromestatus lists it for desktop), covering names, descriptions, icons and shortcuts. Localized values are language maps keyed by language tag; a value is a string or an object with value, and optionally lang and dir:
{
"lang": "en",
"dir": "ltr",
"shortcuts": [
{
"name": "New note",
"name_localized": {
"de": "Neue Notiz",
"fr": "Nouvelle note",
"ar": { "value": "ملاحظة جديدة", "dir": "rtl" }
},
"short_name": "New",
"short_name_localized": { "de": "Neu", "fr": "Nouveau" },
"url": "/app/new?shortcut=new-note",
"icons": [{ "src": "/icons/shortcuts/new-96.png", "sizes": "96x96", "type": "image/png" }],
"icons_localized": {
"ar": [{ "src": "/icons/shortcuts/new-rtl-96.png", "sizes": "96x96", "type": "image/png" }]
}
}
]
}
User agents pick the entry whose language tag best matches the user's preferences and fall back to the unlocalized value. Browsers without localization support use the unlocalized value, so always provide it. Shortcut names are not security-sensitive members, so changing them is a silent update.
Testing shortcuts¶
DevTools. Application > Manifest in Chromium lists each Shortcut #N with its name, URL and icons, and shows the warnings above (more than four shortcuts, missing 96×96 icon). Parser warnings about out-of-scope or missing URLs appear in the console and the manifest view.
Automated. The Chrome DevTools Protocol's Page.getAppManifest returns processed shortcuts (name and resolved url), so a test can catch dropped entries by comparing them with the raw JSON:
import { test, expect } from "@playwright/test";
test.skip(({ browserName }) => browserName !== "chromium", "Uses the Chrome DevTools Protocol");
test("every declared shortcut survives processing and loads", async ({ page }) => {
await page.goto("/app/");
const cdp = await page.context().newCDPSession(page);
const { data, errors, manifest } = await cdp.send("Page.getAppManifest");
const declared = JSON.parse(data).shortcuts ?? [];
const processed = manifest.shortcuts ?? [];
expect(errors.map((e) => e.message)).toEqual([]);
expect(declared.length).toBeLessThanOrEqual(4); // The cross-platform budget.
expect(processed.map((s) => s.name)).toEqual(declared.map((s) => s.name.trim()));
const scope = new URL(manifest.scope);
for (const shortcut of processed) {
const url = new URL(shortcut.url);
expect(url.pathname.startsWith(scope.pathname), `${shortcut.name} is out of scope`).toBe(true);
expect(url.searchParams.get("shortcut"), `${shortcut.name} lacks attribution`).toBeTruthy();
// The target must render, online...
const response = await page.goto(shortcut.url);
expect(response?.ok(), shortcut.url).toBe(true);
}
});
test("shortcut targets load offline", async ({ page, context }) => {
await page.goto("/app/");
await page.evaluate(() => navigator.serviceWorker.ready);
await page.reload(); // Make sure the page is controlled.
const cdp = await context.newCDPSession(page);
const { manifest } = await cdp.send("Page.getAppManifest");
await context.setOffline(true);
try {
for (const shortcut of manifest.shortcuts ?? []) {
const response = await page.goto(shortcut.url);
// Served by the service worker (possibly after a SW-generated redirect).
expect(response?.ok(), `${shortcut.url} offline`).toBe(true);
}
} finally {
await context.setOffline(false);
}
});
On each platform. Install the app, then check the real menus: long-press the icon on Android, right-click the taskbar icon and Start menu entry on Windows, right-click the Dock icon on macOS (and use the File menu in Safari Dock apps), and inspect the app's .desktop file in ~/.local/share/applications/ on Linux. To test a changed list on desktop Chromium, reload a page that links the manifest (Chrome 144+ applies shortcut changes silently) and reopen the menu. On Android, use about://webapks to force an update check, relaunch the app, and keep the device on Wi-Fi and charging. More tools in Browser DevTools.
Common pitfalls¶
- Relative URLs resolved against a manifest in a subfolder.
"url": "new"in/static/manifest.webmanifestis/static/new, probably out of scope and silently dropped. - Shortcuts outside scope after a scope change. They vanish without an error.
- More than four shortcuts with the important one last. Android never shows it.
- Invalid entries at the top of the list. They count against Chromium's ten-entry parse limit.
- Identical or near-identical names. Confusing in menus, and they can collide as Linux desktop action identifiers.
- Icons only as SVG, or smaller than 96 pixels. Missing icons on Android, and a DevTools warning.
- No offline handling for shortcut URLs. The launch lands on the browser's offline error page.
- Double-counting launches. Not stripping the attribution parameter makes every reload a launch; with
focus-existing, forgettinglaunchQueuemisses them entirely. - Expecting instant changes on Android. A new shortcut list ships with the next WebAPK update, often days later.
- Treating shortcuts as the only path to a feature. Users in a browser tab, on iOS, or in Firefox never see them.
Browser support¶
| Browser | shortcuts | Where they appear |
|---|---|---|
| Chrome / Edge (Windows) | ✅ 85 | Jump list (taskbar, Start menu), up to 10 |
| Chrome / Edge (macOS, Linux, ChromeOS) | ✅ 96 | Dock menu, .desktop actions, shelf and launcher |
| Chrome for Android | ✅ 84 | Launcher long-press, up to 4 |
| Samsung Internet | ✅ 14.0 | Launcher long-press |
| Opera (desktop) | ✅ 821 | As Chromium |
| Safari (macOS) | ✅ 17.4 | File menu and Dock menu of Dock web apps |
| Safari (iOS, iPadOS) | ❌ | |
| Firefox (desktop, Android) | ❌ |
Support data as of September 2026. For live data see MDN's shortcuts reference and caniuse.
Further reading¶
On this site
- App Identity & Updates: scope rules and how shortcut changes propagate
- Members Reference: all manifest members
- Icons & Maskable Icons: building icon sets
- Protocol Handlers & Launch Handling:
launch_handlerandlaunchQueue - Advanced & Integration Members: other launch-related members
- Offline UX & Fallbacks: making every entry point work offline
- Analytics for PWAs: measuring installed usage and launch sources
- Badging API: dynamic information on the app icon
External references
- Web Application Manifest: shortcuts member and shortcut items
- MDN: shortcuts
- web.dev: Get things done quickly with app shortcuts
- Microsoft Edge: Define app shortcuts
- WebKit Features in Safari 17.4: shortcuts on macOS
- Android: App shortcuts overview
- Desktop Entry Specification: the actions Chrome writes on Linux
-
Windows only from Opera 71, all desktop platforms from 82, per MDN's compatibility data. ↩