Skip to main content
Angular PWA

PWA Overview

Estimated Time: 2 hours | Difficulty: Intermediate | Prerequisites: HTTP, Caching
Progressive Web Apps (PWAs) combine the best of web and native apps. They’re reliable (work offline), fast (cached resources), and engaging (installable, push notifications).
┌─────────────────────────────────────────────────────────────────────────┐
│                    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

# 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

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

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

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

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

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

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

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

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

# 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

┌─────────────────────────────────────────────────────────────────────────┐
│              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