Progressive Web Apps (PWA)
Registering a service worker gives an Angular app offline support, cached repeat loads, background updates, and installability
Modern Angular only No equivalent exists in the original 100 Days series. Written fresh for Angular v22.
Lead with this: A Progressive Web App adds offline support, background updates, and home screen installability to your Angular app by registering a service worker — a background script that intercepts network requests and decides whether to serve them from cache or the network.
What it is
A Progressive Web App (PWA) is a web app that uses modern browser APIs to behave more like a native app:
| Capability | What enables it |
|---|---|
| Works offline | Service worker caches app shell + assets |
| Fast repeat loads | Assets served from cache, not network |
| Background updates | New version downloaded while user uses the old one |
| Installable | manifest.webmanifest + service worker → Add to Home Screen |
| Push notifications | Push API + service worker (advanced, not covered here) |
Angular's @angular/service-worker package handles the hard parts: it
generates a versioned asset manifest at build time, ships a pre-built service
worker (ngsw-worker.js), and provides SwUpdate — an injectable service
for detecting and applying updates.
How it works under the hood
Old model — plain web app, no offline support
A plain Angular app has no concept of offline:
User visits page ↓Browser requests index.html from server ↓Browser parses HTML, requests main.js, styles.css, assets ↓All requests go to the network — if network is unavailable, app fails ↓User must reload when a new version is deployed to see updatesEvery visit pays full network cost. A slow connection means a slow app. No network means a broken app. The user has no way to install the app to their home screen for quick re-access.
New model — service worker as network proxy
A service worker is a JavaScript file that runs in the background in a separate thread from the main page. Once registered, it intercepts every network request the page makes — JS bundles, CSS, images, API calls — and decides how to handle each one:
User visits page (first time) ↓Browser registers ngsw-worker.js (the Angular service worker) ↓SW downloads and caches all prefetch assets (app shell) ↓Future visits: SW intercepts requests ├─ App shell (JS, CSS, HTML): served from cache — near-instant └─ API requests: served per the configured strategy ├─ performance strategy: cache-first, background update └─ freshness strategy: network-first, fall back to cache
User is offline ↓SW serves everything from cache — app still works ↓Network request to API fails: app shows cached data or graceful error
New version deployed ↓On next page load, SW fetches ngsw.json and compares hashes ↓Changed hashes → SW downloads new bundles in background ↓SwUpdate.versionUpdates emits VersionReadyEvent ↓App prompts user: "Update available — click to reload" ↓User confirms → activateUpdate() + window.location.reload()How Angular's service worker manages versions
At build time, Angular generates ngsw.json — a manifest that lists every
cached file with its content hash. The service worker loads this manifest on
install and on each application reload.
When you deploy a new version, some hashes change. The service worker detects the change, downloads the new files in the background (without disrupting the current user session), and flags itself as ready. The old version keeps serving the current user until they reload.
This version-atomic approach means users never see a mixed state of old and new files — they either get the complete old version or the complete new version, never a mix.
Setup
ng add @angular/pwaThis command:
- Installs
@angular/service-worker - Creates
src/manifest.webmanifest(app name, icons, colors) - Creates
src/ngsw-config.json(caching rules) - Adds icon files to
src/assets/icons/ - Configures
angular.jsonto bundle the service worker - Adds
provideServiceWorker()to your providers - Adds
<link rel="manifest">and theme-color meta tag toindex.html
Your app.config.ts gets:
// Generated by ng add @angular/pwaimport { provideServiceWorker } from '@angular/service-worker';import { isDevMode } from '@angular/core';
export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), // disabled during ng serve registrationStrategy: 'registerWhenStable:30000' // Register when the app is stable, or after 30 seconds — whichever comes first }), ],};// NgModule approach (Angular 2–13)@NgModule({ imports: [ ServiceWorkerModule.register('ngsw-worker.js', { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000', }), ],})export class AppModule {}Critical: Service workers only run in production builds.
ng serve (development mode) explicitly disables them.
Always test PWA features against a production build:
ng build && npx http-server dist/my-app/browser -p 8080 -c-1The -c-1 flag disables http-server's own caching so you see the service
worker's caching, not the server's.
Configuring what to cache — ngsw-config.json
The generated ngsw-config.json has two sections: assetGroups (static app
files) and dataGroups (API responses).
assetGroups — caching static app files
{ "assetGroups": [ { "name": "app", "installMode": "prefetch", "updateMode": "prefetch", "resources": { "files": [ "/favicon.ico", "/index.html", "/manifest.webmanifest", "/*.css", "/*.js" ] } }, { "name": "assets", "installMode": "lazy", "updateMode": "lazy", "resources": { "files": [ "/assets/**", "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" ] } } ]}| Option | Values | Meaning |
|---|---|---|
installMode: 'prefetch' | — | Download and cache at install time (app shell) |
installMode: 'lazy' | — | Cache only when first requested |
updateMode: 'prefetch' | — | Download new version proactively when app updates |
updateMode: 'lazy' | — | Only update in cache when next requested |
Keep your critical app shell (index.html, JS bundles, CSS) on prefetch.
Large image assets should be lazy — no point downloading images the user
might never visit.
dataGroups — caching API responses
{ "dataGroups": [ { "name": "api-performance", "urls": ["/api/products/**", "/api/categories"], "cacheConfig": { "strategy": "performance", "maxSize": 100, "maxAge": "1h", "timeout": "3s" } }, { "name": "api-freshness", "urls": ["/api/user/**", "/api/cart"], "cacheConfig": { "strategy": "freshness", "maxSize": 20, "maxAge": "5m", "timeout": "3s" } } ]}| Strategy | Behavior | Use when |
|---|---|---|
performance | Cache-first. Serve from cache immediately, refresh in background | Product listings, reference data — stale is acceptable |
freshness | Network-first. Try network; fall back to cache if offline | User data, cart, anything that must be current |
timeout caps how long to wait for the network before falling back to cache.
maxAge sets how long cached entries stay valid. maxSize limits how many
URLs this group caches.
Handling app updates — SwUpdate
SwUpdate is an injectable service that exposes the service worker's update
lifecycle as an Observable:
import { Component, inject, signal } from '@angular/core';import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';import { filter } from 'rxjs';import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ selector: 'app-root', standalone: true, template: ` @if (updateAvailable()) { <div class="update-banner"> A new version is available. <button (click)="applyUpdate()">Update now</button> </div> } <router-outlet /> `,})export class AppComponent { private swUpdate = inject(SwUpdate); updateAvailable = signal(false);
constructor() { if (!this.swUpdate.isEnabled) return; // graceful degradation
// versionUpdates is the modern API (versionUpdates replaced available/activated) this.swUpdate.versionUpdates.pipe( filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), takeUntilDestroyed() ).subscribe(() => { this.updateAvailable.set(true); });
// Handle the rare case where the service worker enters an unrecoverable state this.swUpdate.unrecoverable.pipe( takeUntilDestroyed() ).subscribe(() => { window.location.reload(); }); }
async applyUpdate(): Promise<void> { await this.swUpdate.activateUpdate(); window.location.reload(); }}Proactive update checks
The service worker automatically checks for updates on each page reload. For long-running sessions (apps left open in a tab for hours), you may want to check periodically:
constructor() { if (!this.swUpdate.isEnabled) return;
// Check for updates every 6 hours in long-running sessions interval(6 * 60 * 60 * 1000).pipe( takeUntilDestroyed() ).subscribe(() => { this.swUpdate.checkForUpdate().catch(() => { // Silently ignore — user may be offline }); });}Common mistakes
Mistake 1 — Testing with ng serve
Service workers are explicitly disabled when isDevMode() is true. If you
test your PWA with ng serve, the service worker never registers:
# ❌ Service worker disabled — PWA features don't workng serve
# ✅ Production build + static server — service worker activeng build --configuration=productionnpx http-server dist/my-app/browser -c-1Mistake 2 — Using the deprecated SwUpdate.available Observable
swUpdate.available and swUpdate.activated were deprecated. Use the unified
swUpdate.versionUpdates with type narrowing:
// ❌ Deprecated — shows warning in v22this.swUpdate.available.subscribe(() => this.updateAvailable = true);
// ✅ Current API — versionUpdates with type guardthis.swUpdate.versionUpdates.pipe( filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY')).subscribe(() => this.updateAvailable.set(true));Mistake 3 — Not checking swUpdate.isEnabled before calling methods
In browsers that don't support service workers, swUpdate.isEnabled is
false. Calling checkForUpdate() or subscribing to versionUpdates on
a disabled SwUpdate returns rejected Promises and never-emitting Observables
— but calling methods directly can still throw:
// ❌ Throws if service workers unsupportedthis.swUpdate.checkForUpdate();
// ✅ Guard with isEnabled firstif (this.swUpdate.isEnabled) { this.swUpdate.checkForUpdate();}Mistake 4 — Caching user-specific API endpoints aggressively
Caching /api/user/profile with performance strategy means User A might
see User B's cached data if they log in on the same device. Always use
freshness strategy for user-specific, auth-protected, or mutable data:
{ "name": "user-data", "urls": ["/api/user/**", "/api/account/**"], "cacheConfig": { "strategy": "freshness", // ✅ network-first for user data "maxAge": "1m", "maxSize": 10 }}How this evolved
-
Angular 5 (2017):
@angular/service-workerintroduced alongside the@angular/pwaschematic. First production-ready service worker for Angular apps, withngsw-config.jsonand the asset group model. -
Angular 6–9 (2018–2020): Stability improvements. Push notifications support added via
SwPush. Data groups for API caching added. -
Angular 12 (2021):
SwUpdate.availableandSwUpdate.activateddeprecated in favor ofSwUpdate.versionUpdates— a unified Observable that emits typedVersionEventobjects. -
Angular 14 (2022): Standalone:
provideServiceWorker()introduced as the standalone alternative toServiceWorkerModule.register(). -
Angular 22 (now):
@angular/service-workeris stable and unchanged.SwUpdate.versionUpdatesis the standard update API. The main forward- looking note: Angular's built-in service worker is intentionally simple. For advanced use cases (background sync, fine-grained push notification logic, custom offline pages), consider Workbox as a more capable alternative that can coexist with Angular.
See also
- SSR & Hydration — SSR and PWA serve different performance goals; they can work together
- HTTP —
HttpClientrequests are intercepted by the service worker when dataGroups rules match - Official docs — Service Workers overview
- Official docs — SwUpdate API
- Official docs — ngsw-config reference
- Web App Manifest — MDN
- Workbox — for advanced service worker needs