PWA Overview
Estimated Time: 2 hours | Difficulty: Intermediate | Prerequisites: HTTP, Caching
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ PWA Capabilities │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ RELIABLE FAST ENGAGING │
│ ───────────── ───── ────────── │
│ • Offline support • Cached assets • Installable │
│ • Background sync • Lazy loading • Push notifications │
│ • Network resilience • Preloading • Full-screen mode │
│ • Graceful fallbacks • Streaming • Home screen icon │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Service Worker │ │
│ │ │ │
│ │ Browser ──────► Service Worker ──────► Network/Cache │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Cache Storage │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Setup Angular PWA
Copy
# Add PWA support
ng add @angular/pwa
# This creates:
# - ngsw-config.json (Service Worker configuration)
# - manifest.webmanifest (Web App Manifest)
# - Icons in assets/icons/
# - Updates angular.json and app.config.ts
Service Worker Configuration
Copy
// ngsw-config.json
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
],
"dataGroups": [
{
"name": "api-freshness",
"urls": ["/api/users/**", "/api/products/**"],
"cacheConfig": {
"strategy": "freshness",
"maxSize": 100,
"maxAge": "1h",
"timeout": "5s"
}
},
{
"name": "api-performance",
"urls": ["/api/categories/**", "/api/config/**"],
"cacheConfig": {
"strategy": "performance",
"maxSize": 50,
"maxAge": "1d"
}
}
],
"navigationUrls": [
"/**",
"!/__/**",
"!/**/api/**"
],
"navigationRequestStrategy": "freshness"
}
Web App Manifest
Copy
// manifest.webmanifest
{
"name": "My Angular App",
"short_name": "AngularApp",
"description": "A Progressive Web App built with Angular",
"theme_color": "#1976d2",
"background_color": "#fafafa",
"display": "standalone",
"orientation": "portrait-primary",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "assets/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"screenshots": [
{
"src": "assets/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "assets/screenshots/mobile.png",
"sizes": "640x1136",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [
{
"name": "New Task",
"short_name": "Task",
"description": "Create a new task",
"url": "/tasks/new",
"icons": [{ "src": "assets/icons/new-task.png", "sizes": "192x192" }]
}
]
}
Service Worker Updates
Copy
// update.service.ts
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter, interval } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UpdateService {
private swUpdate = inject(SwUpdate);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
constructor() {
if (this.swUpdate.isEnabled) {
this.checkForUpdates();
this.handleVersionReady();
}
}
private checkForUpdates() {
// Check for updates every 6 hours
interval(6 * 60 * 60 * 1000).subscribe(() => {
this.swUpdate.checkForUpdate();
});
}
private handleVersionReady() {
this.swUpdate.versionUpdates
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
.subscribe(evt => {
console.log('Current version:', evt.currentVersion);
console.log('Available version:', evt.latestVersion);
this.promptUpdate();
});
}
private promptUpdate() {
const snackBarRef = this.snackBar.open(
'A new version is available!',
'Update',
{ duration: 10000 }
);
snackBarRef.onAction().subscribe(() => {
this.activateUpdate();
});
}
async activateUpdate() {
try {
await this.swUpdate.activateUpdate();
document.location.reload();
} catch (err) {
console.error('Failed to activate update:', err);
}
}
// Force update on critical changes
async forceUpdate() {
if (!this.swUpdate.isEnabled) return;
try {
const updateAvailable = await this.swUpdate.checkForUpdate();
if (updateAvailable) {
await this.swUpdate.activateUpdate();
window.location.reload();
}
} catch (err) {
console.error('Update check failed:', err);
}
}
}
Offline Support
Offline Detection
Copy
// connectivity.service.ts
@Injectable({ providedIn: 'root' })
export class ConnectivityService {
private _online = signal(navigator.onLine);
readonly online = this._online.asReadonly();
readonly offline = computed(() => !this._online());
constructor() {
window.addEventListener('online', () => {
this._online.set(true);
this.onOnline();
});
window.addEventListener('offline', () => {
this._online.set(false);
this.onOffline();
});
}
private snackBar = inject(MatSnackBar);
private onOnline() {
this.snackBar.open('You are back online!', 'Close', {
duration: 3000,
panelClass: 'online-snackbar'
});
}
private onOffline() {
this.snackBar.open('You are offline. Some features may be unavailable.', 'Close', {
duration: 5000,
panelClass: 'offline-snackbar'
});
}
}
// offline-indicator.component.ts
@Component({
selector: 'app-offline-indicator',
standalone: true,
template: `
@if (connectivity.offline()) {
<div class="offline-banner" role="alert">
<mat-icon>wifi_off</mat-icon>
<span>You're offline. Changes will sync when connected.</span>
</div>
}
`,
styles: [`
.offline-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #f44336;
color: white;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
z-index: 1000;
}
`]
})
export class OfflineIndicatorComponent {
connectivity = inject(ConnectivityService);
}
Offline Data with IndexedDB
Copy
// indexed-db.service.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface AppDB extends DBSchema {
tasks: {
key: string;
value: Task;
indexes: { 'by-status': string; 'by-date': Date };
};
pendingSync: {
key: string;
value: {
id: string;
action: 'create' | 'update' | 'delete';
data: any;
timestamp: Date;
};
};
}
@Injectable({ providedIn: 'root' })
export class IndexedDBService {
private db: IDBPDatabase<AppDB> | null = null;
async init() {
this.db = await openDB<AppDB>('my-app-db', 1, {
upgrade(db) {
// Tasks store
const taskStore = db.createObjectStore('tasks', { keyPath: 'id' });
taskStore.createIndex('by-status', 'status');
taskStore.createIndex('by-date', 'createdAt');
// Pending sync store
db.createObjectStore('pendingSync', { keyPath: 'id' });
}
});
}
// Task operations
async getTasks(): Promise<Task[]> {
return this.db!.getAll('tasks');
}
async getTask(id: string): Promise<Task | undefined> {
return this.db!.get('tasks', id);
}
async saveTask(task: Task): Promise<void> {
await this.db!.put('tasks', task);
}
async deleteTask(id: string): Promise<void> {
await this.db!.delete('tasks', id);
}
async getTasksByStatus(status: string): Promise<Task[]> {
return this.db!.getAllFromIndex('tasks', 'by-status', status);
}
// Pending sync operations
async addPendingSync(action: 'create' | 'update' | 'delete', data: any): Promise<void> {
await this.db!.put('pendingSync', {
id: crypto.randomUUID(),
action,
data,
timestamp: new Date()
});
}
async getPendingSync(): Promise<AppDB['pendingSync']['value'][]> {
return this.db!.getAll('pendingSync');
}
async clearPendingSync(id: string): Promise<void> {
await this.db!.delete('pendingSync', id);
}
}
// offline-sync.service.ts
@Injectable({ providedIn: 'root' })
export class OfflineSyncService {
private db = inject(IndexedDBService);
private api = inject(ApiService);
private connectivity = inject(ConnectivityService);
constructor() {
// Sync when coming back online
effect(() => {
if (this.connectivity.online()) {
this.syncPendingChanges();
}
});
}
async syncPendingChanges() {
const pending = await this.db.getPendingSync();
for (const item of pending) {
try {
switch (item.action) {
case 'create':
await firstValueFrom(this.api.createTask(item.data));
break;
case 'update':
await firstValueFrom(this.api.updateTask(item.data.id, item.data));
break;
case 'delete':
await firstValueFrom(this.api.deleteTask(item.data.id));
break;
}
await this.db.clearPendingSync(item.id);
} catch (error) {
console.error('Sync failed for item:', item.id, error);
// Will retry on next online event
}
}
}
}
Push Notifications
Copy
// push-notification.service.ts
import { SwPush } from '@angular/service-worker';
@Injectable({ providedIn: 'root' })
export class PushNotificationService {
private swPush = inject(SwPush);
private http = inject(HttpClient);
readonly VAPID_PUBLIC_KEY = 'your-vapid-public-key';
async requestSubscription(): Promise<boolean> {
if (!this.swPush.isEnabled) {
console.log('Push notifications not supported');
return false;
}
try {
const subscription = await this.swPush.requestSubscription({
serverPublicKey: this.VAPID_PUBLIC_KEY
});
// Send subscription to backend
await firstValueFrom(
this.http.post('/api/push/subscribe', subscription)
);
return true;
} catch (err) {
console.error('Could not subscribe:', err);
return false;
}
}
async unsubscribe(): Promise<void> {
const subscription = await this.swPush.subscription.pipe(take(1)).toPromise();
if (subscription) {
await subscription.unsubscribe();
await firstValueFrom(
this.http.post('/api/push/unsubscribe', { endpoint: subscription.endpoint })
);
}
}
listenForMessages() {
this.swPush.messages.subscribe((message: any) => {
console.log('Received push message:', message);
// Handle the message (show notification, update UI, etc.)
});
this.swPush.notificationClicks.subscribe(({ action, notification }) => {
console.log('Notification clicked:', action, notification);
// Handle notification click
switch (action) {
case 'open':
window.open(notification.data?.url || '/', '_blank');
break;
case 'dismiss':
// Just close the notification
break;
default:
// Default action - open app
window.focus();
}
});
}
}
// notification-prompt.component.ts
@Component({
selector: 'app-notification-prompt',
template: `
@if (showPrompt()) {
<div class="notification-prompt">
<div class="prompt-content">
<mat-icon>notifications</mat-icon>
<div>
<h4>Stay Updated</h4>
<p>Enable notifications to receive updates about your tasks.</p>
</div>
</div>
<div class="prompt-actions">
<button mat-button (click)="dismiss()">Not Now</button>
<button mat-raised-button color="primary" (click)="enable()">
Enable
</button>
</div>
</div>
}
`
})
export class NotificationPromptComponent {
private pushService = inject(PushNotificationService);
showPrompt = signal(false);
ngOnInit() {
// Show prompt if not already subscribed or dismissed
const dismissed = localStorage.getItem('notification-prompt-dismissed');
if (!dismissed && 'Notification' in window && Notification.permission === 'default') {
setTimeout(() => this.showPrompt.set(true), 5000);
}
}
async enable() {
const success = await this.pushService.requestSubscription();
if (success) {
this.showPrompt.set(false);
}
}
dismiss() {
this.showPrompt.set(false);
localStorage.setItem('notification-prompt-dismissed', 'true');
}
}
Install Prompt
Copy
// install-prompt.service.ts
@Injectable({ providedIn: 'root' })
export class InstallPromptService {
private deferredPrompt: BeforeInstallPromptEvent | null = null;
private _canInstall = signal(false);
readonly canInstall = this._canInstall.asReadonly();
constructor() {
window.addEventListener('beforeinstallprompt', (e: Event) => {
e.preventDefault();
this.deferredPrompt = e as BeforeInstallPromptEvent;
this._canInstall.set(true);
});
window.addEventListener('appinstalled', () => {
this.deferredPrompt = null;
this._canInstall.set(false);
console.log('PWA was installed');
});
}
async promptInstall(): Promise<boolean> {
if (!this.deferredPrompt) {
return false;
}
this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
this.deferredPrompt = null;
this._canInstall.set(false);
return outcome === 'accepted';
}
isStandalone(): boolean {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true;
}
}
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
// install-banner.component.ts
@Component({
selector: 'app-install-banner',
template: `
@if (showBanner()) {
<div class="install-banner">
<div class="banner-content">
<img src="assets/icons/icon-72x72.png" alt="App icon" />
<div>
<h4>Install Our App</h4>
<p>Add to home screen for the best experience</p>
</div>
</div>
<div class="banner-actions">
<button mat-button (click)="dismiss()">Later</button>
<button mat-raised-button color="primary" (click)="install()">
Install
</button>
</div>
</div>
}
`
})
export class InstallBannerComponent {
private installService = inject(InstallPromptService);
showBanner = computed(() => {
return this.installService.canInstall() &&
!this.installService.isStandalone() &&
!this.isDismissed();
});
private isDismissed(): boolean {
const dismissed = localStorage.getItem('install-banner-dismissed');
if (!dismissed) return false;
// Show again after 7 days
const dismissedDate = new Date(dismissed);
const daysSince = (Date.now() - dismissedDate.getTime()) / (1000 * 60 * 60 * 24);
return daysSince < 7;
}
async install() {
await this.installService.promptInstall();
}
dismiss() {
localStorage.setItem('install-banner-dismissed', new Date().toISOString());
}
}
Background Sync
Copy
// background-sync.service.ts
@Injectable({ providedIn: 'root' })
export class BackgroundSyncService {
async registerSync(tag: string): Promise<boolean> {
if (!('serviceWorker' in navigator) || !('SyncManager' in window)) {
return false;
}
try {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
return true;
} catch (err) {
console.error('Background sync registration failed:', err);
return false;
}
}
// Queue data for sync
async queueForSync(action: string, data: any): Promise<void> {
// Store in IndexedDB
const db = await openDB('sync-queue', 1, {
upgrade(db) {
db.createObjectStore('queue', { keyPath: 'id', autoIncrement: true });
}
});
await db.add('queue', {
action,
data,
timestamp: Date.now()
});
// Register for sync
await this.registerSync('data-sync');
}
}
// In your service worker (ngsw-worker.js extension)
self.addEventListener('sync', (event) => {
if (event.tag === 'data-sync') {
event.waitUntil(syncData());
}
});
async function syncData() {
const db = await openDB('sync-queue', 1);
const queue = await db.getAll('queue');
for (const item of queue) {
try {
await fetch('/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item)
});
await db.delete('queue', item.id);
} catch (err) {
console.error('Sync failed for item:', item.id);
// Will retry on next sync event
}
}
}
Testing PWA
Copy
# Build for production (required for service worker)
ng build --configuration production
# Serve with http-server
npx http-server dist/my-app/browser -p 8080
# Or use Angular's built-in server
ng serve --configuration production
PWA Audit with Lighthouse
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Lighthouse PWA Checklist │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Installable: │
│ □ Uses HTTPS │
│ □ Registers a service worker │
│ □ Has a web app manifest │
│ □ Manifest has required icons │
│ │
│ PWA Optimized: │
│ □ Redirects HTTP to HTTPS │
│ □ Configured for custom splash screen │
│ □ Sets theme color │
│ □ Content sized correctly for viewport │
│ □ Has maskable icon │
│ │
│ Offline Capable: │
│ □ Returns 200 when offline │
│ □ start_url responds with 200 when offline │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Next: Deployment & CI/CD
Deploy your Angular application with modern CI/CD practices