Skip to content

Advanced

Published

At the end of this guide the app offers Web Push, file access, background sync, and an icon badge where the browser supports them, and loses only that one feature, not the page, where it does not. Each capability is wrapped in the same three-part pattern: a feature check, a fallback branch that is written before the enhancement, and a compatibility lookup on this site before the code ships. Complete Getting started, Make it installable, and Offline strategies first; every capability here assumes a registered service worker and an installable manifest.

Put the detection in one module so that the UI, the service worker, and analytics agree on what is available. The gate reads the object graph rather than the user agent, and the fallback for each capability is a concrete action, not a disabled button:

export const capabilities = {
push: 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window,
fileSystemAccess: 'showOpenFilePicker' in window,
backgroundSync: 'serviceWorker' in navigator && 'SyncManager' in window,
badging: 'setAppBadge' in navigator,
};
export const fallbacks = {
push: () => showEmailDigestOptIn(), // server-side digest instead of push
fileSystemAccess: () => showDownloadAndUpload(), // <a download> plus <input type="file">
backgroundSync: () => retryQueueOnNextLoad(), // flush the IndexedDB queue on 'online'
badging: () => showUnreadCountInTitle(), // document.title = `(3) Field Notes`
};
export function withCapability(name, enhanced) {
return capabilities[name] ? enhanced() : fallbacks[name]();
}

Presence of an API is a necessary condition, not proof the feature works for a given user: PushManager exists on iOS Safari in a browser tab, yet WebKit grants push only to a web app added to the Home Screen (iOS and iPadOS 16.4, 2023-03). Pair every gate with the matching compatibility entry on this site and read its notes column.

web.dev’s push overview splits the work into three steps: client code that asks permission and subscribes, server code that sends, and service-worker code that receives and shows a notification. The permission request must follow a gesture such as a click; WebKit states the same for Home Screen web apps. Subscribe with your VAPID public key (web.dev: “the combination of the private and public key is known as the application server keys”):

async function enablePush(vapidPublicKey) {
if (!capabilities.push) return fallbacks.push();
const permission = await Notification.requestPermission();
if (permission !== 'granted') return fallbacks.push();
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey,
});
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(subscription),
});
}

The endpoint inside the subscription names the browser vendor’s push service; web.dev describes its domain as “essentially the push service” and its path as the client identifier. Store the whole subscription object server-side and delete it when a send returns 404 or 410. In the service worker, a push listener calls self.registration.showNotification(); a handler that receives a push and shows nothing is treated as a silent push, which browsers do not allow.

Gate file access and background sync the same way

Section titled “Gate file access and background sync the same way”

showOpenFilePicker() gives an editor read and write access to a user-chosen file; the fallback is the pair every browser supports, <input type="file"> for reading and an <a download> for writing. Background Sync lets the worker replay writes made offline when connectivity returns; the fallback is to keep the queue in IndexedDB and flush it on the online event or the next page load. For each, call withCapability() so the two branches stay next to each other in the source and the fallback cannot rot unnoticed.

Chrome’s Service workers pane has Push and Sync links that dispatch a push and a sync event to the active worker without a server, which lets you exercise the receive side before the backend exists. Use Offline to confirm that a queued write survives a reload and is replayed when the box is unticked.

← Back to the Guides overview.