Back to Blog

Background Sync & Push API Internals: Service Worker Push, Periodic Sync, and Offline-First Patterns

March 22, 202647 min read0 views

Background Sync & Push API Internals: Service Worker Push, Periodic Sync, and Offline-First Patterns

Background Sync and Push API enable web applications to synchronize data and receive notifications even when the page isn't open. Understanding service worker lifecycle integration, sync event guarantees, push message encryption, and quota limitations is essential for building reliable offline-first applications and engagement-driving notification systems.

Background Sync API

One-Time Sync: Guaranteeing Data Delivery

// Register a sync when online connectivity is needed
async function queueSync(tag) {
  const registration = await navigator.serviceWorker.ready;

  // Check if BackgroundSync is supported
  if ('sync' in registration) {
    await registration.sync.register(tag);
    console.log(`Sync registered: ${tag}`);
  } else {
    // Fallback: attempt immediate sync
    await performSync(tag);
  }
}

// Usage: Queue sync when offline
async function submitForm(data) {
  try {
    await fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(data)
    });
  } catch (error) {
    // Network failed - queue for background sync
    await saveToIndexedDB('pending-submissions', data);
    await queueSync('submit-form');
  }
}

Service Worker Sync Handler

// service-worker.js
self.addEventListener('sync', (event) => {
  console.log('Sync event fired:', event.tag);

  if (event.tag === 'submit-form') {
    event.waitUntil(handleFormSync());
  }

  if (event.tag.startsWith('upload-')) {
    event.waitUntil(handleUploadSync(event.tag));
  }
});

async function handleFormSync() {
  const db = await openDB('app-db');
  const pending = await db.getAll('pending-submissions');

  for (const item of pending) {
    try {
      await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(item.data),
        headers: { 'Content-Type': 'application/json' }
      });

      // Success - remove from pending
      await db.delete('pending-submissions', item.id);
    } catch (error) {
      // Failed - will retry on next sync event
      console.error('Sync failed for item:', item.id);
      throw error; // Re-throw to signal sync failure
    }
  }
}

Sync Event Guarantees

┌─────────────────────────────────────────────────────────────────────────────┐
│                    BACKGROUND SYNC BEHAVIOR                                  │
└─────────────────────────────────────────────────────────────────────────────┘

When sync event fires:
──────────────────────
1. Device has network connectivity
2. Service worker is active
3. Browser determines it's a good time (battery, network quality)

Retry behavior:
───────────────
• If event.waitUntil() promise rejects → sync will retry
• Exponential backoff between retries
• Browser may limit retry attempts
• Eventually sync is abandoned (varies by browser)

Example timeline:
─────────────────
T+0:    User submits form offline
        → Saved to IndexedDB
        → sync.register('submit-form') called

T+5min: Device goes online
        → 'sync' event fires
        → Handler attempts fetch
        → Fetch fails (server down)
        → Promise rejects

T+10min: Retry 1
        → Still failing
        → Promise rejects

T+30min: Retry 2
        → Server back up
        → Fetch succeeds
        → Promise resolves
        → Sync complete

Constraints:
────────────
• Must complete within ~5 minutes per attempt
• No guaranteed timing for when sync fires
• User can close browser - sync still happens
• Requires service worker registration
• HTTPS only

Periodic Background Sync

// Request periodic sync permission
async function setupPeriodicSync() {
  const status = await navigator.permissions.query({
    name: 'periodic-background-sync',
  });

  if (status.state === 'granted') {
    const registration = await navigator.serviceWorker.ready;

    // Register periodic sync
    await registration.periodicSync.register('content-update', {
      minInterval: 24 * 60 * 60 * 1000, // Minimum 24 hours
    });
  }
}

// Service worker handler
self.addEventListener('periodicsync', (event) => {
  if (event.tag === 'content-update') {
    event.waitUntil(updateContent());
  }
});

async function updateContent() {
  const cache = await caches.open('content-cache');

  // Fetch fresh content
  const response = await fetch('/api/latest-content');
  await cache.put('/api/latest-content', response.clone());

  // Optionally notify user of new content
  const data = await response.json();
  if (data.hasNewArticles) {
    await self.registration.showNotification('New content available!', {
      body: `${data.newArticleCount} new articles`,
      icon: '/icon.png',
      tag: 'new-content'
    });
  }
}

Periodic Sync Constraints

┌─────────────────────────────────────────────────────────────────────────────┐
│                    PERIODIC SYNC LIMITATIONS                                 │
└─────────────────────────────────────────────────────────────────────────────┘

Browser requirements:
─────────────────────
• Installed as PWA (varies by browser)
• Site engagement score (user visits regularly)
• Permission granted
• minInterval is a REQUEST, not guarantee

Actual interval depends on:
───────────────────────────
• Site engagement score (higher = more frequent)
• Battery level
• Network type (WiFi preferred)
• Device state (idle preferred)
• Browser heuristics

Chrome minimum intervals:
─────────────────────────
• Best case: every 12 hours
• Typical: every 24 hours
• Low engagement: rarely or never

Debugging:
──────────
// Check registered syncs
const registration = await navigator.serviceWorker.ready;
const tags = await registration.periodicSync.getTags();
console.log('Registered periodic syncs:', tags);

// DevTools: Application → Service Workers → Periodic Sync

Push API

Push Subscription Setup

// Client-side: Request push permission and subscribe
async function subscribeToPush() {
  // Request permission
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    throw new Error('Notification permission denied');
  }

  const registration = await navigator.serviceWorker.ready;

  // Generate VAPID key pair on server, use public key here
  const vapidPublicKey = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U';

  // Convert base64 to Uint8Array
  const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);

  // Subscribe
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,  // Required: must show notification
    applicationServerKey
  });

  // Send subscription to server
  await fetch('/api/push/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription),
    headers: { 'Content-Type': 'application/json' }
  });

  return subscription;
}

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const rawData = atob(base64);
  return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}

Push Subscription Object

// PushSubscription structure
{
  endpoint: 'https://fcm.googleapis.com/fcm/send/...',
  expirationTime: null,
  keys: {
    p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=',
    auth: 'tBHItJI5svbpez7KI4CCXg=='
  }
}

// endpoint: Browser's push service URL
// keys.p256dh: Public key for message encryption
// keys.auth: Authentication secret

Server-Side Push

// Node.js server: Send push notification
const webpush = require('web-push');

// Configure VAPID keys
webpush.setVapidDetails(
  'mailto:admin@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

async function sendPushNotification(subscription, payload) {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify(payload),
      {
        TTL: 60 * 60,  // Message expires in 1 hour
        urgency: 'normal',  // 'very-low', 'low', 'normal', 'high'
        topic: 'new-message'  // Replaces previous with same topic
      }
    );
  } catch (error) {
    if (error.statusCode === 410) {
      // Subscription expired - remove from database
      await removeSubscription(subscription.endpoint);
    }
    throw error;
  }
}

// Send to user
const subscription = await getSubscriptionFromDB(userId);
await sendPushNotification(subscription, {
  title: 'New Message',
  body: 'You have a new message from John',
  icon: '/icon.png',
  data: {
    url: '/messages/123'
  }
});

Service Worker Push Handler

// service-worker.js
self.addEventListener('push', (event) => {
  console.log('Push received:', event);

  let data = { title: 'Notification', body: 'New update' };

  if (event.data) {
    try {
      data = event.data.json();
    } catch (e) {
      data.body = event.data.text();
    }
  }

  const options = {
    body: data.body,
    icon: data.icon || '/icon.png',
    badge: '/badge.png',
    vibrate: [100, 50, 100],
    data: data.data || {},
    actions: data.actions || [],
    requireInteraction: data.requireInteraction || false,
    tag: data.tag,  // Replaces existing notification with same tag
    renotify: data.renotify || false,  // Vibrate even if replacing
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// Handle notification click
self.addEventListener('notificationclick', (event) => {
  console.log('Notification clicked:', event.notification.tag);
  event.notification.close();

  const urlToOpen = event.notification.data?.url || '/';

  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true })
      .then((clientList) => {
        // Focus existing window if open
        for (const client of clientList) {
          if (client.url === urlToOpen && 'focus' in client) {
            return client.focus();
          }
        }
        // Open new window
        return clients.openWindow(urlToOpen);
      })
  );
});

// Handle notification close (without click)
self.addEventListener('notificationclose', (event) => {
  // Track dismissed notifications
  fetch('/api/analytics/notification-dismissed', {
    method: 'POST',
    body: JSON.stringify({ tag: event.notification.tag })
  });
});

Push Encryption (RFC 8291)

┌─────────────────────────────────────────────────────────────────────────────┐
│                    PUSH MESSAGE ENCRYPTION                                   │
└─────────────────────────────────────────────────────────────────────────────┘

Why encryption?
───────────────
Push messages travel through browser vendor servers (FCM, APNs, etc.)
End-to-end encryption ensures push service can't read message content.

Encryption flow:
────────────────
1. Client generates ECDH key pair (p256dh)
2. Client generates auth secret (16 random bytes)
3. Client sends public key + auth to server

4. Server generates ephemeral ECDH key pair
5. Server performs ECDH: shared_secret = ECDH(server_private, client_public)
6. Server derives content encryption key using HKDF
7. Server encrypts payload with AES-128-GCM
8. Server sends encrypted message + ephemeral public key

9. Push service delivers encrypted message
10. Browser receives message
11. Browser performs ECDH: shared_secret = ECDH(client_private, server_ephemeral_public)
12. Browser derives same key via HKDF
13. Browser decrypts payload
14. Service worker receives decrypted data

Message format:
───────────────
┌───────────────────────────────────────────────────┐
│ Salt (16 bytes)                                   │
├───────────────────────────────────────────────────┤
│ Record Size (4 bytes)                             │
├───────────────────────────────────────────────────┤
│ Key ID Length (1 byte)                            │
├───────────────────────────────────────────────────┤
│ Key ID (server ephemeral public key, 65 bytes)    │
├───────────────────────────────────────────────────┤
│ Ciphertext (AES-128-GCM encrypted payload)        │
└───────────────────────────────────────────────────┘

Why This Matters in Production

Offline-First Architecture

// Complete offline-first pattern

// 1. Save locally first
async function saveItem(item) {
  const db = await openDB('app-db');

  // Save to IndexedDB immediately
  item.id = item.id || crypto.randomUUID();
  item.syncStatus = 'pending';
  await db.put('items', item);

  // Queue for background sync
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const reg = await navigator.serviceWorker.ready;
    await reg.sync.register('sync-items');
  } else {
    // Fallback: try immediate sync
    await syncItems();
  }

  return item;
}

// 2. Service worker syncs when possible
self.addEventListener('sync', event => {
  if (event.tag === 'sync-items') {
    event.waitUntil(syncItems());
  }
});

async function syncItems() {
  const db = await openDB('app-db');
  const pending = await db.getAllFromIndex('items', 'syncStatus', 'pending');

  const results = await Promise.allSettled(
    pending.map(async item => {
      const response = await fetch('/api/items', {
        method: 'POST',
        body: JSON.stringify(item),
        headers: { 'Content-Type': 'application/json' }
      });

      if (response.ok) {
        item.syncStatus = 'synced';
        item.serverId = (await response.json()).id;
        await db.put('items', item);
      } else {
        throw new Error(`Sync failed: ${response.status}`);
      }
    })
  );

  const failures = results.filter(r => r.status === 'rejected');
  if (failures.length > 0) {
    throw new Error(`${failures.length} items failed to sync`);
  }
}

Push Notification Best Practices

## Push Notification Guidelines

### Do
- [ ] Ask for permission at relevant moment (not on page load)
- [ ] Explain value before requesting permission
- [ ] Allow users to manage preferences
- [ ] Use notification grouping (tag)
- [ ] Include actionable notification actions
- [ ] Handle clicks to navigate to relevant content
- [ ] Respect quiet hours (server-side logic)
- [ ] Track delivery and engagement metrics

### Don't
- [ ] Request permission immediately on first visit
- [ ] Send too frequently (user will revoke permission)
- [ ] Send irrelevant notifications
- [ ] Ignore notification dismiss events
- [ ] Assume permission is granted forever
- [ ] Send notifications that don't require urgency

### Metrics to Track
- Permission grant rate
- Notification delivery rate
- Click-through rate
- Dismiss rate
- Permission revocation rate
- Re-engagement rate

Background Sync and Push API fundamentally change what web applications can do without an open tab. One-time sync ensures data eventually reaches the server despite network failures, enabling true offline-first architectures. Periodic sync keeps content fresh for engaged users. Push notifications re-engage users with timely, relevant information. The key constraints—service worker requirement, HTTPS, browser heuristics controlling timing, userVisibleOnly for push—exist to prevent abuse while enabling powerful capabilities. Production implementations must handle these constraints gracefully, with fallbacks for unsupported browsers and respect for user preferences.

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.