PatternsMobile & PWA
Background Sync
Sync data in the background when the user is offline
Background Sync allows your app to defer actions until the user has stable connectivity. Perfect for sending messages, uploading photos, or submitting forms while offline.
The Problem
// ❌ Without Background Sync
async function sendMessage(message) {
try {
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message),
});
} catch (error) {
// User is offline - message lost! 😢
console.error('Failed to send message');
}
}The Solution
// ✅ With Background Sync
async function sendMessage(message) {
// Save to IndexedDB
await saveToQueue(message);
// Register sync
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('send-messages');
}
}
// Service Worker handles sync when online
self.addEventListener('sync', (event) => {
if (event.tag === 'send-messages') {
event.waitUntil(sendQueuedMessages());
}
});Basic Implementation
Register Sync
// utils/background-sync.ts
export async function registerBackgroundSync(tag: string) {
if (!('serviceWorker' in navigator) || !('sync' in ServiceWorkerRegistration.prototype)) {
console.warn('Background Sync not supported');
return false;
}
try {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
console.log('Background Sync registered:', tag);
return true;
} catch (error) {
console.error('Background Sync registration failed:', error);
return false;
}
}Service Worker Handler
// sw.js
self.addEventListener('sync', (event) => {
console.log('Sync event:', event.tag);
if (event.tag === 'send-messages') {
event.waitUntil(syncMessages());
} else if (event.tag === 'upload-images') {
event.waitUntil(syncImages());
}
});
async function syncMessages() {
try {
// Get messages from IndexedDB
const messages = await getQueuedMessages();
// Send each message
for (const message of messages) {
const response = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message),
});
if (response.ok) {
await removeFromQueue(message.id);
}
}
} catch (error) {
// Sync will retry automatically
console.error('Sync failed:', error);
throw error;
}
}IndexedDB Queue
// utils/db.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface SyncDB extends DBSchema {
messages: {
key: string;
value: {
id: string;
text: string;
timestamp: number;
userId: string;
};
};
images: {
key: string;
value: {
id: string;
blob: Blob;
timestamp: number;
};
};
}
let db: IDBPDatabase<SyncDB> | null = null;
async function getDB() {
if (!db) {
db = await openDB<SyncDB>('sync-queue', 1, {
upgrade(db) {
if (!db.objectStoreNames.contains('messages')) {
db.createObjectStore('messages', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('images')) {
db.createObjectStore('images', { keyPath: 'id' });
}
},
});
}
return db;
}
export async function addToQueue(storeName: 'messages' | 'images', item: any) {
const db = await getDB();
await db.add(storeName, item);
}
export async function getQueue(storeName: 'messages' | 'images') {
const db = await getDB();
return db.getAll(storeName);
}
export async function removeFromQueue(storeName: 'messages' | 'images', id: string) {
const db = await getDB();
await db.delete(storeName, id);
}
export async function clearQueue(storeName: 'messages' | 'images') {
const db = await getDB();
await db.clear(storeName);
}Real-World Examples
Chat Application
// components/ChatInput.tsx
import { useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { addToQueue } from '@/utils/db';
import { registerBackgroundSync } from '@/utils/background-sync';
export function ChatInput({ userId }: Props) {
const [message, setMessage] = useState('');
const [pendingCount, setPendingCount] = useState(0);
const sendMessage = async () => {
if (!message.trim()) return;
const newMessage = {
id: uuidv4(),
text: message,
timestamp: Date.now(),
userId,
};
// Optimistic UI update
addMessageToUI(newMessage);
setMessage('');
try {
// Try to send immediately
const response = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newMessage),
});
if (!response.ok) throw new Error('Network error');
} catch (error) {
// Offline - queue for background sync
await addToQueue('messages', newMessage);
await registerBackgroundSync('send-messages');
setPendingCount(prev => prev + 1);
showNotification('Message will be sent when online');
}
};
return (
<div>
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
/>
<button onClick={sendMessage}>Send</button>
{pendingCount > 0 && (
<span className="pending-badge">{pendingCount} pending</span>
)}
</div>
);
}Image Upload
// components/ImageUpload.tsx
import { useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
export function ImageUpload() {
const [uploading, setUploading] = useState(false);
const handleUpload = async (file: File) => {
setUploading(true);
const imageData = {
id: uuidv4(),
blob: file,
timestamp: Date.now(),
};
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload failed');
showNotification('Image uploaded!');
} catch (error) {
// Queue for background sync
await addToQueue('images', imageData);
await registerBackgroundSync('upload-images');
showNotification('Image will upload when online');
} finally {
setUploading(false);
}
};
return (
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
disabled={uploading}
/>
);
}Form Submission
// components/ContactForm.tsx
export function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const submission = {
id: uuidv4(),
...formData,
timestamp: Date.now(),
};
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(submission),
});
if (!response.ok) throw new Error('Submission failed');
setFormData({ name: '', email: '', message: '' });
showNotification('Form submitted!');
} catch (error) {
// Save for background sync
await addToQueue('forms', submission);
await registerBackgroundSync('submit-forms');
setFormData({ name: '', email: '', message: '' });
showNotification('Form saved - will submit when online');
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Name"
required
/>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="Email"
required
/>
<textarea
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
placeholder="Message"
required
/>
<button type="submit">Send</button>
</form>
);
}Advanced Patterns
Retry Logic
// sw.js
const MAX_RETRIES = 3;
const RETRY_DELAY = 5000; // 5 seconds
async function syncWithRetry(syncFn, retries = 0) {
try {
await syncFn();
} catch (error) {
if (retries < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (retries + 1)));
return syncWithRetry(syncFn, retries + 1);
}
throw error;
}
}
self.addEventListener('sync', (event) => {
if (event.tag === 'send-messages') {
event.waitUntil(syncWithRetry(syncMessages));
}
});Batch Sync
// Sync multiple items in batches
async function syncMessages() {
const messages = await getQueue('messages');
const BATCH_SIZE = 10;
for (let i = 0; i < messages.length; i += BATCH_SIZE) {
const batch = messages.slice(i, i + BATCH_SIZE);
try {
// Send batch
const response = await fetch('/api/messages/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batch),
});
if (response.ok) {
// Remove successful items
await Promise.all(
batch.map(msg => removeFromQueue('messages', msg.id))
);
}
} catch (error) {
console.error('Batch sync failed:', error);
break; // Stop on error, will retry next time
}
}
}Progress Tracking
// Track sync progress
class SyncProgress {
private total = 0;
private completed = 0;
async syncWithProgress(items: any[], syncFn: (item: any) => Promise<void>) {
this.total = items.length;
this.completed = 0;
for (const item of items) {
await syncFn(item);
this.completed++;
this.notifyProgress();
}
}
notifyProgress() {
// Notify all clients of progress
self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
client.postMessage({
type: 'SYNC_PROGRESS',
progress: (this.completed / this.total) * 100,
});
});
});
}
}
// Use in sync event
self.addEventListener('sync', (event) => {
if (event.tag === 'send-messages') {
const progress = new SyncProgress();
event.waitUntil(
getQueue('messages').then((messages) =>
progress.syncWithProgress(messages, async (message) => {
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message),
});
await removeFromQueue('messages', message.id);
})
)
);
}
});Periodic Background Sync
For regular updates (e.g., fetch new content every hour):
// Register periodic sync (requires user permission)
const status = await navigator.permissions.query({
name: 'periodic-background-sync',
});
if (status.state === 'granted') {
const registration = await navigator.serviceWorker.ready;
await registration.periodicSync.register('fetch-news', {
minInterval: 60 * 60 * 1000, // 1 hour
});
}
// Service Worker
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'fetch-news') {
event.waitUntil(fetchLatestNews());
}
});
async function fetchLatestNews() {
const response = await fetch('/api/news/latest');
const news = await response.json();
// Update cache
const cache = await caches.open('news-cache');
await cache.put('/api/news/latest', new Response(JSON.stringify(news)));
// Notify user if important news
if (news.some(item => item.priority === 'high')) {
self.registration.showNotification('Breaking News!', {
body: news[0].title,
icon: '/icon.png',
});
}
}Testing
describe('Background Sync', () => {
it('should queue message when offline', async () => {
// Simulate offline
await page.setOfflineMode(true);
await page.type('#message-input', 'Test message');
await page.click('#send-button');
// Check message is queued
const queueCount = await page.evaluate(() => {
return indexedDB.databases().then(dbs =>
dbs.find(db => db.name === 'sync-queue')
);
});
expect(queueCount).toBeTruthy();
});
it('should sync when back online', async () => {
// Go online
await page.setOfflineMode(false);
// Trigger sync
await page.evaluate(() => {
return navigator.serviceWorker.ready.then(reg =>
reg.sync.register('send-messages')
);
});
// Wait for sync
await page.waitForResponse('/api/messages');
// Queue should be empty
const queue = await getQueue('messages');
expect(queue).toHaveLength(0);
});
});Browser Support
// Check if Background Sync is supported
function isBackgroundSyncSupported() {
return (
'serviceWorker' in navigator &&
'sync' in ServiceWorkerRegistration.prototype
);
}
// Fallback for unsupported browsers
async function sendMessageWithFallback(message: any) {
if (isBackgroundSyncSupported()) {
await addToQueue('messages', message);
await registerBackgroundSync('send-messages');
} else {
// Fallback: Use setTimeout or manual retry
await retryWithExponentialBackoff(() =>
fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message),
})
);
}
}Best Practices
- Always queue first: Save to IndexedDB before sync
- Optimistic UI: Show immediate feedback
- Clear feedback: Tell users what's happening
- Retry logic: Handle temporary failures
- Batch requests: Group multiple items
- Progress updates: Show sync progress
- Error handling: Handle permanent failures
- Storage limits: Clean old queue items
- Test offline: Verify sync works
- Graceful fallback: Support old browsers
Common Pitfalls
❌ No queue: Data lost if sync fails
✅ Always save to IndexedDB first
❌ No feedback: User doesn't know status
✅ Show pending/syncing indicators
❌ Infinite retries: Fills queue
✅ Limit retries, handle failures
❌ No batching: Too many requests
✅ Batch multiple items
❌ Assuming support: Old browsers fail
✅ Feature detection + fallback
Background Sync creates a seamless offline experience—queue actions and sync automatically when online!