Chapter 85: Progressive Web Apps (PWAs)

Background Sync API

Guaranteeing message and mutation delivery across unstable networks using `SyncManager`, IndexedDB outbox queues, and background event replay.

LEARNING OBJECTIVES
  • Understand the role of the Background Sync API in deferring actions until the user has stable internet connectivity.
  • Register sync tags from the main UI thread using registration.sync.register('outbox-sync').
  • Implement the IndexedDB Outbox Pattern to store mutating HTTP payloads (messages, comments, form submissions) while offline.
  • Handle the sync event inside the Service Worker thread to drain the outbox queue and notify the user upon successful replay.
🎬 INTERACTIVE VISUAL PIPELINE Core Architecture Simulation
🌐
1. Input
Directives & Tags
⚙️
2. Parse
Tokenizer & AST
🌳
3. Layout
Box Model & Flow
🎨
4. Render
GPU Paint & Composite
PHASE 1: INPUT & DIRECTIVES
Browser receives declarative markup stream, parsing tag tokens and initializing component state.

📖 The Mental Model & Story (Intuitive Foundation)

Imagine writing a physical postcard while sitting on an airplane with no mailboxes in sight. You don't throw the postcard out the window because the postal worker isn't immediately available. Instead, you slip the postcard into your briefcase's "To Mail" pocket (IndexedDB Outbox Queue).

The instant your flight lands and you walk past a postal drop box at the airport terminal (Network Reconnection), you drop all accumulated letters into the box (Background Sync Replay). Even if you were in a rush and immediately closed your notebook or turned off your phone, the postal service delivers the letters on your behalf.

In traditional web applications, if a user clicks "Submit Form" or "Send Message" while walking through a subway tunnel or an elevator, the request instantly fails and the user loses their typed text. With the Background Sync API, the browser registers a persistent sync request with the operating system. Even if the user navigates away or closes the browser tab entirely, the browser wakes up your Service Worker in the background the moment internet connectivity returns, flushing the queue cleanly to your servers.


Technical Deep Dive & Specifications

The Background Sync Architecture & Workflow

[ USER UI THREAD ]                       [ INDEXED DB ]               [ SERVICE WORKER ]             [ ORIGIN SERVER ]
        |                                       |                              |                             |
 1. User submits comment                        |                              |                             |
    (Network is Offline)                        |                              |                             |
        |                                       |                              |                             |
 2. Write payload to 'outbox' ----------------->|                              |                             |
        |                                       |                              |                             |
 3. registration.sync.register('sync-comments') |                              |                             |
    ==========================================================================>|                             |
        |                                       |                              |                             |
 [ User Closes Tab / Browser ]                  |                              |                             |
        .                                       .                              .                             .
        . <=== Device Re-establishes Stable Wi-Fi / 5G Connection ===========> .                             .
        .                                       .                              .                             .
                                                |                       4. Browser wakes SW                  |
                                                |                          fires 'sync' event                |
                                                |                              |                             |
                                                | 5. Read queued payloads <----|                             |
                                                |----------------------------->|                             |
                                                |                              | 6. POST /api/comments =====>|
                                                |                              |<==== 201 Created ===========|
                                                | 7. Delete sent records <-----|                             |
                                                |                              | 8. (Optional) Show Push     |
                                                |                              |    Notification to User     |

SyncManager API Specification

The Background Sync API is accessed via the sync property of a ServiceWorkerRegistration:

// Feature Detection
if ('serviceWorker' in navigator && 'SyncManager' in window) {
  const registration = await navigator.serviceWorker.ready;
  // Register a named sync event
  await registration.sync.register('send-outbox');
}
Method / Property Context Return Type Description
registration.sync.register(tag) Window / SW Promise<void> Registers a synchronization task identified by tag.
registration.sync.getTags() Window / SW Promise<string[]> Returns an array of all currently pending sync registration tags.
self.addEventListener('sync', (e) => ...) SW Global Scope void Fires when the browser determines network conditions are stable.
event.tag Inside SW sync string The identifier string matching the registered task.
event.lastChance Inside SW sync boolean true if this is the final retry attempt before the browser gives up.

💻 Interactive Code Playground

Starter Code: Offline Outbox Queue with IndexedDB & Background Sync

1. File: db.js (Minimal IndexedDB Outbox Wrapper)

2. File: app.js (Client UI & Sync Registration)

3. File: sw.js (Service Worker Sync Listener)

Line-by-Line Code Breakdown

  • app.js Line 13 (await saveToOutbox(payload)): Mutating user actions are persisted in local IndexedDB before making any network assumptions.
  • app.js Line 18 (await reg.sync.register('replay-outbox-messages')): Hands the synchronization contract to the browser engine with a specific tag name.
  • sw.js Line 3 (importScripts('/db.js')): Loads the shared IndexedDB transaction helper inside the background worker thread.
  • sw.js Line 5 (self.addEventListener('sync', ...)): Fired automatically by the browser when network connectivity is confirmed to be stable.
  • sw.js Line 32 (throw networkErr): Rejecting the event.waitUntil() promise informs the browser that the sync failed, causing the browser to back off and retry automatically when network conditions improve.

Expected Browser Render Output


const DB_NAME = 'PwaOutboxDB';
const DB_VERSION = 1;
const STORE_NAME = 'messages';

function openOutboxDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);
    request.onupgradeneeded = (e) => {
      const db = e.target.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
      }
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function saveToOutbox(data) {
  const db = await openOutboxDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readwrite');
    const store = tx.objectStore(STORE_NAME);
    const req = store.add({ ...data, timestamp: Date.now() });
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function getAllOutboxItems() {
  const db = await openOutboxDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readonly');
    const store = tx.objectStore(STORE_NAME);
    const req = store.getAll();
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function deleteOutboxItem(id) {
  const db = await openOutboxDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readwrite');
    const store = tx.objectStore(STORE_NAME);
    const req = store.delete(id);
    req.onsuccess = () => resolve();
    req.onerror = () => reject(req.error);
  });
}
const form = document.getElementById('chat-form');
const input = document.getElementById('chat-input');
const statusDiv = document.getElementById('status-msg');

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const text = input.value.trim();
  if (!text) return;

  const payload = { message: text, sender: 'User_101' };

  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    try {
      // 1. Save locally to IndexedDB outbox
      await saveToOutbox(payload);
      input.value = '';
      statusDiv.textContent = 'Message queued in local outbox. Syncing in background...';

      // 2. Request Background Sync
      const reg = await navigator.serviceWorker.ready;
      await reg.sync.register('replay-outbox-messages');
      console.log('[UI] Background sync registered: replay-outbox-messages');
    } catch (err) {
      console.error('[UI] Background sync registration failed:', err);
    }
  } else {
    // Fallback: Direct immediate fetch for browsers lacking SyncManager
    statusDiv.textContent = 'Direct send (Background Sync unsupported)...';
    fetch('/api/messages', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    });
  }
});
importScripts('/db.js');

self.addEventListener('sync', (event) => {
  if (event.tag === 'replay-outbox-messages') {
    console.log('[SW Sync] "replay-outbox-messages" tag triggered. Draining outbox...');
    event.waitUntil(flushOutboxQueue());
  }
});

async function flushOutboxQueue() {
  const queuedItems = await getAllOutboxItems();
  console.log(`[SW Sync] Found ${queuedItems.length} messages in outbox.`);

  for (const item of queuedItems) {
    try {
      const response = await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: item.message, sender: item.sender })
      });

      if (response.ok) {
        console.log(`[SW Sync] Successfully synced message ID ${item.id}`);
        // Remove from IndexedDB outbox once confirmed
        await deleteOutboxItem(item.id);
      } else {
        console.warn(`[SW Sync] Server returned status ${response.status}. Retrying later...`);
        throw new Error('Server rejected sync payload.');
      }
    } catch (networkErr) {
      console.error('[SW Sync] Failed to send message. Browser will retry sync.', networkErr);
      // Throwing an error causes event.waitUntil to reject, prompting the browser to reschedule
      throw networkErr;
    }
  }
}
(User types "Hello Team!" and clicks Send while in Airplane Mode):
[UI] Message queued in local outbox. Syncing in background...
[UI] Background sync registered: replay-outbox-messages

(User closes browser tab and restores Wi-Fi):
[SW Sync] "replay-outbox-messages" tag triggered. Draining outbox...
[SW Sync] Found 1 messages in outbox.
[SW Sync] Successfully synced message ID 1

🏋️ Hands-On Exercise

🎯 The Challenge: Build an Offline Survey Response Sync Engine

Instructions:

  1. In a web client script, create a function submitFeedback(rating, comment).
  2. Check for SyncManager support. If supported, store the object { rating, comment, submittedAt: Date.now() } into an IndexedDB store named 'feedback-outbox'.
  3. Register a sync task with the tag 'sync-feedback'.
  4. In sw.js, intercept the sync event for 'sync-feedback', fetch all feedback entries from IndexedDB, POST them to /api/feedback, and delete them upon a successful 200 HTTP response.

🏁 Starter Code Sandbox

⚠️ Common Pitfalls

  1. Storing Outbox Data in localStorage: localStorage is completely synchronous and is NOT available inside Service Workers. Always use IndexedDB for outbox storage since it is accessible in both the window and ServiceWorkerGlobalScope.
  2. Catching Errors Silently inside event.waitUntil(): If you catch network errors inside your sync handler and do not rethrow them, event.waitUntil() resolves successfully, and the browser will assume the sync succeeded, failing to reschedule the retry.
  3. Non-Idempotent Server Endpoints: Network timeouts can cause the client to retry requests that the server actually received. Always attach a unique UUID or clientMutationId to each outbox item so the server can deduplicate retried messages.

💡 Pro Tips

  1. Inspect event.lastChance for Graceful Degradation: Inside your sync event listener, check if (event.lastChance). If true, the browser will not attempt any more automatic sync retries. You can alert the user via a local push notification that their message could not be sent.
  2. Deduplicate Sync Registrations: Calling registration.sync.register('send-outbox') multiple times with the same tag coalesces into a single pending event. You don't need to manually debounce sync registrations in the client.

📌 Key Takeaways

  • The Background Sync API allows web applications to defer server mutations until the client has an active, stable internet connection.
  • Mutation data must be persisted in IndexedDB (not localStorage) because Service Workers operate in a background thread.
  • Sync tasks are registered via navigator.serviceWorker.ready.then(reg => reg.sync.register('tag-name')).
  • In the Service Worker, the sync event handler must wrap its asynchronous replay loop in event.waitUntil().
  • Rejection of the event.waitUntil() promise triggers the browser's built-in exponential backoff retry mechanism.
  • --
⭐ LEARN: HTML 🌟 ⚔️ QUIZ BATTLE ARENA // ACTIVE
3x
STREAK!
BONUS ACTIVE
COMBO
? Question 1 / 3

Why is IndexedDB used instead of localStorage for implementing the offline outbox queue with Background Sync?

Question 1 / 3 Topic: HTML Fundamentals
00:45 REMAINING
XP REWARD
+250 XP
3x
STREAK!
BONUS ACTIVE
COMBO
? Question 2 / 3

What happens if the promise passed to event.waitUntil() inside a sync event handler rejects?

Question 2 / 3 Topic: HTML Fundamentals
00:45 REMAINING
XP REWARD
+250 XP
3x
STREAK!
BONUS ACTIVE
COMBO
? Question 3 / 3

What does the event.lastChance boolean property indicate inside a Service Worker sync event listener?

Question 3 / 3 Topic: HTML Fundamentals
00:45 REMAINING
XP REWARD
+250 XP