Angular Background Sync
In the world of Progressive Web Applications (PWAs), delivering a seamless experience regardless of network conditions is crucial. Background synchronization is a powerful feature that allows web applications to defer actions until the user has stable connectivity, ensuring data integrity even when the network is unreliable.
What is Background Sync?
Background Sync is a web API that lets service workers run operations when the user's device has a stable internet connection. This is particularly useful for scenarios where:
- A user makes changes while offline
- Network requests fail due to poor connectivity
- You want to ensure data is synchronized even after the user has closed your application
For Angular PWAs, implementing Background Sync provides a more robust user experience by handling connectivity issues gracefully.
Prerequisites
Before diving into Background Sync implementation, ensure you have:
- An Angular project set up
- Angular Service Worker (NGSW) installed and configured
- Basic understanding of PWAs and Service Workers
If you haven't set up Angular PWA yet, you can run:
ng add @angular/pwa
Browser Support Considerations
Background Sync is not supported in all browsers. As of 2023, it's primarily available in Chromium-based browsers (Chrome, Edge, Opera). Always implement fallbacks for non-supporting browsers.
function isBackgroundSyncSupported() {
return 'serviceWorker' in navigator && 'SyncManager' in window;
}
Implementing Background Sync in Angular
Let's implement Background Sync in an Angular application step by step.
Step 1: Create a Service for Background Sync
First, create a service dedicated to handling background sync operations:
ng generate service services/background-sync
Step 2: Register Sync Event in the Service
Edit the background sync service to register sync events:
import { Injectable } from '@angular/core';
import { SwUpdate } from '@angular/service-worker';
@Injectable({
providedIn: 'root'
})
export class BackgroundSyncService {
constructor(private swUpdate: SwUpdate) {}
async registerSync(syncTag: string): Promise<boolean> {
if (!this.isSyncSupported()) {
console.warn('Background sync is not supported by your browser');
return false;
}
try {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(syncTag);
console.log(`Background sync registered: ${syncTag}`);
return true;
} catch (error) {
console.error('Background sync registration failed:', error);
return false;
}
}
isSyncSupported(): boolean {
return 'serviceWorker' in navigator && 'SyncManager' in window;
}
}
Step 3: Modify Your Service Worker
Next, you'll need to modify your service worker to handle sync events. In Angular, the service worker is automatically generated, but you can extend it.
Create a custom service worker file in the src
directory named custom-sw.js
:
// src/custom-sw.js
// Import the Angular-generated service worker
importScripts('./ngsw-worker.js');
// Handle sync events
self.addEventListener('sync', (event) => {
console.log('Background sync event fired', event);
if (event.tag === 'post-data') {
event.waitUntil(syncData());
}
});
// Function to sync data
async function syncData() {
try {
// Get data from IndexedDB
const db = await openDatabase();
const pendingData = await getPendingData(db);
// Send data to server
for (const item of pendingData) {
await sendToServer(item);
await markAsSynced(db, item.id);
}
console.log('Data synchronization complete');
} catch (error) {
console.error('Data synchronization failed:', error);
throw error; // Rethrow to keep the sync event active
}
}
// Helper functions
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('offlineData', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('pendingRequests')) {
db.createObjectStore('pendingRequests', { keyPath: 'id' });
}
};
request.onsuccess = (event) => resolve(event.target.result);
request.onerror = (error) => reject(error);
});
}
function getPendingData(db) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['pendingRequests'], 'readonly');
const store = transaction.objectStore('pendingRequests');
const request = store.getAll();
request.onsuccess = (event) => resolve(event.target.result);
request.onerror = (error) => reject(error);
});
}
function sendToServer(item) {
return fetch(item.url, {
method: item.method,
headers: item.headers,
body: item.body
});
}
function markAsSynced(db, id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['pendingRequests'], 'readwrite');
const store = transaction.objectStore('pendingRequests');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = (error) => reject(error);
});
}
Step 4: Configure Angular to Use Your Custom Service Worker
Update your angular.json
file to use the custom service worker:
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json",
"swFilePath": "src/custom-sw.js"
Step 5: Create a Data Service for Offline Operations
Now create a service to handle offline data operations:
ng generate service services/offline-data
Implement the service:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BackgroundSyncService } from './background-sync.service';
import { Observable, from, of } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class OfflineDataService {
constructor(
private http: HttpClient,
private backgroundSync: BackgroundSyncService
) {}
saveData(url: string, data: any, method: string = 'POST'): Observable<any> {
// Attempt to send data immediately
return this.http.request(method, url, { body: data }).pipe(
catchError(error => {
// If network error, store the request for later and trigger sync
return from(this.saveForLater(url, data, method));
})
);
}
private async saveForLater(url: string, data: any, method: string): Promise<any> {
try {
// Open IndexedDB
const db = await this.openDatabase();
// Store the request
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await this.storeRequest(db, {
id,
url,
method,
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
},
timestamp: Date.now()
});
// Register for background sync
await this.backgroundSync.registerSync('post-data');
return {
offline: true,
message: 'Data stored for synchronization when online'
};
} catch (error) {
console.error('Error saving data for later:', error);
return {
offline: true,
error: 'Failed to store data for later synchronization',
details: error
};
}
}
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('offlineData', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('pendingRequests')) {
db.createObjectStore('pendingRequests', { keyPath: 'id' });
}
};
request.onsuccess = (event) => resolve(event.target.result);
request.onerror = (event) => reject(event.target.error);
});
}
private storeRequest(db: IDBDatabase, request: any): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['pendingRequests'], 'readwrite');
const store = transaction.objectStore('pendingRequests');
const storeRequest = store.add(request);
storeRequest.onsuccess = () => resolve();
storeRequest.onerror = (event) => reject(event.target.error);
});
}
}
Step 6: Use the Service in Components
Now you can use the offline data service in your components:
import { Component } from '@angular/core';
import { OfflineDataService } from '../services/offline-data.service';
@Component({
selector: 'app-task-form',
template: `
<form (ngSubmit)="saveTask()">
<input [(ngModel)]="task.title" name="title" placeholder="Task title" required>
<textarea [(ngModel)]="task.description" name="description" placeholder="Description"></textarea>
<button type="submit" [disabled]="isSaving">Save Task</button>
<p *ngIf="message">{{ message }}</p>
</form>
`
})
export class TaskFormComponent {
task = { title: '', description: '' };
isSaving = false;
message = '';
constructor(private offlineData: OfflineDataService) {}
saveTask() {
this.isSaving = true;
this.message = '';
this.offlineData.saveData('https://api.example.com/tasks', this.task)
.subscribe(
response => {
this.isSaving = false;
if (response.offline) {
this.message = 'Task saved locally. It will be synced when you are online.';
} else {
this.message = 'Task saved successfully!';
this.resetForm();
}
console.log(response);
},
error => {
this.isSaving = false;
this.message = 'Error saving task: ' + (error.message || 'Unknown error');
console.error('Error saving task:', error);
}
);
}
resetForm() {
this.task = { title: '', description: '' };
}
}
Real-World Use Cases
1. Contact Form Submission
Background Sync is ideal for contact forms. If a user fills out a contact form while offline or with poor connectivity, the form data can be saved locally and synchronized later:
// In your contact form component
submitForm() {
this.offlineData.saveData('/api/contact', this.contactForm.value)
.subscribe(response => {
if (response.offline) {
this.snackBar.open('Your message will be sent when you are online', 'OK');
} else {
this.snackBar.open('Message sent successfully!', 'OK');
}
});
}
2. E-commerce Shopping Cart
For e-commerce applications, Background Sync can ensure that users' cart additions and purchases are not lost due to connectivity issues:
addToCart(product: Product) {
this.offlineData.saveData('/api/cart/add', {
productId: product.id,
quantity: 1,
timestamp: new Date().toISOString()
}).subscribe(response => {
this.cartService.updateLocalCart(product);
if (response.offline) {
this.notificationService.show('Added to cart. Will sync when online.');
}
});
}
3. Comment System
For a blog or forum with a comment system:
postComment() {
const comment = {
postId: this.postId,
text: this.commentText,
author: this.currentUser.name,
date: new Date().toISOString()
};
this.offlineData.saveData('/api/comments', comment)
.subscribe(response => {
// Optimistically add comment to the UI
this.comments.unshift({...comment, pending: response.offline});
this.commentText = '';
if (response.offline) {
this.toastService.show('Your comment will be posted when you are online');
}
});
}
Advanced Techniques
Handling Sync Completion Notification
To notify users when data has been successfully synchronized, we can use the Broadcast Channel API:
// In custom-sw.js
const syncChannel = new BroadcastChannel('sync-channel');
async function syncData() {
try {
// Sync logic here...
// Broadcast success
syncChannel.postMessage({
type: 'SYNC_COMPLETED',
timestamp: Date.now()
});
} catch (error) {
// Broadcast error
syncChannel.postMessage({
type: 'SYNC_FAILED',
error: error.message,
timestamp: Date.now()
});
}
}
And in your Angular service:
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class SyncNotificationService {
private syncChannel: BroadcastChannel;
public syncEvents = new Subject<any>();
constructor() {
if ('BroadcastChannel' in window) {
this.syncChannel = new BroadcastChannel('sync-channel');
this.syncChannel.onmessage = (event) => {
this.syncEvents.next(event.data);
};
}
}
}
Handling Conflicts
When synchronizing data, conflicts might arise. Here's a simple conflict resolution approach:
// In custom-sw.js
async function sendToServer(item) {
const response = await fetch(item.url, {
method: item.method,
headers: item.headers,
body: item.body
});
if (response.status === 409) { // Conflict status code
const serverData = await response.json();
const localData = JSON.parse(item.body);
// Resolve conflict (this is application-specific)
const resolvedData = resolveConflict(localData, serverData);
// Send resolved data
return fetch(item.url, {
method: item.method,
headers: item.headers,
body: JSON.stringify(resolvedData)
});
}
return response;
}
function resolveConflict(localData, serverData) {
// This is highly specific to your application
// Example: choose the most recent version
return new Date(localData.updatedAt) > new Date(serverData.updatedAt)
? localData
: serverData;
}
Summary
Background Sync is a powerful feature for Angular PWAs that enhances the offline user experience by ensuring data integrity across varying network conditions. By implementing Background Sync:
- Users can continue using your application even when offline
- Data is automatically synchronized when connectivity is restored
- The application becomes more resilient to network fluctuations
- User experience improves with transparent handling of connectivity issues
While Background Sync is primarily supported in Chromium-based browsers, it represents a progressive enhancement that significantly improves the robustness of your Angular PWA.
Additional Resources
- MDN Web Docs: Background Synchronization API
- Google Developers: Introducing Background Sync
- Angular Service Worker Guide
- Jake Archibald's "Offline Cookbook"
Exercises
-
Basic Implementation: Create a simple Angular application that allows users to add notes that are synchronized when the user comes online.
-
Sync Status UI: Enhance your application with a UI component that shows which items are pending synchronization and notifies users when synchronization completes.
-
Conflict Resolution: Implement a system that handles conflicts when the same data has been modified both locally and on the server.
-
Periodic Sync: For browsers that support it, implement periodic background sync to regularly update content even when the user isn't actively using the app.
-
Retry Strategy: Implement an exponential backoff strategy for failed sync attempts to avoid overwhelming your servers.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)