Une application web a d’habitude besoin du réseau pour tout, y compris pour s’afficher. Un Service Worker se place entre l’application et le réseau : il peut répondre depuis un cache, essayer le réseau avec une limite de temps, ou les deux. Bien fait, l’application s’ouvre instantanément dans un train lent et continue de fonctionner dans un tunnel.
Angular a son propre Service Worker, piloté par un fichier de configuration plutôt que par du code. Cette page couvre les deux moitiés : la lecture (ce que vous affichez quand le réseau est lent ou absent) et l’écriture (ce que vous faites des modifications faites hors ligne).
L’activer
ng add @angular/pwa ajoute le paquet @angular/service-worker, un manifeste, des icônes, un ngsw-config.json et provideServiceWorker(). Le worker ne tourne que dans les builds de production et en HTTPS (localhost est l’exception). registerWhenStable:30000 l’enregistre une fois l’application stable, ou au plus tard après 30 secondes, pour que ses téléchargements ne concurrencent pas le premier rendu.
ng add @angular/pwa
# ajoute @angular/service-worker, ngsw-config.json, un manifeste, des icônes et provideServiceWorker()
ng build # le worker ne tourne que dans les builds de production, en HTTPS (ou sur localhost)Ce qui est mis en cache : assetGroups et dataGroups
Les assetGroups mettent en cache les fichiers de l’application. prefetch télécharge tout à l’installation — le shell : index.html, JS, CSS — pour que l’application s’ouvre hors ligne. lazy ne met un fichier en cache que la première fois qu’il est demandé : images, polices, pages rarement visitées. Les fichiers avec un hash dans leur nom ne deviennent jamais obsolètes ; le worker vérifie s’il existe une nouvelle version à chaque navigation.
Les dataGroups mettent en cache les réponses de votre API, avec l’une de deux stratégies. freshness interroge d’abord le réseau et se rabat sur le cache si le réseau échoue ou est plus lent que timeout : adapté aux données qui changent (prix, stock, notifications). performance répond d’abord depuis le cache et ne va sur le réseau que s’il n’y a pas de copie ; avec refreshAhead, une copie qui arrive aussi près de maxAge est toujours affichée, mais rafraîchie en arrière-plan avant d’expirer : adapté aux données qui changent rarement (un catalogue, des réglages, des traductions).
{
"$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 requête envoyée au réseau
- 3000 ms timeout après 3 s → réponse depuis le cache
- 6000 ms le réseau répond en retard : le cache est mis à jour pour la prochaine fois
- contenu après
- 3000 ms
- source
- cache
- âge des données
- 10 minutes
Attention à maxAge : c’est une limite stricte, pas une indication. Les entrées plus anciennes sont jetées, même sans réseau — une copie expirée n’est jamais affichée, hors ligne ou non. Choisissez-le selon les données les plus anciennes que vous accepteriez encore d’afficher.
Nouvelles versions : demander, ne pas changer le code sous les pieds de l’utilisateur
Quand vous déployez, le worker le remarque à la navigation suivante et télécharge la nouvelle version en arrière-plan. L’onglet ouvert continue d’exécuter l’ancienne — mélanger deux versions est ce qui casse les applications. SwUpdate.versionUpdates émet VERSION_READY quand la nouvelle version est complète ; affichez un message et rechargez quand l’utilisateur est d’accord. unrecoverable signifie que les fichiers de la version en cours ne sont plus sur le serveur : rechargez. Un onglet qui reste ouvert des jours devrait aussi appeler checkForUpdate() de temps en temps.
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">
Une nouvelle version est prête.
<button type="button" (click)="reload()">Recharger</button>
</div>
}
`,
})
export class UpdateBanner {
private readonly updates = inject(SwUpdate);
protected readonly ready = signal(false);
constructor() {
if (!this.updates.isEnabled) return; // mode dev, ou un navigateur sans 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)); // demander ; ne pas recharger au milieu d’un formulaire
// les fichiers de la version en cours ont disparu du serveur : seul un rechargement s’en sort
this.updates.unrecoverable
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(() => document.location.reload());
// un onglet qui reste ouvert des jours doit aussi vérifier de temps en temps
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(); // la nouvelle version démarre au prochain chargement de la page
}
}ngsw.json et ngsw-worker.js ne doivent jamais être mis en cache longtemps par votre CDN ou votre serveur (Cache-Control: no-cache). Si le CDN garde un vieux ngsw.json, le worker n’apprend jamais qu’une nouvelle version existe et les utilisateurs restent sur l’ancienne tant que vit le cache du CDN. Les fichiers avec un hash dans leur nom, en revanche, peuvent être immutable.Écrire hors ligne : une outbox
Le Service Worker met en cache les lectures ; il ne garde pas vos requêtes POST pour plus tard. Les modifications faites hors ligne vont dans une outbox : elles sont enregistrées localement, affichées comme en attente et partent une par une, dans l’ordre, quand la connexion revient.
- Les notes enregistrées apparaissent ici, avec leur état de synchronisation.
- en attente
- 0
- synchronisées
- 0
Trois détails rendent une outbox fiable. Elle est persistée (ici dans localStorage ; IndexedDB pour de grosses données ou si un worker doit la lire), donc un rechargement ne perd rien. Elle envoie un élément à la fois, donc le serveur reçoit les modifications dans l’ordre où elles ont été faites. Et chaque élément porte une Idempotency-Key générée au moment de l’enregistrement, pour qu’un envoi à moitié réussi ne crée pas de doublon — la même idée que pour les mises à jour optimistes. Pour navigator.onLine, seul false est certain : true veut dire qu’il y a un réseau, pas qu’il atteint 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'; // voir la page sur les mises à jour optimistes
import { injectOnline } from './online';
interface Pending {
key: string; // Idempotency-Key, générée UNE SEULE FOIS quand l’utilisateur enregistre
url: string;
body: unknown;
}
const STORAGE = 'outbox';
@Service()
export class Outbox {
private readonly http = inject(HttpClient);
private readonly storage = inject(DOCUMENT).defaultView?.localStorage; // undefined pendant le 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() {
// survit à un rechargement (IndexedDB si les données sont volumineuses ou qu’un worker doit les lire)
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();
}
/** Une à la fois, dans l’ordre. Un échec arrête la file ; le prochain "online" la relance. */
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 {
// toujours hors ligne, ou le serveur est en panne : l’élément reste en tête de file
} finally {
this.flushing = false;
}
}
}Quand OUI, quand NON
- Les applications utilisées sur le terrain ou en déplacement : inspections, livraisons, visites commerciales.
- Le contenu qu’on relit : documentation, articles, catalogues.
- Les formulaires qui ne doivent pas perdre de données quand le réseau tombe.
- Les données dangereuses quand elles sont périmées : trading, dispatch en direct, informations d’urgence.
- Les outils internes toujours utilisés sur le réseau rapide du bureau : la complexité en plus rapporte peu.
- Les applications où vous ne pouvez pas investir dans l’invalidation du cache et les tests : mettez au moins en cache le shell seul.
Pièges fréquents
- Un CDN qui met
ngsw.jsonen cache. Les utilisateurs restent sur l’ancienne version longtemps après le déploiement. performancepour des données qui doivent être fraîches. Un prix d’hier, affiché instantanément et en toute confiance.- Recharger automatiquement quand une version est prête. L’utilisateur perd le formulaire qu’il remplissait.
- Faire confiance à
navigator.onLine === true. Le Wi-Fi d’hôtel avec page de connexion dit aussi « en ligne ». - Une outbox seulement en mémoire, ou sans clés d’idempotence. La première perd des données au rechargement, la seconde crée des doublons au nouvel essai.
Aide-mémoire
ng add @angular/pwa worker + manifeste + ngsw-config.json (builds de production uniquement)
registrationStrategy 'registerWhenStable:30000' : après le premier rendu
assetGroups prefetch le shell de l’app : index.html, JS, CSS, disponibles hors ligne
assetGroups lazy images, polices : mises en cache à la première utilisation
dataGroups freshness + timeout le réseau d’abord ; plus lent que timeout → réponse depuis le cache
dataGroups performance le cache d’abord ; refreshAhead rafraîchit en arrière-plan avant maxAge
maxAge limite stricte : les entrées plus anciennes sont jetées, même hors ligne
SwUpdate.versionUpdates VERSION_READY → demander à l’utilisateur, recharger au clic
SwUpdate.unrecoverable les fichiers de l’ancienne version ont disparu → recharger
checkForUpdate() périodiquement, pour les onglets ouverts pendant des jours
CDN / cache HTTP ngsw.json et ngsw-worker.js : no-cache, jamais immutable
navigator.onLine false est certain, true veut seulement dire « il y a un réseau »
écritures hors ligne outbox : ordonnée, persistée, chacune avec une Idempotency-Key