← Back to DevBytes

Web Periodic Sync: Complete Guide

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

Typical Use Cases

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:

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:

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

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles