← Back to DevBytes

Implementing Web Background Sync in Modern Web Applications

Introduction to Background Sync

Modern web applications face a fundamental challenge: what happens when a user performs a critical action — like submitting a form, placing an order, or sending a message — but their network connection drops mid-operation? Without a robust offline strategy, that data is often lost, leading to frustrated users and abandoned transactions. The Background Sync API, part of the Service Worker specification, solves this problem elegantly by deferring network operations until connectivity is restored, even if the user has closed the browser tab.

What is Background Sync?

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Background Sync is a web API that allows you to register a named synchronization task with the browser's service worker. When the user's device regains internet connectivity, the browser fires a sync event inside the service worker, giving you the opportunity to complete pending operations — such as sending queued API requests, uploading cached form data, or reconciling local database changes with a remote server.

The key distinction is that background sync works even after the page has been closed. Unlike fetch calls made directly from a page, which fail immediately when there's no network, sync events persist across page sessions. The browser keeps track of registered sync tags and triggers them at the optimal moment: when connectivity is available and the device is not under resource constraints.

There are two variants of the API:

Why Background Sync Matters

Background Sync directly addresses several real-world pain points in web application development:

How to Implement Background Sync

Prerequisites

Before writing any code, ensure your environment meets these requirements:

Step 1: Register a Service Worker

Create a file named sw.js in your application root and register it from your main JavaScript file. The registration must succeed before you can request syncs.


// main.js — Register the service worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => {
      console.log('Service Worker registered with scope:', registration.scope);
    })
    .catch(error => {
      console.error('Service Worker registration failed:', error);
    });
}

Step 2: Add the Sync Event Listener in the Service Worker

Inside sw.js, listen for the sync event. The event object contains a tag property — the string name you chose when registering the sync. Use this tag to branch logic for different sync operations.


// sw.js — Listen for background sync events
self.addEventListener('sync', event => {
  console.log('Sync event fired with tag:', event.tag);

  if (event.tag === 'submit-order') {
    // Hold onto the promise chain so the browser knows when sync is complete
    event.waitUntil(submitPendingOrders());
  }

  if (event.tag === 'sync-messages') {
    event.waitUntil(sendQueuedMessages());
  }
});

async function submitPendingOrders() {
  // Retrieve stored orders from IndexedDB or Cache
  const pendingOrders = await getPendingOrdersFromStorage();
  
  for (const order of pendingOrders) {
    try {
      const response = await fetch('/api/orders', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(order)
      });
      
      if (response.ok) {
        await removePendingOrderFromStorage(order.id);
        console.log('Order submitted successfully:', order.id);
      } else {
        // Server rejected the payload — store for manual review
        await markOrderAsFailed(order.id, response.status);
      }
    } catch (networkError) {
      // Network still unstable — keep in queue, browser will retry later
      console.warn('Sync attempt failed, will retry:', networkError);
      throw networkError; // Throwing causes the browser to retry the sync
    }
  }
}

async function sendQueuedMessages() {
  const messages = await getQueuedMessagesFromStorage();
  const sendPromises = messages.map(msg =>
    fetch('/api/messages', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(msg)
    }).then(response => {
      if (response.ok) return removeQueuedMessage(msg.id);
      throw new Error(`Server responded with ${response.status}`);
    })
  );
  
  await Promise.all(sendPromises);
}

// Helper stubs — replace with actual IndexedDB or Cache API logic
async function getPendingOrdersFromStorage() {
  // Retrieve from IndexedDB
  return [];
}
async function removePendingOrderFromStorage(id) {
  // Delete from IndexedDB
}
async function markOrderAsFailed(id, status) {
  // Update record in IndexedDB
}
async function getQueuedMessagesFromStorage() {
  return [];
}
async function removeQueuedMessage(id) {
  // Delete from IndexedDB
}

The critical pattern here is event.waitUntil(promise). This tells the browser to keep the service worker alive until the promise resolves or rejects. If the promise rejects (e.g., the network request fails again), the browser may schedule a retry. If it resolves, the sync is considered complete.

Step 3: Request a Sync from the Client Page

From your application code, obtain the service worker registration and call sync.register() with a unique tag name. This queues the sync operation.


// checkout.js — Request a background sync after user places an order
async function placeOrder(orderData) {
  // First, save the order locally so the service worker can access it later
  await saveOrderToIndexedDB(orderData);

  // Check if Background Sync is available
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    
    try {
      await registration.sync.register('submit-order');
      console.log('Background sync registered for submit-order');
    } catch (error) {
      console.error('Sync registration failed:', error);
      // Fallback: attempt immediate network submission
      await attemptDirectSubmission(orderData);
    }
  } else {
    // Fallback for browsers without SyncManager support
    await attemptDirectSubmission(orderData);
  }
}

async function saveOrderToIndexedDB(orderData) {
  // Implementation using IndexedDB — store order with a unique ID
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('OrderQueue', 1);
    request.onupgradeneeded = event => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('orders')) {
        db.createObjectStore('orders', { keyPath: 'id' });
      }
    };
    request.onsuccess = event => {
      const db = event.target.result;
      const transaction = db.transaction('orders', 'readwrite');
      const store = transaction.objectStore('orders');
      store.put({ id: Date.now().toString(), ...orderData, status: 'pending' });
      transaction.oncomplete = () => resolve();
      transaction.onerror = () => reject(transaction.error);
    };
    request.onerror = () => reject(request.error);
  });
}

async function attemptDirectSubmission(orderData) {
  try {
    const response = await fetch('/api/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(orderData)
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  } catch (error) {
    console.error('Direct submission failed:', error);
    // Could store for manual retry
  }
}

Step 4: Testing Your Implementation

Testing background sync requires simulating offline conditions. Use Chrome DevTools:

Complete Example: Offline Form Submission

Here is a consolidated, production-ready example that ties all the pieces together. It handles an offline contact form submission using Background Sync and IndexedDB for local persistence.


// ==========================================
// FILE: public/index.html
// ==========================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Background Sync Demo</title>
</head>
<body>
  <h1>Contact Us</h1>
  <form id="contact-form">
    <input type="text" name="name" placeholder="Your name" required>
    <input type="email" name="email" placeholder="Your email" required>
    <textarea name="message" placeholder="Your message" required></textarea>
    <button type="submit">Send Message</button>
  </form>
  <p id="status"></p>

  <script src="main.js"></script>
</body>
</html>

// ==========================================
// FILE: public/main.js
// ==========================================
// Register service worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(reg => console.log('SW registered:', reg.scope))
    .catch(err => console.error('SW registration failed:', err));
}

// Open or create IndexedDB database
function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('ContactFormQueue', 1);
    request.onupgradeneeded = event => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('messages')) {
        db.createObjectStore('messages', { 
          keyPath: 'id', 
          autoIncrement: true 
        });
      }
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function storeMessage(data) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction('messages', 'readwrite');
    const store = tx.objectStore('messages');
    store.add({ ...data, timestamp: Date.now() });
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

document.getElementById('contact-form').addEventListener('submit', async event => {
  event.preventDefault();
  
  const formData = {
    name: event.target.name.value,
    email: event.target.email.value,
    message: event.target.message.value
  };

  // Store locally
  await storeMessage(formData);
  
  // Attempt background sync registration
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    try {
      await registration.sync.register('send-contact-message');
      document.getElementById('status').textContent = 
        'Message saved. It will be sent when you are back online.';
      event.target.reset();
    } catch (err) {
      document.getElementById('status').textContent = 
        'Sync not available. Message saved locally.';
      console.error(err);
    }
  } else {
    document.getElementById('status').textContent = 
      'Background Sync not supported. Message saved locally.';
  }
});

// ==========================================
// FILE: public/sw.js
// ==========================================
const MESSAGE_API_URL = '/api/contact';

self.addEventListener('sync', event => {
  if (event.tag === 'send-contact-message') {
    event.waitUntil(processPendingMessages());
  }
});

async function processPendingMessages() {
  const db = await openSWDB();
  const pendingMessages = await getAllPendingMessages(db);
  
  for (const msg of pendingMessages) {
    try {
      const response = await fetch(MESSAGE_API_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: msg.name,
          email: msg.email,
          message: msg.message
        })
      });
      
      if (response.ok) {
        await deleteMessage(db, msg.id);
        console.log('Message sent successfully:', msg.id);
      } else {
        console.warn('Server returned error for message:', msg.id, response.status);
        // Keep in queue for manual review or retry
      }
    } catch (fetchError) {
      console.error('Failed to send message, will retry:', fetchError);
      throw fetchError; // Re-throw to signal retry
    }
  }
}

function openSWDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('ContactFormQueue', 1);
    request.onupgradeneeded = event => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('messages')) {
        db.createObjectStore('messages', { keyPath: 'id', autoIncrement: true });
      }
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

function getAllPendingMessages(db) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('messages', 'readonly');
    const store = tx.objectStore('messages');
    const request = store.getAll();
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

function deleteMessage(db, id) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('messages', 'readwrite');
    const store = tx.objectStore('messages');
    store.delete(id);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

Periodic Background Sync

While one-off sync is ideal for deferred writes, Periodic Background Sync enables regular, scheduled operations like fetching the latest news articles, updating weather data, or refreshing cached assets. This API is more restricted: it requires the site to be installed as a PWA (added to the home screen on mobile or installed via the browser menu on desktop) and the browser applies an engagement scoring heuristic to grant permission.

Registering a Periodic Sync

From the client page, use the periodicSync property on the service worker registration. You must also specify a minInterval in milliseconds, which is the minimum time between syncs. The browser may coalesce or delay syncs to align with system-wide background task scheduling.


// main.js — Register periodic background sync (requires PWA install)
async function registerPeriodicSync() {
  if ('serviceWorker' in navigator && 'PeriodicSyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    
    try {
      const status = await navigator.permissions.query({
        name: 'periodic-background-sync'
      });
      
      if (status.state === 'granted') {
        await registration.periodicSync.register('fetch-news', {
          minInterval: 60 * 60 * 1000 // 1 hour minimum
        });
        console.log('Periodic sync registered for fetch-news');
      } else {
        console.log('Periodic sync permission not granted');
      }
    } catch (error) {
      console.error('Periodic sync registration failed:', error);
    }
  }
}

// Call this after user installs the PWA
window.addEventListener('appinstalled', () => {
  registerPeriodicSync();
});

Handling Periodic Sync in the Service Worker

The service worker listens for periodicsync events similarly to regular sync events.


// sw.js — Handle periodic sync
self.addEventListener('periodicsync', event => {
  if (event.tag === 'fetch-news') {
    event.waitUntil(fetchLatestNewsAndNotify());
  }
});

async function fetchLatestNewsAndNotify() {
  try {
    const response = await fetch('/api/latest-news');
    const articles = await response.json();
    
    // Cache the articles for offline reading
    const cache = await caches.open('news-cache-v1');
    await cache.put('/api/latest-news', new Response(JSON.stringify(articles)));
    
    // Show a notification to the user
    if (Notification.permission === 'granted') {
      await self.registration.showNotification('Fresh News Available', {
        body: `${articles.length} new articles ready to read`,
        icon: '/icons/news-icon.png',
        tag: 'news-update',
        data: { url: '/news' }
      });
    }
    
    console.log('Periodic sync completed: fetched', articles.length, 'articles');
  } catch (error) {
    console.error('Periodic sync failed:', error);
    throw error;
  }
}

Best Practices

Browser Support and Feature Detection

As of 2025, Background Sync is supported in Chromium-based browsers (Google Chrome, Microsoft Edge, Samsung Internet, Opera) on both desktop and Android. Firefox and Safari (including iOS Safari) have not implemented the API. Periodic Background Sync has an even narrower support footprint and requires PWA installation on supported platforms.

Use the following pattern to safely integrate sync capabilities while maintaining compatibility:


// Feature detection helper
const syncSupport = {
  backgroundSync: 'serviceWorker' in navigator && 'SyncManager' in window,
  periodicSync: 'serviceWorker' in navigator && 'PeriodicSyncManager' in window
};

async function safeSyncRegistration(tag) {
  if (!syncSupport.backgroundSync) {
    console.log('Background Sync not available — using direct network call');
    return 'direct';
  }
  
  const registration = await navigator.serviceWorker.ready;
  try {
    await registration.sync.register(tag);
    return 'sync-registered';
  } catch (error) {
    console.warn('Sync registration rejected:', error);
    return 'rejected';
  }
}

// Usage in your application logic
async function handleUserAction(data) {
  await persistToStorage(data);
  
  const result = await safeSyncRegistration('process-action');
  
  if (result === 'direct') {
    // Immediately attempt the network call
    await attemptNetworkCall(data);
  } else if (result === 'sync-registered') {
    // Sync will handle it later — inform the user
    showToast('Action saved. It will complete when you are back online.');
  } else {
    // Sync was rejected (e.g., due to frequency limits)
    // Provide a manual retry option
    showToast('Could not schedule background sync. Please try again later.');
  }
}

Debugging and Troubleshooting

When background sync does not fire as expected, check the following:

Conclusion

Background Sync transforms the web from a strictly online medium into a resilient, offline-capable platform. By deferring network writes to moments of connectivity — and doing so even after the user closes the browser — you eliminate one of the most common sources of user frustration: lost work due to flaky connections. The implementation pattern is straightforward: persist data locally, register a named sync, handle the sync event in your service worker with waitUntil(), and iterate through queued items until they are successfully sent. Combined with Periodic Background Sync for content freshness and IndexedDB for robust client-side storage, you can build web applications that rival native apps in reliability and user experience. As browser support continues to expand, integrating Background Sync into your offline strategy is an investment that pays dividends in user trust and engagement.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles