Skip to content

Background Execution and Re-engagement in PWAs

A Progressive Web App can do useful work while none of its pages are open: the browser starts the app's service worker for a push message, a returning network connection, a scheduled refresh or a finished download, lets it run briefly, and stops it again. Six APIs cover that territory: Push, Notifications, Background Sync, Periodic Background Sync, Background Fetch and Badging. They differ in who triggers them, which permission gates them, how long they may run and, above all, which browsers and operating systems implement them. This page maps the whole area, explains the platform rules that decide whether your code runs at all, and links to a deep-dive page for each API.

Key takeaways

  • Nothing on the web runs "in the background" on its own schedule. The browser decides when to start your service worker, for a small set of functional events, and terminates it shortly after the event settles (Chromium: 30 seconds idle, 5 minutes per event in general, 3 minutes for sync and a shorter timeout of about 90 seconds for push).
  • Push plus notifications is the only background mechanism in every engine, and on iOS and iPadOS it works only in Home Screen web apps (16.4 and later). Background Sync, Periodic Background Sync and Background Fetch are Chromium-only.
  • Every browser that supports push requires each message to end in a visible notification. Chrome shows a generic one, Firefox spends a quota of 16, and Safari removes the subscription after the third miss.
  • On desktop, background events need a running browser process. On Android, the OS wakes Chrome for push messages and scheduled sync jobs. On iOS, only push wakes a web app.
  • Permissions differ per API: notifications prompt the user, Background Sync defaults to allowed without a prompt, Periodic Background Sync requires an installed app, and Background Fetch follows the download setting.
  • Design every background feature with a foreground fallback (refresh on launch, retry on online, send while the page is open). For most users on Safari and Firefox, the fallback is the implementation.

What "in the background" means for a web app

A native app can register background tasks with the OS and keep a process alive under OS rules. A web app has no process of its own. Its code runs in three contexts, and only one of them survives the user leaving:

Context When it runs What it can do When it stops
Visible page Tab or app window in the foreground Everything: DOM, timers, network, storage, all APIs Navigation, close, or the browser discarding it
Hidden page Tab in the background, window minimized, app switched away on mobile Script keeps running, but timers are throttled; Chromium may freeze the page (Page Lifecycle freeze event) and mobile OSes suspend the whole browser Freezing, discarding, or the OS killing the browser
Service worker without clients No page of the origin is open Only handlers for events the browser dispatches: push, notificationclick, notificationclose, sync, periodicsync, the backgroundfetch* events, pushsubscriptionchange When the event settles and the worker goes idle

The third row is what this section is about. The service worker is not a daemon. It is a script the browser starts for one event at a time. The Service Workers specification calls these functional events, and every one of them follows the same shape:

sequenceDiagram
    participant Source as "Trigger (push service, network, scheduler, download)"
    participant Browser
    participant SW as Service worker
    Source->>Browser: something happened for origin X
    Browser->>Browser: check permission, engagement, budget
    Browser->>SW: start worker (re-evaluate script)
    Browser->>SW: dispatch event
    SW->>SW: event.waitUntil(promise)
    SW-->>Browser: promise settles
    Note over Browser,SW: idle timer starts, worker terminated

Three consequences follow directly from that model:

  • No state survives between events. Global variables vanish when the worker is terminated. Everything a background handler needs (outbox items, the last sync time, unread counts) lives in IndexedDB or Cache Storage.
  • Work must be attached to the event. Anything not covered by event.waitUntil() can be killed mid-flight. Chromium terminates a worker 30 seconds after its last event settles and gives each event at most 5 minutes; Firefox terminates after 30 seconds plus a 30-second grace period for pending waitUntil() promises. Background Sync has a stricter limit of 3 minutes in Chromium, and push events get about 90 seconds before Chromium kills the worker. The details are on the Service Workers overview.
  • The browser can decline to start you. Every trigger passes through a policy check first: is the permission granted, is the site engaged, is the push budget intact, is the device on a known network. Code you never get to run can't fix a denied policy, so the checks matter as much as the handler.

A detail that is easy to miss: the Service Workers specification's Fire Functional Event algorithm asks the browser to run a soft update check after a functional event when the registration's last update check is more than 24 hours old. An app that users rarely open but that receives regular pushes therefore still picks up new service worker versions. Chromium's work to apply this consistently to every functional event is listed as Proposed on Chrome Platform Status, so do not rely on it alone; the regular update mechanics are covered in Updating Service Workers.

The six background and engagement APIs

The APIs split into two groups. Push, Background Sync, Periodic Background Sync and Background Fetch are triggers: they cause the browser to start your service worker. Notifications and Badging are outputs: they are how background code shows the user that something happened. Most real features combine one of each.

API Trigger Initiated by Gated by User-visible requirement Engines
Push Message from your server via the browser's push service Your server Notification permission Every push must show a notification Chromium, Gecko, WebKit
Notifications Output only Page or service worker Notification permission It is the visible output Chromium, Gecko, WebKit
Background Sync Network connectivity after sync.register() The page (or worker) while a page is open background-sync setting, allowed by default None Chromium only
Periodic Background Sync Browser-chosen interval, at least 12 hours The page, once Installed app + background-sync setting + engagement None Chromium only
Background Fetch Browser-managed download or upload The page Download permission Browser shows progress UI Chromium only
Badging Output only Page or service worker None in Chromium; notification permission on Apple platforms The badge itself Chromium desktop, WebKit

Push messages: the only cross-browser wake-up

Your server sends an encrypted HTTP request to an endpoint URL that the browser handed you at subscription time. The endpoint belongs to the browser vendor's push service (Firebase Cloud Messaging for Chrome, Mozilla's autopush for Firefox, Windows Push Notification Services for Edge, Apple Push Notification service for Safari), which holds the message for up to its TTL and delivers it over the browser's persistent connection. The browser decrypts the payload and fires a push event. The page-side subscription, VAPID keys and server code are covered in Push Notifications; the HTTP requests, RFC 8291 encryption and response codes are covered byte by byte in The Web Push Protocol.

Push is "user visible only" everywhere: PushManager.subscribe() must be called with userVisibleOnly: true in Chrome, Edge and Safari, and Firefox enforces the same promise with a quota. Each engine punishes a push that doesn't end in a notification in its own way:

Engine Penalty for a push without a notification
Chrome Shows its own "This site has been updated in the background" notification once a small engagement-based budget is used up
Edge "Displays a generic notification that indicates that a push message was received" (Microsoft's documentation)
Firefox Spends one unit of a 16-push quota per push without a visible notification; at zero, the subscription expires
Safari (macOS, iOS, iPadOS) showNotification() must be called within about 30 seconds; the third miss removes the origin's subscriptions

Silent "data pushes" that refresh a cache without telling the user are therefore not a portable design. Use Periodic Background Sync where it exists and refresh-on-launch everywhere else. Safari 18.4 and later on iOS (Safari 18.5 on macOS) also accept Declarative Web Push messages, JSON payloads the OS turns into a notification without running your service worker at all; see Web Push on iOS & Safari.

Notifications: the output channel

The Notifications API turns a push, a finished sync or a completed download into a system notification. From a service worker you always call registration.showNotification(): the new Notification() constructor throws a TypeError inside a service worker by specification, and Chrome for Android throws for it in pages too. The permission prompt (Notification.requestPermission()) is the most consequential request a web app makes, because the notification permission also gates push. The page covers every option, per-OS rendering, notificationclick handling and prompt throttling.

Background Sync: finish a write after the user leaves

A page calls registration.sync.register(tag) after it fails to send something, and Chromium fires a sync event when the device next has a connection, even if the user closed the tab in the meantime. Failed attempts are retried after at least 5 and then 15 minutes, and the third attempt carries lastChance: true. The pattern that makes it useful is an IndexedDB outbox with idempotency keys, built in full in Background Sync. The API is not a download mechanism and not a scheduler.

Periodic Background Sync: fresh content before the user opens the app

An installed app registers a tag with a minInterval, and Chromium fires periodicsync at an interval it chooses from the site engagement score, never more often than every 12 hours per origin, and only on a network the device has connected to before. Engagement drives everything: an app nobody launches from its installed icon stops getting events. Periodic Background Sync walks through Chromium's frequency algorithm step by step.

Background Fetch: large transfers the browser owns

registration.backgroundFetch.fetch(id, requests, options) hands a set of downloads (or uploads) to the browser, which shows progress in its own UI, pauses on network loss, survives restarts, and wakes the service worker with backgroundfetchsuccess or backgroundfetchfail at the end. Chromium allows at most 5 active fetches per origin. Since Chrome 149, calling fetch() from inside a service worker rejects with NotAllowedError by default (the temporary enterprise policy RestrictBackgroundFetchFromServiceWorkerEnabled lifts it), so start background fetches from a page. Details are in Background Fetch.

Badging: a count on the app icon

navigator.setAppBadge(count) and clearAppBadge() set the badge on an installed app's taskbar, Dock, shelf or Home Screen icon, from a page or a service worker. Chromium desktop (Windows and macOS since Chrome 81, ChromeOS since 91) and Safari (iOS and iPadOS 16.4 Home Screen web apps, web apps on Mac from Safari 17) implement it. On Chrome for Android the methods exist and resolve but change nothing, because Android shows notification dots instead. On Apple platforms the badge only appears once the user has granted notification permission, and Chrome 152 applies the same rule to installed PWAs on macOS. See Badging API.

Choosing the right mechanism

Pick the trigger by asking who knows that something needs to happen, and when.

flowchart TD
    A["Something must happen while the app is closed"] --> B{"Who knows it must happen?"}
    B -- "The server: new message, alert, status change" --> P["Push + notification"]
    B -- "The client: a write failed while offline" --> C{"Chromium?"}
    C -- yes --> S["Background Sync with an IndexedDB outbox"]
    C -- no --> F1["Retry on launch, online event, visibilitychange"]
    B -- "Nobody: content goes stale over time" --> D{"Installed Chromium app?"}
    D -- yes --> PS["Periodic Background Sync"]
    D -- no --> F2["Refresh on launch; optionally push a notification for important updates"]
    B -- "The user: download or upload a large file" --> E{"Chromium?"}
    E -- yes --> BF["Background Fetch"]
    E -- no --> F3["Foreground fetch with progress UI and resumable ranges"]
    P --> O["Update badge and in-app state"]
    S --> O
    PS --> O
    BF --> O
You want to Use Fallback outside Chromium
Tell the user about a new chat message Push + showNotification() with a tag per conversation None needed: push works in all engines (Home Screen web apps on iOS)
Send a form or message written offline Background Sync + outbox Flush the outbox on launch, on online and on visibilitychange
Have today's articles ready at breakfast Periodic Background Sync Refresh on launch; show cached content immediately and update in place
Download a 2 GB offline map or a podcast season Background Fetch Foreground download with Range requests into OPFS and resume on relaunch
Show an unread count on the icon Badging, set from push and on launch Update the favicon and document.title
Show a reminder at 9:00 tomorrow Push from your server at 9:00 None: there is no shipped API for scheduled local notifications

Some things are not possible on the web today, whatever the browser:

  • Scheduled local notifications. Chromium's Notification Triggers (showTrigger) never shipped and is listed on Chrome Platform Status as a developer trial behind a flag. Schedule on your server and push.
  • Invisible background work on a timer. Only Periodic Background Sync approximates it, and only for installed, engaged apps in Chromium.
  • Background location, geofencing or Bluetooth scanning. The Geolocation, Web Bluetooth and Web NFC APIs work only in a page (see Hardware & Device APIs).
  • Long-lived connections in the service worker. A WebSocket or EventSource opened in a service worker dies with the worker, and holding the worker open is exactly what the idle timeout prevents.

Platform constraints: who wakes the browser

A background event needs three things: the browser has to be running (or startable by the OS), it has to be allowed to start your worker, and the operating system has to let it do so. Those rules differ far more between operating systems than between browser brands.

Desktop: the browser process must be running

On Windows, macOS, Linux and ChromeOS, no OS service wakes a closed browser for a web app. The browser's own process receives push messages over its connection to the push service and runs sync schedulers. web.dev's push FAQ states it directly: "The only time a push won't be received is when the browser is completely closed, i.e. not running at all". What that means per mechanism:

Mechanism Browser running, no window open Browser process not running
Push Delivered; the notification appears Held by the push service until TTL expires; delivered when the browser next starts and reconnects
Background Sync Fires once the connection is available; Chromium keeps the process alive until pending events are dispatched when the last window closes Fires after the next browser start with connectivity
Periodic Background Sync Fires when due Missed; the next event is scheduled after the browser starts
Background Fetch Continues Resumes after the next browser start
Badging Badge kept in memory Lost: Chromium keeps badges in memory, so reapply on launch

Whether the process survives the last window depends on the platform and settings. On macOS, closing all windows usually leaves the browser application running until the user quits it. On Windows and Linux, Chromium-based browsers normally exit when the last window closes, unless a setting such as Chrome's "Continue running background apps when Google Chrome is closed" or Edge's equivalent keeps the process in the background. Your server-side design should therefore treat every push as possibly delayed until the next time the user starts the browser, and set TTL and Topic (see The Web Push Protocol) so that stale messages expire or collapse instead of arriving in a burst.

Installed desktop PWAs run inside the same browser process as the browser's tabs. Closing the app window doesn't necessarily stop the browser, and quitting the browser stops background delivery for every installed app at once. See Desktop Platforms for how each OS integrates installed apps.

Android: the OS wakes Chrome

Android is where Chromium's background APIs work best, because Chrome delegates wake-ups to the OS:

  • Push arrives through Firebase Cloud Messaging, which Android delivers even when Chrome isn't running. The OS starts Chrome's process, which starts your worker.
  • Background Sync registers a one-off job with Android's background task scheduler that requires connectivity and persists across reboots.
  • Periodic Background Sync schedules a wake-up task with the same scheduler.
  • Background Fetch runs as a download with its own notification, which the user can pause or cancel.

The OS still has the last word. Doze and battery saver defer jobs and batch network access, so a sync due "in 5 minutes" can arrive much later on an idle phone. On Android 13 and later, the browser app needs the POST_NOTIFICATIONS runtime permission: if the user denied it to Chrome, your site's permission is granted but nothing appears. Chrome creates one notification channel per origin, and the user's channel settings (sound, importance) override your options. An installed PWA (a WebAPK) shows notifications under its own name and icon. Android covers WebAPKs and Trusted Web Activities.

iOS and iPadOS: push only, and only for Home Screen web apps

On iPhone and iPad every browser uses WebKit, and WebKit offers exactly one background trigger: push, available since iOS and iPadOS 16.4 to web apps added to the Home Screen, never to Safari tabs. There is no Background Sync, no Periodic Background Sync and no Background Fetch. Your worker runs for a push event, a notificationclick, or while the app is in the foreground, and that's it. Consequences:

  • Every push must display a notification within about 30 seconds. WebKit counts misses per app and origin and removes the subscriptions on the third, with no reset. A "silent" refresh push is not an option.
  • The badge is updated from push handlers or from the foreground. Declarative Web Push can carry an app_badge value that the OS applies without running JavaScript.
  • Installation is manual. The user must use Share > Add to Home Screen. Since iOS 26, every site added to the Home Screen opens as a web app by default, and WebKit states that "there are now zero requirements for 'installability' in Safari". The permission request must come from a user gesture inside the installed app.
  • Each Home Screen copy is its own app, with its own storage, subscription and notification settings.
  • In the EU, Home Screen web apps kept working. Apple reversed its early-2024 plan to turn them into bookmarks under the Digital Markets Act before iOS 17.4 shipped.

Everything Apple-specific (APNs responses, the silent-push counter, Declarative Web Push and device debugging) is on Web Push on iOS & Safari; the wider platform picture is on iOS & iPadOS.

Firefox: push and notifications, nothing else

Firefox implements the Push API and the Notifications API on desktop and Android, and neither Background Sync, Periodic Background Sync, Background Fetch nor Badging. Mozilla's standards positions on both Background Sync and Periodic Background Sync are negative, citing cross-network tracking (IP address and location changes) and script execution the user is not aware of. Firefox on desktop needs its process running to receive push, like Chrome. Notification action buttons arrived in Firefox 152, and Declarative Web Push exists only behind the dom.push.declarative.enabled preference.

User trust and permissions

Every background capability is something the user didn't directly ask for at the moment it happens: a notification while they're doing something else, network traffic after they left the site, bytes downloaded while the tab was closed. Browsers handle that with a mix of explicit prompts, implicit gates and automatic revocation.

How each API is gated

API Permission name (permissions.query()) Prompt? Default What else it depends on
Notifications notifications Yes, from Notification.requestPermission() prompt User gesture in Firefox and Safari; secure context; top-level frame
Push push (with userVisibleOnly: true) Shares the notifications prompt Follows notifications Active service worker, VAPID key, Home Screen install on iOS
Background Sync background-sync Never Granted in Chromium Top-level window of the origin; site setting "Background sync"
Periodic Background Sync periodic-background-sync Never Granted only if an app for the origin is installed Background sync setting, site engagement above zero, known network
Background Fetch background-fetch Indirectly, through the download UI Follows the download permission "Automatic downloads" setting for non-top-level callers, which start paused
Badging None No – Notification permission to display on Apple platforms and for installed PWAs on macOS in Chrome 152 and later

Engines that don't know a permission name reject permissions.query() with a TypeError, so each query needs a guard. This module probes everything the section's APIs need, without throwing in any browser, and is safe to call on every launch:

src/background-capabilities.js
// Background-specific probes; general capability detection lives on the capabilities page.
const NAMES = ["notifications", "background-sync", "periodic-background-sync", "background-fetch"];

async function queryPermission(name) {
  try {
    return (await navigator.permissions.query({ name })).state;
  } catch {
    return "unsupported"; // Firefox and Safari throw TypeError for unknown names
  }
}

export async function probeBackgroundCapabilities({ timeoutMs = 5000 } = {}) {
  const caps = { notifications: "Notification" in window, permissions: {} };
  if (!window.isSecureContext || !("serviceWorker" in navigator)) return caps;
  // ready never settles without a registration, so race it against a timeout.
  const reg = await Promise.race([navigator.serviceWorker.ready,
    new Promise((resolve) => setTimeout(resolve, timeoutMs, null))]);
  caps.push = !!reg && "pushManager" in reg;
  caps.backgroundSync = !!reg && "sync" in reg;
  caps.periodicSync = !!reg && "periodicSync" in reg;
  caps.backgroundFetch = !!reg && "backgroundFetch" in reg;
  if (navigator.permissions?.query) {
    for (const name of NAMES) caps.permissions[name] = await queryPermission(name);
  }
  if (caps.push) {
    caps.permissions.push = await reg.pushManager
      .permissionState({ userVisibleOnly: true }).catch(() => "unknown");
  }
  return caps;
}

For display mode, badging and the rest of the capability surface, use the detection module in Capabilities: A capability detection module; Your First PWA shows where to call it at startup. On iOS, check navigator.standalone === true before the display-mode media query, because a Home Screen web app whose manifest says standalone matches display-mode: fullscreen.

A typical result in Chrome on Windows for an installed app is { push: true, backgroundSync: true, periodicSync: true, backgroundFetch: true } with "periodic-background-sync": "granted". The same code in a Safari tab on iPhone returns notifications: false and push: false, and in the Home Screen app push: true with the three Chromium-only features false. Use the result to decide which UI to show (an "Enable notifications" button, an "Add to Home Screen" explanation, a "Download for offline" button), not to decide what the user is allowed to do. The broader permission model is on Permissions.

Asking for notification permission

The notification prompt is where most engagement strategies fail. Browsers now actively limit prompts that users don't want:

  • Chrome's quiet UI. Since Chrome 80, users who repeatedly deny notifications, and sites with very low acceptance rates, get a small address-bar indicator instead of a prompt. Abusive sites are enrolled automatically.
  • Chrome's embargo. After three dismissals or four ignored prompts for an origin, Chromium stops showing the prompt for seven days, and requestPermission() resolves without a prompt.
  • User activation. Firefox and Safari show the prompt only in response to a user gesture. On iOS the gesture must happen inside the installed Home Screen app.
  • Automatic revocation. In October 2025, Google announced that Chrome on Android and desktop will "automatically remove notification permission for sites you haven't interacted with recently", noting that "less than 1% of all notifications receive any interaction from users". The same announcement says the feature "does not revoke notifications for any installed web apps", which is one concrete, practical reason to get your PWA installed.

The pattern that works: explain the value in your own UI, tied to an action the user just took ("Notify me when this order ships"), and call Notification.requestPermission() from the click on your button. Never prompt on page load. Notifications API has a complete permission flow module.

Give users controls you honor

Offer per-topic settings (replies, mentions, digests), quiet hours and a clear "turn off" switch in your app, and have the sender check them. A user who can't tune your notifications blocks them in browser settings, and you can't undo that from code.

Privacy constraints built into the specs

The background APIs were designed around specific privacy risks, which explains many of their restrictions:

  • Location and network leaks. A background request reveals the user's IP address after they left the site. The Background Sync spec allows the browser to limit syncs, and Chromium's Periodic Background Sync runs only on networks the device has connected to before, so a sync from a new location can't reveal it.
  • Tracking through wake-ups. A server could use regular wake-ups to learn when the device is online. Periodic sync is therefore limited to installed, engaged apps and at least 12 hours apart.
  • Invisible downloads. Background Fetch requires the browser to show the origin and the progress, and the user can cancel.
  • Push without visibility. userVisibleOnly exists so that push can't be used as a silent beacon that reports "this device is on" to your server.

Storage-level privacy (partitioning, eviction and clearing) is on Privacy & Storage Partitioning; permission-level policy is on Permissions.

Platform support matrix

Support data as of September 2026. For live data, see MDN's Push API, Notifications API, Background Synchronization API, Periodic Background Synchronization API, Background Fetch API and Badging API pages, and caniuse for Push API and Background Sync.

Feature Chrome / Edge desktop Chrome Android Firefox desktop Firefox Android Safari macOS Safari iOS / iPadOS
Push API (PushManager, push event) ✅ 42 / 17 ✅ 42 ✅ 44 ✅ 48 ✅ 16.11 ⚠️ 16.44
showNotification() from a service worker ✅ 42 / 17 ✅ 42 ✅ 44 ✅ ✅ 16.11 ⚠️ 16.44
Notification action buttons ✅ 48 / 18 ✅ 48 ✅ 152 ✅ 152 ❌ ❌
pushsubscriptionchange ⚠️ 1385 ⚠️ 1385 ✅ 442 ✅ 482 ✅ 16 ❌
Declarative Web Push ❌ ❌ 🧪6 ❌ ✅ 18.53 ⚠️ 18.44
Background Sync ✅ 49 / 79 ✅ 49 ❌ ❌ ❌ ❌
Periodic Background Sync ⚠️ 807 ⚠️ 807 ❌ ❌ ❌ ❌
Background Fetch ⚠️ 74 / 798 ⚠️ 748 ❌ ❌ ❌ ❌
Badging API ✅ 81 (Windows, macOS), 91 (ChromeOS)9 ⚠️10 ❌ ❌ ⚠️ 1711 ⚠️ 16.44

Samsung Internet follows Chromium: Push from version 4.0, Background Sync from 5.0, Background Fetch from 11.0 and Periodic Background Sync from 13.0. Android WebView supports none of the trigger APIs, so a site running inside another app's WebView gets no background events. Opera supports Background Sync from 36, Background Fetch from 62 and Periodic Background Sync from 67. Each page in this section has its own detailed table with per-feature footnotes.

Chrome for Android also has the Content Index API (Chrome 84), which lets an app register content it has made available offline so the browser can surface it for offline browsing, and fires a contentdelete event in the service worker when the user removes an entry. It pairs naturally with Periodic Background Sync and Background Fetch for offline reading apps, but no other engine implements it.

Designing for fallbacks: one refresh routine, many triggers

Because support is so uneven, the robust architecture is a single piece of service worker code that every available trigger calls, plus page-side triggers that call the same code when no background trigger exists. The service worker below refreshes a content feed and the badge from push, one-off sync, periodic sync, and a message from the page:

sw.js
// One refresh routine reachable from every background trigger the browser supports.
// Pages call the same routine (via postMessage) where no trigger exists.
const CONTENT_CACHE = "content-v1";
const FEED_URL = "/api/feed?limit=50";
const REFRESH_TAG = "content-refresh";

async function refreshContent(reason) {
  const response = await fetch(FEED_URL, {
    cache: "no-store", // bypass the HTTP cache: we want the server's current state
    headers: { "X-Refresh-Reason": reason }, // lets the server measure each trigger
  });
  if (!response.ok) throw new Error(`Feed responded with ${response.status}`);
  const cache = await caches.open(CONTENT_CACHE);
  await cache.put(FEED_URL, response.clone());
  return response.json(); // expected shape: { unread: number, items: [...] }
}

async function updateBadge(count) {
  if (!("setAppBadge" in self.navigator) || typeof count !== "number") return;
  try {
    if (count > 0) await self.navigator.setAppBadge(count);
    else await self.navigator.clearAppBadge();
  } catch {
    // The badge is cosmetic; never let it fail the event.
  }
}

self.addEventListener("push", (event) => {
  let payload = {};
  try {
    payload = event.data ? event.data.json() : {};
  } catch {
    payload = { body: event.data ? event.data.text() : "" }; // non-JSON payload
  }

  event.waitUntil(
    (async () => {
      // Show the notification first and unconditionally: Safari counts a push as
      // silent if showNotification() isn't called within about 30 seconds.
      await self.registration.showNotification(payload.title || "New activity", {
        body: payload.body || "Open the app to see what's new.",
        tag: payload.tag || "activity", // replaces instead of stacking, where supported
        icon: "/icons/icon-192.png",
        badge: "/icons/badge-monochrome-96.png",
        data: { url: payload.url || "/" },
      });
      // Secondary work must not reject the event (WebKit treats that as a failure).
      await Promise.allSettled([
        updateBadge(payload.unread),
        refreshContent("push"),
      ]);
    })(),
  );
});

self.addEventListener("periodicsync", (event) => {
  if (event.tag !== REFRESH_TAG) return;
  // A rejection makes Chromium retry after about 5 and then 15 minutes.
  event.waitUntil(refreshContent("periodicsync").then((feed) => updateBadge(feed.unread)));
});

self.addEventListener("sync", (event) => {
  if (event.tag !== REFRESH_TAG) return;
  event.waitUntil(refreshContent(event.lastChance ? "sync-last-chance" : "sync"));
});

self.addEventListener("message", (event) => {
  if (event.data?.type !== "refresh-now") return;
  // ExtendableMessageEvent supports waitUntil(), keeping the worker alive for the fetch.
  event.waitUntil(
    refreshContent("page")
      .then((feed) => {
        event.source?.postMessage({ type: "refreshed", unread: feed.unread });
        return updateBadge(feed.unread);
      })
      .catch((error) => {
        event.source?.postMessage({ type: "refresh-failed", message: String(error) });
      }),
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const target = new URL(event.notification.data?.url || "/", self.location.origin).href;
  event.waitUntil(
    (async () => {
      const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
      const existing = windows.find((client) => client.url === target);
      if (existing) return existing.focus();
      return self.clients.openWindow(target);
    })(),
  );
});

The page registers whichever trigger the browser offers and falls back to refreshing on launch and when the app returns to the foreground:

src/background-refresh.js
const REFRESH_TAG = "content-refresh";
const TWELVE_HOURS = 12 * 60 * 60 * 1000;
const STALE_AFTER_MS = 60 * 60 * 1000; // foreground refresh if older than an hour
const LAST_REFRESH_KEY = "lastContentRefresh";

function readLastRefresh() {
  try {
    return Number(localStorage.getItem(LAST_REFRESH_KEY)) || 0;
  } catch {
    return 0; // storage blocked: treat as stale
  }
}

function writeLastRefresh() {
  try {
    localStorage.setItem(LAST_REFRESH_KEY, String(Date.now()));
  } catch {
    // Non-fatal: we'll refresh a little more often than necessary.
  }
}

async function registerPeriodicRefresh(registration) {
  if (!("periodicSync" in registration)) return false;
  try {
    const status = await navigator.permissions.query({ name: "periodic-background-sync" });
    if (status.state !== "granted") return false; // not installed, or sync blocked
    // 12 hours is Chromium's floor; asking for slightly more can halve the frequency.
    await registration.periodicSync.register(REFRESH_TAG, { minInterval: TWELVE_HOURS });
    return true;
  } catch (error) {
    console.warn("Periodic Background Sync unavailable:", error);
    return false;
  }
}

async function refreshIfStale(registration) {
  if (Date.now() - readLastRefresh() < STALE_AFTER_MS) return;
  if (!navigator.onLine && "sync" in registration) {
    // Offline in Chromium: let Background Sync run the refresh when we reconnect.
    try {
      await registration.sync.register(REFRESH_TAG);
    } catch (error) {
      console.warn("Background Sync registration failed:", error);
    }
    return;
  }
  registration.active?.postMessage({ type: "refresh-now" });
}

export async function initBackgroundRefresh() {
  if (!("serviceWorker" in navigator)) return;
  const registration = await navigator.serviceWorker.ready;

  navigator.serviceWorker.addEventListener("message", (event) => {
    if (event.data?.type === "refreshed") {
      writeLastRefresh();
      document.dispatchEvent(new CustomEvent("content-refreshed", { detail: event.data }));
    }
  });

  await registerPeriodicRefresh(registration);
  await refreshIfStale(registration); // runs everywhere, including Safari and Firefox

  document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "visible") refreshIfStale(registration);
  });
  window.addEventListener("online", () => refreshIfStale(registration));
}

The server sees a X-Refresh-Reason header on every refresh, so you can measure how much of your freshness comes from background triggers versus foreground fallbacks; on most audiences the fallback dominates. The same structure applies to writes: an outbox in IndexedDB that Background Sync flushes in Chromium and the page flushes on launch, online and visibilitychange everywhere else, as built out in Background Sync and Offline-First Data & Sync.

Testing and debugging background features

Background events are hard to observe because they happen when you're not looking. Chromium DevTools has a Background services group in the Application panel that records Background Fetch, Background Sync, Notifications and Push Messaging events; Chrome's documentation says it can log them "for three days, even when DevTools is not open". The Service Workers pane lets you fire push, sync and periodicsync events with a chosen tag or payload without waiting for the real trigger. Safari's Web Inspector can inspect a Home Screen web app's service worker on a connected iPhone, and since Safari 26 it can attach automatically to new service workers. Firefox's about:debugging lists registered service workers and can send a test push to one. Full procedures are in Browser DevTools and in each API page's debugging section.

Common pitfalls

  • Assuming background execution exists everywhere. Designing a feature around Background Sync or Periodic Background Sync without a foreground path leaves Safari and Firefox users, and every iPhone user, with nothing.
  • Using push for silent data refreshes. It triggers Chrome's generic notification, burns Firefox's quota and loses the subscription on Safari after three pushes.
  • Keeping state in service worker globals. The worker is restarted for each event; counters and caches in variables reset without warning.
  • Not returning the work to waitUntil(). The browser may terminate the worker as soon as the handler returns, dropping the notification or the upload.
  • Awaiting cosmetic work before showNotification(). A slow fetch() or a stuck setAppBadge() in front of the notification risks Safari's 30-second rule. Show first, then enhance.
  • Prompting for notifications on page load. It burns Chrome's prompt budget, is blocked outright by Firefox and Safari without a gesture, and trains users to click "Block".
  • Expecting Periodic Background Sync in a browser tab. Without an installed app for the origin, the permission is denied and register() rejects.
  • Starting Background Fetch from a push handler. Chrome 149 and later reject backgroundFetch.fetch() in a service worker by default.
  • Forgetting to reapply the badge. Chromium keeps badges in memory only, so set the count again on launch.

Pages in this section

  • Push Notifications


    The complete implementation: VAPID keys, PushManager.subscribe(), storing subscriptions, sending from Node and Python, TTL, Urgency and Topic, and subscription renewal.

    Push Notifications

  • The Web Push Protocol


    RFC 8030 requests, RFC 8291 aes128gcm encryption in Node crypto, VAPID JWTs, response codes per push service and sending at scale.

    The Web Push Protocol

  • Notifications API


    Permission states and prompt throttling, showNotification() versus new Notification(), every option, actions, tags and per-OS rendering.

    Notifications API

  • Web Push on iOS & Safari


    Home Screen requirements, APNs responses, the silent-push rule, Declarative Web Push, the EU situation and debugging on a device.

    Web Push on iOS & Safari

  • Badging API


    setAppBadge() and clearAppBadge() from pages and workers, per-OS display rules, push-driven unread counts and favicon fallbacks.

    Badging API

  • Background Sync


    sync.register(), Chromium's retry schedule and lastChance, a complete IndexedDB outbox with idempotency keys, Workbox and fallbacks.

    Background Sync

  • Periodic Background Sync


    periodicSync.register() and minInterval, the installation and engagement rules, and how Chromium actually computes the frequency.

    Periodic Background Sync

  • Background Fetch


    Browser-managed downloads and uploads: backgroundFetch.fetch(), progress, records, the success, fail, abort and click events, and Chromium limits.

    Background Fetch

Further reading

On this site

External references


  1. Safari 16.1 on macOS 13 Ventura (MDN lists 16). WebKit announced Web Push for macOS Ventura in the Safari 16.1 release notes. ↩↩

  2. oldSubscription and newSubscription are exposed since Firefox 137. ↩↩

  3. MDN lists 18.4 for window.pushManager on macOS. ↩

  4. Home Screen web apps only. In Safari tabs and in-app browsers, Notification and PushManager are unavailable. ↩↩↩↩

  5. Chrome and Edge fire the event only when notification permission is granted again after a revocation, with oldSubscription and newSubscription both null. ↩↩

  6. Behind the dom.push.declarative.enabled preference. ↩

  7. Only for installed web apps (and Trusted Web Activities on Android), with frequency scaled by site engagement and at most one event per origin every 12 hours. ↩↩

  8. Since Chrome 149, backgroundFetch.fetch() called from a service worker rejects with NotAllowedError by default; call it from a page. For CORS and Local Network Access enforcement on background fetches, Chrome Platform Status lists enforcement for Chrome 154. ↩↩

  9. Linux resolves the calls without showing anything. Since Chrome 152, the badge of an installed PWA on macOS only appears with notification permission. ↩

  10. The methods exist and resolve but have no effect; Android launchers show a dot for unread notifications instead. ↩

  11. Web apps added to the Dock on macOS 14 Sonoma and later, not Safari tabs. ↩