Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

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 are reliable (work offline), fast (cached resources), and engaging (installable, push notifications). Think of a PWA as giving your web app a “native disguise” — users can install it to their home screen, it launches without the browser chrome, and it works even when the network drops. The honest trade-off: PWAs are not a replacement for native apps in every scenario. You lose access to some device APIs (Bluetooth LE on iOS, background processing), the install experience is less discoverable than app stores, and iOS Safari still lags behind Chrome in PWA support. But for content-heavy apps, internal tools, and e-commerce — where you want reach over richness — PWAs are an excellent fit.
┌─────────────────────────────────────────────────────────────────────────┐
│                    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

The ngsw-config.json is the brain of Angular’s service worker. It defines two critical things: asset groups (your app shell — HTML, JS, CSS, images) and data groups (API responses). The caching strategies here determine the offline experience your users get, so it is worth understanding each option.
// 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

Detecting offline status sounds simple, but there is a nuance: navigator.onLine only tells you if there is a network connection, not if the internet is reachable. A user connected to airport Wi-Fi with a captive portal shows as “online” but cannot reach your API. For critical operations, combine the browser’s online/offline events with actual health-check pings to your server.
// 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