An installed app can grow deeper into the system, but each such ability is a separate capability that exists in some places and not others. The rule is one: feature detection and a meaningful fallback. Check the API, and if it is missing, degrade quietly to basic behavior. The naive alternative - deciding that because it works in your Chrome it works for everyone - shatters against the zoo of browsers.
Several capabilities extend entry points. shortcuts in the manifest give quick actions from the icon's context menu - for example, New Note straight to the right screen. Share Target lets the app accept text and files from the system share sheet as if it were native: a user shares an article and it lands as a new note. Protocol handlers register link schemes so they open in the installed PWA. Each of these is an enhancement, not a foundation.
Badging draws an unobtrusive number - the count of unsynced or unread items - right on the icon, if the API is supported. Web Push is appropriate only after clear value and permission, and the payload must carry no secrets, since it passes through a third-party delivery service. The display-mode: standalone media query adapts the UI to the actual mode. An important caveat about iOS: there Web Push is available for apps added to the home screen, but Background Sync cannot be considered portable - plan for the foreground flush from earlier chapters.
In standalone mode the address bar and part of the browser navigation disappear. Do not draw your own Back button if your navigation model breaks without one - better to lean on the platform's history and gestures. Account for safe areas on notched devices (env(safe-area-inset-*)), a possible title bar overlay on desktop, and the on-screen keyboard that eats viewport height.
Assembling a service worker and strategies by hand is instructive, but in production Workbox does this - Google's library implementing proven caching strategies and lifecycle mechanics. It removes the drudgery but does not decide for you: which resource to serve with which strategy is your policy. In a Vite stack, vite-plugin-pwa wires it in. For offline-first, the strategies: injectManifest mode is reasonable - you write your own sw.ts, and the plugin only injects the precache manifest (the list of files with revisions).
The key choice is registerType. The prompt value aligns with safe updates: the new worker waits until the user confirms a reload, and tabs do not refresh underfoot. Do not enable autoUpdate mechanically - for an app with unsaved drafts, a sudden version swap mid-work is more dangerous than a client that is a minute stale. Separately, inspect the generated precache manifest: sourcemaps, giant videos, or private files easily slip into it by oversight, bloating the install and leaking out.
registerRoute(
({ request, url }) =>
request.destination === 'image' && url.origin === self.location.origin,
new CacheFirst({
cacheName: 'images-v3',
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 80, maxAgeSeconds: 30 * 86400 })
]
})
);Runtime caching is described through registerRoute: you match a request to a strategy - say, same-origin images through CacheFirst with an ExpirationPlugin that caps the number of entries and the lifetime. Workbox also has its own Background Sync plugin that stashes failed requests in IndexedDB, but for domain conflicts and UI statuses your own outbox is clearer. The telling failure of autogeneration: a team enabled autoUpdate and a broad glob, precache pulled in old videos, the first install ballooned to a hundred megabytes, and the update silently discarded an open draft.
pnpm add -D vite-plugin-pwa@1.3.0 workbox-build@7.4.1 workbox-precaching@7.4.1
pnpm add idb@8.0.3
// vite.config.ts
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
registerType: 'prompt',
injectRegister: false,
manifest: false,
injectManifest: {
globPatterns: ['**/*.{js,css,html,svg,png,woff2}']
}
})