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
syncevent inside the Service Worker thread to drain the outbox queue and notify the user upon successful replay.
📖 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.jsLine 13 (await saveToOutbox(payload)): Mutating user actions are persisted in local IndexedDB before making any network assumptions.app.jsLine 18 (await reg.sync.register('replay-outbox-messages')): Hands the synchronization contract to the browser engine with a specific tag name.sw.jsLine 3 (importScripts('/db.js')): Loads the shared IndexedDB transaction helper inside the background worker thread.sw.jsLine 5 (self.addEventListener('sync', ...)): Fired automatically by the browser when network connectivity is confirmed to be stable.sw.jsLine 32 (throw networkErr): Rejecting theevent.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:
- In a web client script, create a function
submitFeedback(rating, comment). - Check for
SyncManagersupport. If supported, store the object{ rating, comment, submittedAt: Date.now() }into an IndexedDB store named'feedback-outbox'. - Register a sync task with the tag
'sync-feedback'. - In
sw.js, intercept thesyncevent for'sync-feedback', fetch all feedback entries from IndexedDB,POSTthem to/api/feedback, and delete them upon a successful 200 HTTP response.
🏁 Starter Code Sandbox
⚠️ Common Pitfalls
- Storing Outbox Data in
localStorage:localStorageis completely synchronous and is NOT available inside Service Workers. Always use IndexedDB for outbox storage since it is accessible in both the window andServiceWorkerGlobalScope. - 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. - Non-Idempotent Server Endpoints: Network timeouts can cause the client to retry requests that the server actually received. Always attach a unique
UUIDorclientMutationIdto each outbox item so the server can deduplicate retried messages.
💡 Pro Tips
- Inspect
event.lastChancefor Graceful Degradation: Inside yoursyncevent listener, checkif (event.lastChance). Iftrue, 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. - 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
syncevent handler must wrap its asynchronous replay loop inevent.waitUntil(). - Rejection of the
event.waitUntil()promise triggers the browser's built-in exponential backoff retry mechanism. - --