Skip to main content

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:

  1. An Angular project set up
  2. Angular Service Worker (NGSW) installed and configured
  3. Basic understanding of PWAs and Service Workers

If you haven't set up Angular PWA yet, you can run:

bash
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.

typescript
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:

bash
ng generate service services/background-sync

Step 2: Register Sync Event in the Service

Edit the background sync service to register sync events:

typescript
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:

javascript
// 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:

json
"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:

bash
ng generate service services/offline-data

Implement the service:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
// 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

Exercises

  1. Basic Implementation: Create a simple Angular application that allows users to add notes that are synchronized when the user comes online.

  2. Sync Status UI: Enhance your application with a UI component that shows which items are pending synchronization and notifies users when synchronization completes.

  3. Conflict Resolution: Implement a system that handles conflicts when the same data has been modified both locally and on the server.

  4. 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.

  5. 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! :)