A web app usually needs the network for everything, including showing itself. A Service Worker sits between the app and the network: it can answer from a cache, try the network with a time limit, or both. Done well, the app opens instantly on a slow train and keeps working in a tunnel.
Angular has its own Service Worker, driven by a configuration file instead of code. This page covers both halves: reading (what you show when the network is slow or missing) and writing (what you do with the changes made offline).
Turning it on
ng add @angular/pwa adds the @angular/service-worker package, a manifest, icons, an ngsw-config.json and provideServiceWorker(). The worker only runs in production builds and over HTTPS (localhost is the exception). registerWhenStable:30000 registers it once the app is stable, or after 30 seconds at the latest, so its downloads don't compete with the first render.
ng add @angular/pwa
# adds @angular/service-worker, ngsw-config.json, a manifest, icons and provideServiceWorker()
ng build # the worker only runs in production builds, over HTTPS (or on localhost)What gets cached: assetGroups and dataGroups
assetGroups cache the app's own files. prefetch downloads everything at install — the shell: index.html, JS, CSS — so the app opens offline. lazy caches a file only the first time it's requested: images, fonts, rarely used pages. Files with a hash in their name never go stale; the worker checks for a new version on every navigation.
dataGroups cache answers from your API, with one of two strategies. freshness asks the network first and falls back to the cache if the network fails or is slower than timeout: right for data that changes (prices, stock, notifications). performance answers from the cache first and only goes to the network when there's no copy; with refreshAhead, a copy that gets that close to maxAge is still shown, but refreshed in the background before it expires: right for data that rarely changes (a catalogue, settings, translations).
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app-shell",
"installMode": "prefetch",
"resources": { "files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"] }
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": { "files": ["/assets/**", "/*.(svg|png|webp|woff2)"] }
}
],
"dataGroups": [
{
"name": "prices",
"urls": ["/api/prices/**"],
"cacheConfig": { "strategy": "freshness", "timeout": "3s", "maxSize": 50, "maxAge": "1h" }
},
{
"name": "catalogue",
"urls": ["/api/products/**"],
"cacheConfig": {
"strategy": "performance",
"maxSize": 200,
"maxAge": "7d",
"refreshAhead": "1d"
}
}
]
}- 0 ms request sent to the network
- 3000 ms timeout after 3 s → answered from the cache
- 6000 ms the network answers late: the cache is updated for next time
- content after
- 3000 ms
- source
- cache
- data age
- 10 minutes
Watch maxAge: it's a hard limit, not a hint. Entries older than that are thrown away, even when there's no network — an expired copy is never shown, offline or not. Choose it for the oldest data you'd still be willing to show.
New versions: ask, don't swap code under the user's feet
When you deploy, the worker notices on the next navigation and downloads the new version in the background. The open tab keeps running the old one — mixing two versions is what breaks apps. SwUpdate.versionUpdates emits VERSION_READY when the new version is complete; show a message and reload when the user agrees. unrecoverable means the running version's files are no longer on the server: reload. A tab that stays open for days should also call checkForUpdate() now and then.
import { ApplicationRef, Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { SwUpdate, type VersionReadyEvent } from '@angular/service-worker';
import { filter, interval } from 'rxjs';
@Component({
selector: 'app-update-banner',
template: `
@if (ready()) {
<div role="status">
A new version is ready.
<button type="button" (click)="reload()">Reload</button>
</div>
}
`,
})
export class UpdateBanner {
private readonly updates = inject(SwUpdate);
protected readonly ready = signal(false);
constructor() {
if (!this.updates.isEnabled) return; // dev mode, or a browser without Service Worker
const destroyRef = inject(DestroyRef);
this.updates.versionUpdates
.pipe(
filter((e): e is VersionReadyEvent => e.type === 'VERSION_READY'),
takeUntilDestroyed(destroyRef),
)
.subscribe(() => this.ready.set(true)); // ask; don't reload in the middle of a form
// the files of the running version are gone from the server: the only way out is a reload
this.updates.unrecoverable
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(() => document.location.reload());
// a tab that stays open for days also needs to check now and then
const appRef = inject(ApplicationRef);
void appRef.whenStable().then(() =>
interval(6 * 60 * 60 * 1000)
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(() => void this.updates.checkForUpdate()),
);
}
protected reload(): void {
document.location.reload(); // the new version starts with the next page load
}
}ngsw.json and ngsw-worker.js must never be cached for long by your CDN or server (Cache-Control: no-cache). If the CDN keeps an old ngsw.json, the worker never learns that a new version exists and users stay on the old one for as long as the CDN cache lives. Files with a hash in their name, on the other hand, can be immutable.Writing offline: an outbox
The Service Worker caches reads; it doesn't keep your POST requests for later. Changes made offline go to an outbox: they're saved locally, shown as waiting, and leave one by one, in order, when the connection returns.
- Saved notes appear here, with their sync status.
- waiting
- 0
- synced
- 0
Three details make an outbox reliable. It's persisted (here in localStorage; IndexedDB for large data or if a worker needs to read it), so a reload doesn't lose anything. It sends one item at a time, so the server receives changes in the order they were made. And every item carries an Idempotency-Key generated when the user saved, so a send that half-succeeded can't create a duplicate — the same idea as in optimistic updates. For navigator.onLine, only false is certain: true means there's a network, not that it reaches the internet.
import { HttpClient, HttpContext } from '@angular/common/http';
import { DOCUMENT, Service, computed, effect, inject, signal, untracked } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { IDEMPOTENCY_KEY } from './idempotency'; // see the page on optimistic updates
import { injectOnline } from './online';
interface Pending {
key: string; // Idempotency-Key, generated ONCE when the user saves
url: string;
body: unknown;
}
const STORAGE = 'outbox';
@Service()
export class Outbox {
private readonly http = inject(HttpClient);
private readonly storage = inject(DOCUMENT).defaultView?.localStorage; // undefined during SSR
readonly online = injectOnline();
readonly items = signal<readonly Pending[]>(JSON.parse(this.storage?.getItem(STORAGE) ?? '[]'));
readonly waiting = computed(() => this.items().length);
private flushing = false;
constructor() {
// survives a reload (IndexedDB if the data is large or a worker needs to read it)
effect(() => this.storage?.setItem(STORAGE, JSON.stringify(this.items())));
effect(() => {
if (this.online()) untracked(() => void this.flush());
});
}
save(url: string, body: unknown): void {
this.items.update((list) => [...list, { key: crypto.randomUUID(), url, body }]);
if (this.online()) void this.flush();
}
/** One at a time, in order. A failure stops the queue; the next "online" resumes it. */
private async flush(): Promise<void> {
if (this.flushing) return;
this.flushing = true;
try {
while (this.online() && this.items().length) {
const [next] = this.items();
await firstValueFrom(
this.http.post(next.url, next.body, {
context: new HttpContext().set(IDEMPOTENCY_KEY, next.key),
}),
);
this.items.update((list) => list.slice(1));
}
} catch {
// still offline, or the server is down: the item stays first in line
} finally {
this.flushing = false;
}
}
}When YES, when NO
- Apps used in the field or on the move: inspections, deliveries, sales visits.
- Content people read again: documentation, articles, catalogues.
- Forms that must not lose data when the network drops.
- Data that's dangerous when stale: stock trading, live dispatch, emergency information.
- Internal tools always used on a fast office network: the extra complexity buys little.
- Apps where you can't invest in cache invalidation and testing: at least cache only the shell.
Common pitfalls
- A CDN that caches
ngsw.json. Users stay on the old version long after the deploy. performancefor data that must be fresh. A price from yesterday, shown instantly and confidently.- Reloading automatically when a version is ready. The user loses the form they were filling in.
- Trusting
navigator.onLine === true. Hotel Wi-Fi with a login page says “online” too. - An outbox in memory only, or without idempotency keys. The first loses data on reload, the second creates duplicates on retry.
Cheat sheet
ng add @angular/pwa worker + manifest + ngsw-config.json (production builds only)
registrationStrategy 'registerWhenStable:30000': after the first render
assetGroups prefetch the app shell: index.html, JS, CSS, available offline
assetGroups lazy images, fonts: cached on first use
dataGroups freshness + timeout network first; slower than timeout → answer from the cache
dataGroups performance cache first; refreshAhead refreshes before maxAge in the background
maxAge hard limit: older entries are thrown away, even offline
SwUpdate.versionUpdates VERSION_READY → ask the user, reload on click
SwUpdate.unrecoverable the old version's files are gone → reload
checkForUpdate() periodically, for tabs that stay open for days
CDN / HTTP cache ngsw.json and ngsw-worker.js: no-cache, never immutable
navigator.onLine false is certain, true only means "there is a network"
writes offline outbox: ordered, persisted, with an Idempotency-Key each