Introduction to Web Periodic Sync
The Periodic Background Sync API is a Progressive Web App (PWA) capability that allows web applications to periodically fetch fresh data in the background, even when the user is not actively using the app or when the app is closed. This means your users can open your app and immediately see up-to-date content such as news articles, weather forecasts, inbox messages, or calendar events without waiting for a network request on launch.
Unlike the one-shot Background Sync API, which fires only when a previously failed request needs to be retried, Periodic Sync runs on a recurring schedule defined by the browser. This makes it ideal for content refresh scenarios where freshness matters more than immediate delivery.
Why Periodic Sync Matters
Modern users expect native-app-like experiences from the web. Native apps have long enjoyed the ability to wake up in the background, fetch new data, and present it instantly when the user opens them. Periodic Sync brings this same capability to the web platform, closing one of the last major gaps between web and native applications.
Key Benefits
- Instant content availability: Users see fresh data the moment they open the app, with no loading spinners.
- Improved perceived performance: Because data is pre-fetched, the app feels significantly faster.
- Better offline experience: Fresh content is cached and ready even when connectivity drops after the sync runs.
- Battery and data efficiency: The browser batches and schedules syncs intelligently, respecting device state and user patterns.
- Engagement boost: Always-fresh content encourages users to return more frequently.
Typical Use Cases
- News and blog apps refreshing article lists
- Email clients pulling new messages
- Weather apps updating forecasts
- Social media apps fetching feed updates
- Productivity apps syncing tasks or notes
- Media apps pre-caching artwork or metadata
Browser Support and Prerequisites
Periodic Sync is currently supported in Chromium-based browsers (Chrome, Edge, Opera, Samsung Internet) on desktop and Android. Safari and Firefox do not yet support it, so you must implement feature detection and graceful degradation.
Before you can use Periodic Sync, your app must meet several requirements:
- The app must be a Progressive Web App with a registered service worker.
- The app must be installed (added to home screen or installed as a desktop app).
- The app must have a valid web app manifest.
- The site must be served over HTTPS.
- The user must have engaged with the app recently; browsers throttle or skip syncs for apps that have not been used.
Feature detection is straightforward:
if ('serviceWorker' in navigator && 'periodicSync' in ServiceWorkerRegistration.prototype) {
console.log('Periodic Background Sync is supported');
} else {
console.log('Periodic Background Sync is not supported');
}
Requesting Permission
Periodic Sync requires explicit permission from the user. The permission is requested through the navigator.permissions API. You should request this permission in response to a user action, such as clicking a button, rather than automatically on page load.
async function requestPeriodicSyncPermission() {
const status = await navigator.permissions.query({
name: 'periodic-background-sync',
});
if (status.state === 'granted') {
console.log('Permission already granted');
return true;
} else if (status.state === 'prompt') {
// Browsers may show a prompt or auto-deny based on engagement
console.log('Permission will be evaluated by the browser');
return false;
} else {
console.log('Permission denied');
return false;
}
}
Note that unlike notifications, Periodic Sync does not always show a visible permission prompt. The browser decides based on user engagement and site quality signals. If the user has not engaged with your app enough, the permission may be silently denied.
Registering a Periodic Sync
Once permission is granted and your service worker is registered, you can register a periodic sync. You provide a tag (a unique name for this sync) and a minInterval (the minimum time in milliseconds between syncs).
async function registerPeriodicSync() {
const registration = await navigator.serviceWorker.ready;
try {
await registration.periodicSync.register('content-refresh', {
minInterval: 24 * 60 * 60 * 1000, // 24 hours
});
console.log('Periodic sync registered successfully');
} catch (error) {
console.error('Periodic sync registration failed:', error);
}
}
Understanding minInterval
The minInterval parameter is a hint, not a guarantee. The browser may run the sync more or less frequently depending on factors such as:
- How often the user opens the app
- Device battery level and charging state
- Network type (Wi-Fi vs. cellular)
- System-wide resource constraints
In practice, you should not rely on precise timing. Choose a minInterval that reflects how stale your data can become before it negatively impacts the user experience. For a news app, a few hours might be appropriate. For a weather app, 12 to 24 hours may suffice.
Checking Existing Registrations
You can inspect existing periodic sync registrations to avoid duplicates or to update intervals:
async function checkPeriodicSync() {
const registration = await navigator.serviceWorker.ready;
const tags = await registration.periodicSync.getTags();
if (tags.includes('content-refresh')) {
console.log('Content refresh sync is already registered');
} else {
console.log('No content refresh sync found');
}
}
Unregistering a Periodic Sync
If your app no longer needs a particular sync, unregister it to free resources:
async function unregisterPeriodicSync() {
const registration = await navigator.serviceWorker.ready;
try {
await registration.periodicSync.unregister('content-refresh');
console.log('Periodic sync unregistered');
} catch (error) {
console.error('Failed to unregister periodic sync:', error);
}
}
Handling the Sync Event in the Service Worker
The actual background work happens inside the service worker. When the browser decides to run your periodic sync, it fires a periodicsync event on the service worker. You listen for this event and perform your data fetching there.
// service-worker.js
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'content-refresh') {
event.waitUntil(refreshContent());
} else if (event.tag === 'sync-messages') {
event.waitUntil(syncMessages());
} else {
// Unknown sync, do nothing
}
});
async function refreshContent() {
try {
const cache = await caches.open('content-cache');
const response = await fetch('/api/latest-content');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// Cache the fresh response
await cache.put('/api/latest-content', new Response(JSON.stringify(data)));
// Optionally cache individual items
for (const item of data.items) {
const itemResponse = await fetch(item.url);
if (itemResponse.ok) {
await cache.put(item.url, itemResponse);
}
}
// Notify open clients that new content is available
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({ type: 'CONTENT_UPDATED', timestamp: Date.now() });
});
console.log('Content refreshed successfully');
} catch (error) {
console.error('Content refresh failed:', error);
// The browser may retry on the next interval
}
}
async function syncMessages() {
try {
const response = await fetch('/api/messages?since=' + getLastSyncTime());
const messages = await response.json();
// Store messages in IndexedDB
await storeMessages(messages);
console.log('Messages synced successfully');
} catch (error) {
console.error('Message sync failed:', error);
}
}
function getLastSyncTime() {
// Retrieve from IndexedDB or return a default
return 0;
}
async function storeMessages(messages) {
// Implement IndexedDB storage logic here
}
Key Points About the Sync Handler
- Always call
event.waitUntil()with a promise so the service worker stays alive until your work completes. - Use the
event.tagto distinguish between different registered syncs. - Handle errors gracefully. A failed sync will simply be retried at the next interval.
- Keep sync operations focused and efficient. Long-running operations consume battery and data.
- Use
postMessageto notify open tabs that new data is available so they can update the UI.
Putting It All Together: A Complete Example
Here is a complete example showing the main page logic and the service worker working together.
Main Page (index.js)
// Register the service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js');
console.log('Service Worker registered with scope:', registration.scope);
// Listen for content update messages from the service worker
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'CONTENT_UPDATED') {
console.log('New content available, refreshing UI...');
loadCachedContent();
}
});
// Set up periodic sync if supported
if ('periodicSync' in registration) {
await setupPeriodicSync(registration);
} else {
console.log('Periodic Sync not supported, falling back to normal fetching');
loadCachedContent();
}
} catch (error) {
console.error('Service Worker registration failed:', error);
}
});
}
async function setupPeriodicSync(registration) {
const permission = await navigator.permissions.query({
name: 'periodic-background-sync',
});
if (permission.state !== 'granted') {
console.log('Periodic sync permission not granted');
loadCachedContent();
return;
}
try {
await registration.periodicSync.register('content-refresh', {
minInterval: 12 * 60 * 60 * 1000, // 12 hours
});
console.log('Periodic sync registered');
} catch (error) {
console.error('Could not register periodic sync:', error);
}
// Always load cached content on startup for instant display
loadCachedContent();
}
async function loadCachedContent() {
try {
const cache = await caches.open('content-cache');
const cachedResponse = await cache.match('/api/latest-content');
if (cachedResponse) {
const data = await cachedResponse.json();
renderContent(data);
} else {
// No cache yet, fetch directly
const response = await fetch('/api/latest-content');
const data = await response.json();
renderContent(data);
}
} catch (error) {
console.error('Failed to load content:', error);
}
}
function renderContent(data) {
const container = document.getElementById('content');
container.innerHTML = data.items
.map((item) => `<article><h3>${item.title}</h3><p>${item.summary}</p></article>`)
.join('');
}
Service Worker (service-worker.js)
const CACHE_NAME = 'content-cache-v1';
const CONTENT_URL = '/api/latest-content';
// Install event: pre-cache essential assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(['/', '/index.html', '/styles.css', '/app.js']);
})
);
self.skipWaiting();
});
// Activate event: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
// Periodic sync event
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'content-refresh') {
event.waitUntil(refreshContent());
}
});
async function refreshContent() {
try {
const response = await fetch(CONTENT_URL);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const cache = await caches.open(CACHE_NAME);
await cache.put(CONTENT_URL, response.clone());
const data = await response.json();
// Pre-cache individual content items
if (data.items && data.items.length) {
await Promise.all(
data.items.map(async (item) => {
try {
const itemResponse = await fetch(item.url);
if (itemResponse.ok) {
await cache.put(item.url, itemResponse);
}
} catch (e) {
console.warn('Could not cache item:', item.url, e);
}
})
);
}
// Notify any open clients
const clients = await self.clients.matchAll({ type: 'window' });
clients.forEach((client) => {
client.postMessage({
type: 'CONTENT_UPDATED',
itemCount: data.items ? data.items.length : 0,
});
});
console.log('Periodic content refresh complete');
} catch (error) {
console.error('Periodic content refresh failed:', error);
}
}
// Fetch event: serve from cache, fall back to network
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
Web App Manifest (manifest.json)
{
"name": "Fresh News PWA",
"short_name": "FreshNews",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1976d2",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Link the manifest in your HTML head:
<link rel="manifest" href="/manifest.json">
Best Practices
1. Always Implement Feature Detection and Fallbacks
Periodic Sync is not available in all browsers. Always check for support and provide a fallback strategy, such as fetching data on page load or using the Page Visibility API to refresh content when the user returns to your app.
async function initContentLoading() {
if ('serviceWorker' in navigator && 'periodicSync' in ServiceWorkerRegistration.prototype) {
await setupPeriodicSync();
} else {
// Fallback: refresh on page load and when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
loadCachedContent();
}
});
loadCachedContent();
}
}
2. Keep Sync Operations Lightweight
The browser allocates limited time for periodic sync events. Avoid heavy computations, large downloads, or chained requests that could time out. Fetch only what you need, and consider using conditional requests with If-Modified-Since or If-None-Match headers to minimize data transfer.
3. Use Meaningful Tags
Each periodic sync is identified by a tag. Use descriptive, unique tags for different sync purposes. If you register a new sync with the same tag as an existing one, it replaces the previous registration.
4. Respect Data and Battery Usage
Choose appropriate minInterval values. A shorter interval means more frequent network requests, which consumes more battery and data. Consider using the Network Information API to adjust behavior based on connection type, though note that this API also has limited browser support.
5. Store Data in IndexedDB or Cache API
Use the Cache API for responses you want to serve offline, and IndexedDB for structured data that your app needs to query. Both are available inside the service worker, making them ideal for storing data fetched during periodic sync.
6. Communicate Updates to the UI
When a periodic sync completes, use postMessage to notify any open client tabs. This allows your app to update the UI in real time without requiring a page reload.
7. Do Not Rely on Exact Timing
The browser controls when periodic sync events fire. Never use periodic sync for time-sensitive operations like alarms, reminders, or real-time messaging. It is designed for opportunistic content refresh, not guaranteed scheduling.
8. Test Thoroughly
Testing periodic sync can be challenging because the browser controls timing. In Chrome DevTools, you can manually trigger a periodic sync event through the Application panel under Service Workers. Look for the "Periodic Sync" button or use the console:
// In DevTools console, trigger a periodic sync manually
const registration = await navigator.serviceWorker.ready;
await registration.periodicSync.register('content-refresh', {
minInterval: 1000,
});
You can also simulate the event by posting a message to your service worker or by using DevTools' "Update" and "Sync" buttons in the Service Workers section.
Debugging Tips
- Check chrome://serviceworker-internals/ for detailed service worker logs.
- Use the Application tab in Chrome DevTools to inspect registered periodic syncs.
- Monitor the Console for errors thrown during the
periodicsyncevent handler. - Verify that your app is installed and that the user has engaged with it recently; otherwise, the browser may skip syncs.
- Ensure your service worker is active and not in a waiting state.
Common Pitfalls
Forgetting to Install the PWA
Periodic Sync only works for installed PWAs. If your app is not installed, the register() call will fail silently or throw an error. Make sure your manifest is valid and that the user has added your app to their home screen or installed it on desktop.
Ignoring Error Handling
Network requests can fail for many reasons during background sync. Always wrap your fetch calls in try-catch blocks and log errors so you can diagnose issues during development and production.
Over-Caching
Be mindful of storage limits. Caching too many resources can fill up the user's device storage and cause the browser to evict your data. Implement a cache eviction strategy that removes old or unused entries.
async function pruneCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxEntries) {
// Delete oldest entries first
const toDelete = keys.slice(0, keys.length - maxEntries);
await Promise.all(toDelete.map((key) => cache.delete(key)));
}
}
Assuming Sync Will Always Run
The browser may never fire a periodic sync event if the device is in power-save mode, the user has not opened the app in a long time, or network conditions are poor. Design your app to work correctly even if syncs are infrequent or never happen.
Conclusion
The Periodic Background Sync API is a powerful tool for building web apps that feel as responsive and fresh as native applications. By fetching data in the background on a recurring schedule, you can ensure that users always see up-to-date content the moment they open your app, without loading screens or waiting on network requests. While the API comes with important constraints—it requires an installed PWA, browser permission, and careful handling of timing and errors—the benefits to user experience are substantial. By following the best practices outlined in this guide, implementing robust fallbacks for unsupported browsers, and keeping your sync operations lightweight and efficient, you can deliver a fast, reliable, and engaging experience that keeps users coming back. As browser support continues to evolve, Periodic Sync will become an increasingly essential part of the modern web developer's toolkit for building world-class progressive web apps.