Eine Web-App braucht normalerweise für alles das Netzwerk, sogar um sich selbst anzuzeigen. Ein Service Worker sitzt zwischen App und Netzwerk: Er kann aus einem Cache antworten, das Netzwerk mit Zeitlimit versuchen oder beides. Gut gemacht öffnet sich die App im langsamen Zug sofort und läuft im Tunnel weiter.
Angular hat einen eigenen Service Worker, gesteuert über eine Konfigurationsdatei statt über Code. Diese Seite behandelt beide Hälften: Lesen (was du zeigst, wenn das Netzwerk langsam ist oder fehlt) und Schreiben (was du mit den offline gemachten Änderungen tust).
Einschalten
ng add @angular/pwa fügt das Paket @angular/service-worker, ein Manifest, Icons, eine ngsw-config.json und provideServiceWorker() hinzu. Der Worker läuft nur in Production-Builds und über HTTPS (localhost ist die Ausnahme). registerWhenStable:30000 registriert ihn, sobald die App stabil ist, spätestens nach 30 Sekunden, damit seine Downloads nicht mit dem ersten Rendern konkurrieren.
ng add @angular/pwa
# fügt @angular/service-worker, ngsw-config.json, ein Manifest, Icons und provideServiceWorker() hinzu
ng build # der Worker läuft nur in Production-Builds, über HTTPS (oder auf localhost)Was gecacht wird: assetGroups und dataGroups
assetGroups cachen die eigenen Dateien der App. prefetch lädt bei der Installation alles herunter — die Shell: index.html, JS, CSS —, damit die App offline startet. lazy cacht eine Datei erst, wenn sie zum ersten Mal angefragt wird: Bilder, Fonts, selten genutzte Seiten. Dateien mit Hash im Namen veralten nie; der Worker prüft bei jeder Navigation, ob es eine neue Version gibt.
dataGroups cachen Antworten deiner API, mit einer von zwei Strategien. freshness fragt zuerst das Netzwerk und fällt auf den Cache zurück, wenn das Netzwerk ausfällt oder langsamer als timeout ist: richtig für Daten, die sich ändern (Preise, Bestand, Benachrichtigungen). performance antwortet zuerst aus dem Cache und geht nur ins Netzwerk, wenn es keine Kopie gibt; mit refreshAhead wird eine Kopie, die so nah an maxAge kommt, weiterhin angezeigt, aber vor dem Ablauf im Hintergrund aktualisiert: richtig für Daten, die sich selten ändern (ein Katalog, Einstellungen, Übersetzungen).
{
"$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 Anfrage ans Netzwerk gesendet
- 3000 ms Timeout nach 3 s → aus dem Cache beantwortet
- 6000 ms das Netzwerk antwortet spät: der Cache wird fürs nächste Mal aktualisiert
- Inhalt nach
- 3000 ms
- Quelle
- Cache
- Alter der Daten
- 10 Minuten
Achte auf maxAge: Es ist eine harte Grenze, kein Hinweis. Ältere Einträge werden verworfen, auch ohne Netzwerk — eine abgelaufene Kopie wird nie angezeigt, offline oder nicht. Wähle es nach den ältesten Daten, die du noch zeigen würdest.
Neue Versionen: fragen, nicht den Code unter den Füßen des Nutzers tauschen
Nach einem Deploy bemerkt der Worker das bei der nächsten Navigation und lädt die neue Version im Hintergrund. Der offene Tab läuft mit der alten weiter — zwei Versionen zu mischen ist das, was Apps kaputt macht. SwUpdate.versionUpdates sendet VERSION_READY, wenn die neue Version vollständig ist; zeig eine Meldung und lade neu, wenn der Nutzer zustimmt. unrecoverable heißt, die Dateien der laufenden Version liegen nicht mehr auf dem Server: neu laden. Ein Tab, der tagelang offen bleibt, sollte außerdem ab und zu checkForUpdate() aufrufen.
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">
Eine neue Version ist bereit.
<button type="button" (click)="reload()">Neu laden</button>
</div>
}
`,
})
export class UpdateBanner {
private readonly updates = inject(SwUpdate);
protected readonly ready = signal(false);
constructor() {
if (!this.updates.isEnabled) return; // Dev-Modus oder ein Browser ohne 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)); // fragen; nicht mitten in einem Formular neu laden
// die Dateien der laufenden Version sind vom Server verschwunden: nur ein Reload hilft
this.updates.unrecoverable
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(() => document.location.reload());
// ein Tab, der tagelang offen bleibt, muss auch ab und zu nachsehen
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(); // die neue Version startet beim nächsten Laden der Seite
}
}ngsw.json und ngsw-worker.js dürfen von CDN oder Server nie lange gecacht werden (Cache-Control: no-cache). Behält das CDN eine alte ngsw.json, erfährt der Worker nie von einer neuen Version, und die Nutzer bleiben auf der alten, solange der CDN-Cache lebt. Dateien mit Hash im Namen dürfen dagegen immutable sein.Offline schreiben: eine Outbox
Der Service Worker cacht Lesezugriffe; deine POST-Anfragen hebt er nicht für später auf. Offline gemachte Änderungen landen in einer Outbox: Sie werden lokal gespeichert, als wartend angezeigt und gehen eine nach der anderen, in Reihenfolge, raus, wenn die Verbindung zurück ist.
- Gespeicherte Notizen erscheinen hier, mit ihrem Sync-Status.
- wartend
- 0
- synchronisiert
- 0
Drei Details machen eine Outbox zuverlässig. Sie ist persistiert (hier in localStorage; IndexedDB für große Daten oder wenn ein Worker sie lesen muss), ein Reload verliert also nichts. Sie sendet ein Element nach dem anderen, der Server erhält die Änderungen also in der Reihenfolge, in der sie gemacht wurden. Und jedes Element trägt einen beim Speichern erzeugten Idempotency-Key, sodass ein halb gelungenes Senden kein Duplikat erzeugen kann — dieselbe Idee wie bei optimistischen Updates. Bei navigator.onLine ist nur false sicher: true heißt, es gibt ein Netzwerk, nicht, dass es das Internet erreicht.
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'; // siehe die Seite zu optimistischen Updates
import { injectOnline } from './online';
interface Pending {
key: string; // Idempotency-Key, EINMAL erzeugt, wenn der Nutzer speichert
url: string;
body: unknown;
}
const STORAGE = 'outbox';
@Service()
export class Outbox {
private readonly http = inject(HttpClient);
private readonly storage = inject(DOCUMENT).defaultView?.localStorage; // undefined während 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() {
// übersteht einen Reload (IndexedDB, wenn die Daten groß sind oder ein Worker sie lesen muss)
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();
}
/** Eins nach dem anderen, in Reihenfolge. Ein Fehler stoppt die Warteschlange; das nächste "online" setzt sie fort. */
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 {
// noch offline oder der Server ist down: das Element bleibt vorne in der Reihe
} finally {
this.flushing = false;
}
}
}Wann ja, wann nein
- Apps im Außendienst oder unterwegs: Inspektionen, Lieferungen, Kundenbesuche.
- Inhalte, die man wieder liest: Dokumentation, Artikel, Kataloge.
- Formulare, die keine Daten verlieren dürfen, wenn das Netz wegbricht.
- Daten, die veraltet gefährlich sind: Börsenhandel, Live-Disposition, Notfallinformationen.
- Interne Tools, die immer im schnellen Büronetz laufen: Die zusätzliche Komplexität bringt wenig.
- Apps, bei denen du nicht in Cache-Invalidierung und Tests investieren kannst: cache wenigstens nur die Shell.
Häufige Stolperfallen
- Ein CDN, das
ngsw.jsoncacht. Die Nutzer bleiben noch lange nach dem Deploy auf der alten Version. performancefür Daten, die frisch sein müssen. Ein Preis von gestern, sofort und selbstbewusst angezeigt.- Automatisch neu laden, wenn eine Version bereit ist. Der Nutzer verliert das Formular, das er gerade ausfüllt.
navigator.onLine === truevertrauen. Auch Hotel-WLAN mit Login-Seite sagt „online“.- Eine Outbox nur im Speicher oder ohne Idempotenzschlüssel. Die erste verliert Daten beim Reload, die zweite erzeugt Duplikate beim Retry.
Spickzettel
ng add @angular/pwa Worker + Manifest + ngsw-config.json (nur Production-Builds)
registrationStrategy 'registerWhenStable:30000': nach dem ersten Rendern
assetGroups prefetch die App-Shell: index.html, JS, CSS, offline verfügbar
assetGroups lazy Bilder, Fonts: beim ersten Gebrauch gecacht
dataGroups freshness + timeout zuerst Netzwerk; langsamer als timeout → Antwort aus dem Cache
dataGroups performance zuerst Cache; refreshAhead aktualisiert vor maxAge im Hintergrund
maxAge harte Grenze: ältere Einträge fliegen raus, auch offline
SwUpdate.versionUpdates VERSION_READY → den Nutzer fragen, beim Klick neu laden
SwUpdate.unrecoverable die Dateien der alten Version sind weg → neu laden
checkForUpdate() regelmäßig, für Tabs, die tagelang offen bleiben
CDN / HTTP-Cache ngsw.json und ngsw-worker.js: no-cache, nie immutable
navigator.onLine false ist sicher, true heißt nur „es gibt ein Netzwerk“
Schreiben offline Outbox: geordnet, persistiert, jeweils mit Idempotency-Key