Periodic Background Sync¶
Periodic Background Sync lets an installed web app ask the browser to wake its service worker at intervals, with network access, so it can refresh content before the user opens the app. You register a tag with registration.periodicSync.register(tag, { minInterval }), and the browser fires periodicsync events at a frequency it chooses, never more often than you asked, and in Chromium never more often than every 12 hours. It is Chromium-only, requires the app to be installed, and is throttled by how much the user engages with the site, so it improves freshness for your most engaged users but can never be the only way content gets updated.
Key takeaways
minInterval(milliseconds) is a floor, not a schedule. Chromium fires at most once per 12 hours per origin, multiplied by an engagement factor: 12 h for high engagement, 24 h for low or medium, 36 h for minimal, and not at all for sites with zero engagement.register()only succeeds when theperiodic-background-syncpermission is granted. Chromium grants it automatically, with no prompt, when a web app of the origin is installed and the Background sync site setting is allowed; otherwiseregister()rejects withNotAllowedError.- Like one-off sync, registration needs an active worker and an open top-level window of the origin.
- Events run only while the browser runs on desktop; on Android, Chrome is woken by the OS job scheduler. Chrome's documentation says syncs only happen on networks the device has connected to before.
- Each
periodicsyncevent gets at most 3 minutes; a rejected event is retried after about 5 and 15 minutes, then the regular schedule resumes. - Test with the Periodic sync button in DevTools and inspect or edit engagement at
about://site-engagement. - Always pair it with a "refresh on launch if stale" path: Firefox and Safari do not implement it, and Mozilla's position is negative.
Chromium-only, installed apps only
Periodic Background Sync is available in Chrome 80 and later (desktop and Android), Edge 80 and later, Opera 67 and Samsung Internet 13. It is not implemented in Firefox, Safari (including installed web apps on iOS and iPadOS) or Android WebView, and even in Chromium it does nothing for users who have not installed your app.
What periodic sync is for¶
The problem it solves is stale content at launch. A news, podcast or documentation app opened on a train shows whatever it cached during the last visit, which may be days old. Push can deliver fresh content, but it needs a server-side sender, a push subscription and (in practice) a visible notification per message. Periodic Background Sync lets the client pull on its own, quietly, when the browser decides the cost is acceptable.
| One-off Background Sync | Periodic Background Sync | Push | |
|---|---|---|---|
| Initiated by | Client, once | Client, recurring | Server |
| Timing | As soon as online, retries within ~20 min | Browser-chosen, ≥ 12 h apart in Chromium | When the server sends |
| Needs install | No | Yes (Chromium) | No (except on iOS) |
| User-visible requirement | None | None | Notification expected for each push |
| Typical use | Deliver writes made offline | Prefetch content for the next launch | Real-time alerts and messages |
| Engines | Chromium | Chromium | All current engines |
Good fits: refreshing a feed and the first few articles; updating podcast episode lists and artwork (the audio itself belongs in Background Fetch); refreshing reference data such as currency rates or timetables; updating data behind an app badge or a Windows widget (Microsoft's widgets integration builds its periodic updates on this API; see Advanced & Integration Members). Poor fits: anything time-critical (use push), anything user-initiated (just do it), and anything large (use Background Fetch). The spec's resource section says the same: "Large resources should be downloaded by registering a background fetch."
The API surface¶
The Web Periodic Background Synchronization specification is a WICG Draft Community Group Report (last published 12 April 2021). It adds a manager, an event and a permission:
[Exposed=(Window,Worker)]
partial interface ServiceWorkerRegistration {
readonly attribute PeriodicSyncManager periodicSync;
};
[Exposed=(Window,Worker)]
interface PeriodicSyncManager {
Promise<undefined> register(DOMString tag, optional BackgroundSyncOptions options = {});
Promise<sequence<DOMString>> getTags();
Promise<undefined> unregister(DOMString tag);
};
dictionary BackgroundSyncOptions {
[EnforceRange] unsigned long long minInterval = 0;
};
partial interface ServiceWorkerGlobalScope {
attribute EventHandler onperiodicsync;
};
[Exposed=ServiceWorker]
interface PeriodicSyncEvent : ExtendableEvent {
constructor(DOMString type, PeriodicSyncEventInit init);
readonly attribute DOMString tag;
};
dictionary PeriodicSyncEventInit : ExtendableEventInit {
required DOMString tag;
};
register(tag, { minInterval })¶
register() creates or updates a periodic sync registration for tag. minInterval is in milliseconds and defaults to 0. Because of [EnforceRange], a negative number, NaN or Infinity throws a TypeError during argument conversion; any non-negative integer is accepted, and the browser treats it as a lower bound ("The actual interval at which periodicsync events are fired MUST be greater than or equal to this").
The spec's algorithm, in order:
- Reject with
InvalidStateErrorif the registration has no active worker. - Reject with
NotAllowedErrorif theperiodic-background-syncpermission is notgranted. - Reject with
InvalidAccessErrorif no top-level or auxiliary window of the origin exists (the call is "in the background"). - If no registration with the tag exists, create one with state pending and an anchor time of now.
- Otherwise, update its
minIntervalif it differs; if it is currently firing, mark it reregistered-while-firing.
Chromium returns the same error names, with these messages:
| Condition | DOMException | Chromium message |
|---|---|---|
| No active worker | InvalidStateError | "Registration failed - no active Service Worker" |
| Permission not granted (not installed, or Background sync blocked) | NotAllowedError | "Permission denied." |
| No top-level window, or tag longer than 10,240 characters | InvalidAccessError | "Attempted to register a sync event without a window or registration tag too long." |
| Fenced frame | NotAllowedError | "Periodic Background Sync is not allowed in fenced frames." |
| Storage error or feature disabled | UnknownError | "Unknown error." |
Re-registering matters in practice, because apps typically call register() on every launch. In Chromium, calling it with the same minInterval for an existing tag returns early and changes nothing, so the next event keeps its scheduled time. Calling it with a different minInterval replaces the registration and recomputes the delay from now: an app that computes minInterval dynamically (say, from a user setting that has not changed but is recomputed with rounding differences) keeps pushing its next event into the future. Compute the value from constants, or check getTags() first as the example below does.
getTags() and unregister(tag)¶
getTags() resolves with the tags of all periodic registrations for the service worker registration. unregister(tag) removes the registration if it exists and resolves either way; it never rejects for an unknown tag. Periodic tags live in a separate namespace from one-off sync tags, so getTags() on periodicSync and on sync return independent lists.
Registrations persist until you unregister them, the service worker registration is removed (for example by clearing site data), or the browser removes them because the user blocked Background sync for the site. Unregistering when the user turns off a "refresh in background" preference in your app is good manners and saves their battery and data.
The periodicsync event¶
The browser fires periodicsync as a functional event at the active worker, with event.tag set. The contract is the same as for sync: check the tag, pass a promise to event.waitUntil(), and let it reject if the work failed. There is no lastChance attribute; periodic registrations are never dropped because of failures, they simply wait for the next slot.
self.addEventListener("periodicsync", (event) => {
if (event.tag === "content-refresh") {
event.waitUntil(refreshContent());
}
});
A periodicsync handler usually runs with no window open. Things that need a window fail there: sync.register() and periodicSync.register() reject with InvalidAccessError, and clients.openWindow() is not allowed because the event carries no user activation. Showing a notification is possible if the origin already has notification permission, but a notification on every background refresh is exactly what users uninstall apps for; update a badge instead.
Registration states¶
The spec models four states: pending (waiting for its time), firing, suspended, and reregistered-while-firing. Chromium adds the practical meaning of suspended: a registration whose origin has no site engagement at all is parked until the user engages with the site again, at which point Chromium revives the origin's registrations.
When register() succeeds: install, permission, window¶
The permission is tied to installation¶
The permission name is periodic-background-sync. Chromium's permission context for it never prompts (its DecidePermission is unreachable); instead it computes the state:
- On Android, if a Trusted Web Activity for the origin is installed, the permission is granted.
- Otherwise, if no installed web app exists for the origin, it is denied.
- Otherwise, it mirrors the one-off Background sync site setting: granted when that is allowed (the default), denied when the user blocked it.
The install check is per origin, not per app scope: Chromium's source notes that if an app is installed for https://example.com/travel, a registration from https://example.com/maps also passes. Chrome's documentation phrases the requirement as the app being installed and launched as a distinct application, and launching matters for the frequency (see the engagement bonus below). Installing the app changes the permission while the page is open; Chromium fires a change event on the PermissionStatus on desktop, but a known bug (crbug.com/397357113) means the event does not fire when a PWA or TWA is installed or uninstalled on Android, so also react to appinstalled and re-check on the next launch.
const status = await navigator.permissions.query({ name: "periodic-background-sync" });
// "granted": installed and Background sync allowed
// "denied": not installed, or Background sync blocked for the site
// Chromium never reports "prompt" for this permission.
In Firefox and Safari, permissions.query() rejects with a TypeError for this unknown name, so wrap it in try/catch.
Other preconditions¶
- An active service worker and a top-level window of the origin, as for one-off sync. Register from the page, not from inside a
periodicsyncorpushhandler. - Secure context, as for all service worker APIs.
- Not in a fenced frame.
In practice: call it from your app's startup code, after navigator.serviceWorker.ready, when the permission is granted.
How the browser decides the actual frequency¶
The spec deliberately leaves frequency to the browser. It defines a minimum periodic sync interval for any origin (12 hours if the browser defines nothing else), an effective minimum sync interval for origin that adds a browser-defined amount "based off the amount of engagement the user has with the origin", and a separate cap across origins. Chromium's implementation (BackgroundSyncControllerImpl::GetNextEventDelay) works like this.
Step 1: an engagement-scaled floor¶
Chromium's min_periodic_sync_events_interval is 12 hours. It is multiplied by a penalty derived from the site's engagement level:
| Engagement level | Score range | Multiplier | Effective floor |
|---|---|---|---|
| NONE | 0 | n/a | Suspended: no events until engagement returns |
| MINIMAL | above 0, below 1 | 3 | 36 hours |
| LOW | 1 to below 15 | 2 | 24 hours |
| MEDIUM | 15 to below 50 | 2 | 24 hours |
| HIGH | 50 to below 100 | 1 | 12 hours |
| MAX | 100 | 1 | 12 hours |
Step 2: round minInterval up to a multiple of the floor¶
Your minInterval is then snapped up to a multiple of that floor (SnapToMaxOriginFrequency): below the floor it becomes the floor; an exact multiple stays as is; anything else rounds up to the next multiple.
minInterval requested | Engagement | Floor | Actual minimum delay |
|---|---|---|---|
| 1 hour | HIGH | 12 h | 12 h |
| 24 hours | HIGH | 12 h | 24 h |
| 24 hours | LOW | 24 h | 24 h |
| 30 hours | LOW | 24 h | 48 h |
| 12 hours | MINIMAL | 36 h | 36 h |
| 0 (default) | MEDIUM | 24 h | 24 h |
The 30-hour row is the surprise: asking for slightly more than a day can halve your frequency for a medium-engagement user. Choose minInterval as 12 or 24 hours (or leave it at 0 and let engagement decide) unless you have a reason to go longer.
Step 3: at most one event per origin per 12 hours¶
If the origin has several tags, Chromium aligns them (ApplyMinGapForOrigin) so that events for the origin are at least 12 hours apart: a tag due shortly before or after another tag's scheduled event is moved to fire with it or 12 hours after it. Several tags therefore do not buy more wake-ups; one tag that refreshes everything is simpler and equivalent.
Step 4: conditions at fire time¶
When the delay has elapsed, the event still needs:
- Connectivity. Like one-off sync, Chromium needs a network connection. Chrome's and Edge's documentation both state that periodic syncs only happen on a network the device has connected to before, which mitigates the spec's "history leaking" concern of syncing from a new network.
- A running browser (desktop). On Windows, macOS and Linux, events fire only while the browser process runs; a user who quits Chrome every evening gets their refresh the next time Chrome starts, which is when a launch-time refresh would run anyway. On Android, Chrome schedules a wake-up task with the OS job scheduler (
PeriodicBackgroundSyncChromeWakeUpTask), so events can fire with Chrome closed. - Device conditions. Chrome's documentation says the frequency "takes into account the device's power and connectivity state"; on Android, the OS scheduler batches and defers jobs (Doze, battery saver), so the real delay is often longer than the computed one.
flowchart TD
A["periodicSync.register(tag, minInterval)"] --> B{"Engagement level"}
B -- NONE --> S["Suspended until the user engages again"]
B -- "MINIMAL: x3" --> C["Floor 36 h"]
B -- "LOW or MEDIUM: x2" --> D["Floor 24 h"]
B -- "HIGH or MAX: x1" --> E["Floor 12 h"]
C --> F["Round minInterval up to a multiple of the floor"]
D --> F
E --> F
F --> G["Keep at least 12 h from other events of the origin"]
G --> H{"Due, online, browser running or woken?"}
H -- yes --> I["Fire periodicsync, 3 min limit"]
H -- no --> H
I -- fulfilled --> J["Schedule next from now"]
I -- rejected --> K["Retry after about 5 min, then 15 min"]
K --> J Site engagement in Chromium¶
Site engagement is a per-origin score from 0 to 100 that Chrome keeps for many features (it also influences autoplay and media decisions). The defaults in site_engagement_score.cc:
- Points come from navigations to the site (1.5), user input such as clicks and key presses (0.6), the first engagement of the day (1.5) and media playback, capped at 15 points per day.
- The base score decays without engagement: multiplied by 0.984 every 2 hours, which halves it in about three and a half days.
- An installed app gets an installed bonus of 5 points for 10 days after it was last launched from its installed shortcut (home screen icon, dock, Start menu). That bonus alone lifts an otherwise idle origin from NONE to LOW, which is why launching the installed app matters.
Field trials can change these parameters, so treat the numbers as today's defaults, not guarantees. Two practical conclusions: periodic sync rewards apps people open regularly from their installed icon, and it quietly stops for people who stopped using the app, which is the behavior you want.
Retries after a failed event¶
Chromium applies its one-off retry parameters to periodic events too: when the waitUntil() promise rejects or the 3-minute limit expires, the next attempt is scheduled after 5 minutes, then 15 minutes. After the third failed attempt the counter resets and the registration goes back to its regular schedule. A rejected promise therefore buys up to two quick retries, not permanent failure. The spec permits this ("The user agent MAY define a maximum number of retries"), recommending that the time spent retrying stay an order of magnitude below the minimum interval, which 5 + 15 minutes against 12 hours satisfies.
A complete implementation with fallback¶
The design has one refresh routine in the service worker and three triggers:
periodicsync, where the browser grants it (installed Chromium apps).- A launch-time check in the page: if the last refresh is older than a threshold, ask the worker to refresh now. This runs in every browser and is the only mechanism in Firefox and Safari.
- Optionally a push message that tells the worker new content exists, if you already run a push service.
The last refresh time is stored as a small JSON response in the same cache as the content, so the page can read it with the Cache API without a round trip to the worker.
Page code¶
// background-refresh.js: page side. Registers periodic sync where the browser
// allows it and falls back to "refresh on launch when stale" everywhere.
const TAG = "content-refresh";
const REQUESTED_INTERVAL = 12 * 60 * 60 * 1000; // ms; Chromium rounds this up
const STALE_AFTER = 3 * 60 * 60 * 1000; // launch-time refresh threshold
const CONTENT_CACHE = "content-v1";
const META_URL = "/__meta/last-refresh";
export async function initBackgroundRefresh({ enabled = true } = {}) {
if (!("serviceWorker" in navigator)) return { mode: "none" };
const registration = await navigator.serviceWorker.ready;
const mode = await syncPeriodicRegistration(registration, enabled);
// Installing the app later can flip the permission to "granted"; register
// then instead of waiting for the next launch.
watchPermission(() => syncPeriodicRegistration(registration, enabled));
// Every browser, every launch: never show content older than STALE_AFTER
// just because periodic sync is missing, blocked or throttled.
if (enabled) await refreshIfStale(registration);
return { mode };
}
async function syncPeriodicRegistration(registration, enabled) {
if (!("periodicSync" in registration)) return "unsupported";
const state = await permissionState();
const tags = await registration.periodicSync.getTags();
if (!enabled || state !== "granted") {
// The user turned the feature off in *your* settings, or the browser
// withdrew the permission: stop asking for wake-ups.
if (tags.includes(TAG)) await registration.periodicSync.unregister(TAG);
return state === "granted" ? "disabled-by-user" : "not-permitted";
}
if (!tags.includes(TAG)) {
try {
await registration.periodicSync.register(TAG, { minInterval: REQUESTED_INTERVAL });
} catch (error) {
// NotAllowedError: not installed / permission not granted.
// InvalidAccessError: no top-level window (should not happen here).
console.warn("[refresh] periodicSync.register() failed:", error.name, error.message);
return "not-permitted";
}
}
return "periodic-sync";
}
async function permissionState() {
try {
const status = await navigator.permissions.query({ name: "periodic-background-sync" });
return status.state; // "granted" or "denied"; Chromium never returns "prompt"
} catch {
return "unsupported"; // unknown permission name
}
}
async function watchPermission(onChange) {
try {
const status = await navigator.permissions.query({ name: "periodic-background-sync" });
status.addEventListener("change", () => onChange().catch(console.warn));
} catch {
/* not supported: nothing to watch */
}
window.addEventListener("appinstalled", () => onChange().catch(console.warn));
}
async function refreshIfStale(registration) {
let lastRefresh = 0;
try {
const cache = await caches.open(CONTENT_CACHE);
const meta = await cache.match(META_URL);
if (meta) lastRefresh = (await meta.json()).at || 0;
} catch {
/* treat unreadable metadata as stale */
}
if (Date.now() - lastRefresh < STALE_AFTER) return false;
registration.active?.postMessage({ type: "REFRESH_CONTENT" });
return true;
}
Notes on the page side:
getTags()beforeregister()avoids restarting the schedule and avoids a registration call on every launch.- The permission's
changeevent andappinstalledcover the moment the user installs the app from a browser tab: the page can register immediately instead of on the next launch. Remember the Android caveat above. refreshIfStale()runs even when periodic sync is registered. If the browser has throttled the site to every 36 hours, the user still never sees content older thanSTALE_AFTERat launch.- An in-app setting ("Refresh in background") maps to
enabled; turning it off unregisters.
Service worker¶
// sw.js: one refresh routine, three triggers (periodicsync, page request,
// and optionally push). Same-origin URLs only, to keep responses non-opaque.
const TAG = "content-refresh";
const CONTENT_CACHE = "content-v1";
const META_URL = "/__meta/last-refresh";
const FEED_URL = "/api/feed?limit=30";
const MAX_ARTICLES = 10;
const REQUEST_TIMEOUT_MS = 15_000;
self.addEventListener("periodicsync", (event) => {
if (event.tag !== TAG) return;
event.waitUntil(refreshContent({ trigger: "periodicsync" }));
});
self.addEventListener("message", (event) => {
if (event.data?.type !== "REFRESH_CONTENT") return;
event.waitUntil(refreshContent({ trigger: "launch" }).catch((error) => {
console.warn("[refresh] launch refresh failed", error);
}));
});
async function refreshContent({ trigger }) {
// Respect the user's data saver preference where the browser exposes it.
if (self.navigator.connection?.saveData && trigger === "periodicsync") return;
// Serialize with other refreshes (a page-triggered one may be running).
const run = async () => {
const cache = await caches.open(CONTENT_CACHE);
const feedResponse = await fetchWithTimeout(FEED_URL);
if (!feedResponse.ok) {
// Reject: Chromium retries a failed periodicsync (about 5 and 15 min).
throw new Error(`Feed request failed: HTTP ${feedResponse.status}`);
}
const feed = await feedResponse.clone().json();
const articleUrls = feed.items
.slice(0, MAX_ARTICLES)
.map((item) => new URL(item.url, self.location.origin))
.filter((url) => url.origin === self.location.origin)
.map((url) => url.href);
// Fetch everything first, commit to the cache only afterwards, so a
// half-finished refresh never leaves the feed pointing at missing pages.
const articles = await Promise.allSettled(
articleUrls.map(async (url) => {
const response = await fetchWithTimeout(url);
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
return [url, response];
})
);
const fetched = articles.filter((r) => r.status === "fulfilled").map((r) => r.value);
await Promise.all(fetched.map(([url, response]) => cache.put(url, response)));
await cache.put(FEED_URL, feedResponse);
await pruneCache(cache, new Set([FEED_URL, META_URL, ...fetched.map(([url]) => url)]));
await cache.put(
META_URL,
new Response(
JSON.stringify({ at: Date.now(), trigger, articles: fetched.length }),
{ headers: { "Content-Type": "application/json" } }
)
);
const unread = feed.items.filter((item) => !item.read).length;
if (trigger === "periodicsync" && "setAppBadge" in self.navigator) {
await self.navigator.setAppBadge(unread).catch(() => {});
}
await broadcast({ type: "CONTENT_REFRESHED", at: Date.now(), trigger });
};
if (self.navigator.locks) {
return self.navigator.locks.request("content-refresh", run);
}
return run();
}
async function fetchWithTimeout(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
// no-cache: revalidate with the server; the HTTP cache must not answer
// a background refresh with the same stale copy.
return await fetch(url, { cache: "no-cache", signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function pruneCache(cache, keepUrls) {
// Cache keys are absolute URLs; normalize the keep-list the same way.
const keep = new Set([...keepUrls].map((url) => new URL(url, self.location.origin).href));
const requests = await cache.keys();
await Promise.all(
requests.filter((request) => !keep.has(request.url)).map((request) => cache.delete(request))
);
}
async function broadcast(message) {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
for (const client of windows) client.postMessage(message);
}
Why it is written this way:
- Stage, then commit. All article fetches finish before anything is written, and the feed is written last, so the cache never lists articles that are missing. With larger datasets, write into a new versioned cache and swap names, as in Precaching & Runtime Caching.
- Reject on real failure. A failed feed request rejects, which earns the two Chromium retries. Individual article failures do not: a partial refresh is still an improvement.
- Bounded work. Ten articles with a 15-second timeout each fit comfortably inside the 3-minute event limit even on a slow connection. The per-request timeout matters more than the count: one hung connection could otherwise consume the whole event.
cache: "no-cache"forces revalidation with the server, so a background refresh does not store the HTTP cache's own stale copy (see HTTP Caching & Service Workers).- Web Locks serialize a periodic refresh with a launch-triggered one that may start at the same moment.
- Badge, not notification.
navigator.setAppBadge()is available to service workers in Chromium and communicates "new content" without interrupting; see Badging API. - Data saver.
navigator.connection.saveDatais exposed to workers in Chromium; skipping background refreshes when it is set respects the user's choice. Launch-time refreshes still run, because the user is actively asking for content.
Serving the refreshed content is ordinary runtime caching: the feed with stale-while-revalidate or network-first and the articles cache-first, as covered in Caching Strategies.
Updating the service worker from periodicsync¶
A background refresh is also an opportunity to check for a new service worker version with self.registration.update(). Keep expectations modest: Chromium rate-limits update checks triggered from a worker that controls no clients, and a new worker still waits for the old one to release its clients before activating. Updating Service Workers covers the details.
Testing and debugging¶
- Trigger an event. In Chrome or Edge DevTools, Application > Service workers has a Periodic sync field (default tag
test-tag-from-devtools) and button. It dispatchesperiodicsyncwith that tag directly through theServiceWorker.dispatchPeriodicSyncEventprotocol method. The tag does not need to be registered and the app does not need to be installed, which is convenient for handler development but proves nothing about whether real events will fire. - Record real activity. Application > Background services > Periodic background sync records registrations (with
minInterval), the delay Chromium computed ("Got next event delay", with Next Attempt Delay (ms)), dispatches, failures and unregistrations. Recording continues for up to three days with DevTools closed, which is how you confirm the real cadence on a test device. - Inspect engagement.
about://site-engagement(also reachable aschrome://site-engagement) lists every origin's base score, installed bonus and total. The base score is an editable field, so you can move a test profile between engagement levels and watch the computed delay change on the next registration. - Check registrations from the console.
(await navigator.serviceWorker.ready).periodicSync.getTags()in the page's DevTools console. - Check the permission.
(await navigator.permissions.query({ name: "periodic-background-sync" })).stateshould begrantedin the installed app. If it isdeniedin a browser tab, verify that the app is installed in the same browser profile. - Automation. The same protocol method,
ServiceWorker.dispatchPeriodicSyncEventwithorigin,registrationIdandtag, can be called from Puppeteer or Playwright through a CDP session, exactly like the one-off sync example on Background Sync. See Automated Testing.
Privacy and resource considerations¶
The spec's privacy section lists the same risks as one-off sync, amplified by repetition: a site the user visited once could learn the user's IP address (and so approximate location) every day for as long as the registration lives, and fetches made on a new network reveal the visit to that network. Its mitigations are the ones Chromium implements: a permission, installation as a signal of user intent, engagement-based throttling that decays to suspension, a per-origin minimum interval, and limits on event duration and retries.
Mozilla's standards position is negative: its concerns about cross-network tracking and resource use "appear substantially harder" to address for periodic sync than for one-off sync. Firefox has no implementation, and no WebKit implementation exists either. See Privacy & Storage Partitioning and Permissions.
For your own app: every periodic refresh costs the user battery and data. Fetch only what the next launch needs, honor data saver, and offer a setting to turn background refresh off.
Browser support¶
Support data as of September 2026. For live data, see MDN's PeriodicSyncManager compatibility table and caniuse: PeriodicSyncManager.
| Feature | Chrome / Edge | Firefox | Safari (macOS and iOS) | Samsung Internet | Android WebView |
|---|---|---|---|---|---|
ServiceWorkerRegistration.periodicSync, register(), getTags(), unregister() | ✅ 80 / ✅ 80 ⚠️ | ❌ | ❌ | ✅ 13.0 ⚠️ | ❌ |
periodicsync event, PeriodicSyncEvent.tag | ✅ 80 / ✅ 80 ⚠️ | ❌ | ❌ | ✅ 13.0 ⚠️ | ❌ |
periodic-background-sync permission name | ✅ | ❌ | ❌ | ✅ | ❌ |
⚠️ Only for installed web apps (and, on Android, installed Trusted Web Activities); events are throttled by site engagement and fire at most every 12 hours per origin.
Opera supports it from version 67. MDN marks the API as experimental because only one engine implements it; it has nevertheless been enabled by default in Chromium since version 80 (Chrome Platform Status). Mozilla's position is tracked in standards-positions issue 214.
Common pitfalls¶
- Expecting your interval.
minInterval: 60 * 60 * 1000does not mean hourly. The floor is 12 hours at best, 36 hours for barely engaged users, and suspension for unengaged ones. - Units.
minIntervalis in milliseconds. Passing seconds (for example copying a manifestupdatevalue) requests a far shorter interval than intended; the browser's floor hides the mistake, so it goes unnoticed. - Awkward multiples. A
minIntervalslightly above a multiple of the floor rounds up to the next multiple: 30 hours becomes 48 for a 24-hour floor. - Re-registering with a changing interval. A different
minIntervalrestarts the schedule from now. Register once, or only when the value really changes. - Testing in a browser tab. The permission is denied until the app is installed in that profile;
register()rejects withNotAllowedErrorin tabs of a non-installed site. - Registering from the worker.
periodicSync.register()needs a top-level window; call it from the page. - No fallback. Without a launch-time staleness check, users on Firefox, Safari, uninstalled Chromium, or with low engagement see stale content.
- Heavy handlers. Large downloads fail the 3-minute limit and burn battery; hand large files to Background Fetch and keep the periodic handler small.
- Notification spam. Background refresh is silent by design. Use a badge.
- Many tags for more frequency. Chromium keeps an origin's events at least 12 hours apart; extra tags add complexity, not wake-ups.
Further reading¶
On this site
- Background Sync: one-off sync, retries and the outbox pattern
- Background Fetch: large downloads that outlive the tab
- Installability Criteria: what makes an app installable, a prerequisite here
- Push Notifications: server-initiated updates in every engine
- Badging API: signaling new content without notifications
- Caching Strategies: serving the refreshed content
- Offline UX & Fallbacks: communicating content freshness
- Advanced & Integration Members: Windows widgets that update through periodic sync
External references
- Web Periodic Background Synchronization specification (WICG)
- MDN: Web Periodic Background Synchronization API and PeriodicSyncManager
- Chrome for Developers: Richer offline experiences with the Periodic Background Sync API
- Microsoft Edge: Synchronize and update a PWA in the background
- Chromium source: background_sync_controller_impl.cc (delay computation)
- Chromium source: periodic_background_sync_permission_context.cc (install requirement)
- Chrome Platform Status: Periodic Background Sync
- Mozilla standards position on Periodic Background Sync