O aplicație web are de obicei nevoie de rețea pentru orice, inclusiv ca să se afișeze. Un Service Worker stă între aplicație și rețea: poate răspunde dintr-un cache, poate încerca rețeaua cu o limită de timp sau amândouă. Bine făcut, aplicația se deschide instant într-un tren lent și continuă să meargă într-un tunel.
Angular are propriul Service Worker, condus de un fișier de configurare în loc de cod. Pagina asta acoperă ambele jumătăți: citirea (ce arăți când rețeaua e lentă sau lipsește) și scrierea (ce faci cu modificările făcute offline).
Cum îl pornești
ng add @angular/pwa adaugă pachetul @angular/service-worker, un manifest, iconițe, un ngsw-config.json și provideServiceWorker(). Worker-ul rulează doar în build-urile de producție și pe HTTPS (localhost e excepția). registerWhenStable:30000 îl înregistrează când aplicația devine stabilă sau după cel mult 30 de secunde, ca descărcările lui să nu concureze cu primul render.
ng add @angular/pwa
# adaugă @angular/service-worker, ngsw-config.json, un manifest, iconițe și provideServiceWorker()
ng build # worker-ul rulează doar în build-urile de producție, pe HTTPS (sau pe localhost)Ce intră în cache: assetGroups și dataGroups
assetGroups pun în cache fișierele aplicației. prefetch descarcă totul la instalare — shell-ul: index.html, JS, CSS — ca aplicația să se deschidă offline. lazy pune un fișier în cache doar prima dată când e cerut: imagini, fonturi, pagini folosite rar. Fișierele cu hash în nume nu se învechesc niciodată; worker-ul verifică dacă există o versiune nouă la fiecare navigare.
dataGroups pun în cache răspunsurile API-ului tău, cu una din două strategii. freshness întreabă întâi rețeaua și cade pe cache dacă rețeaua pică sau e mai lentă decât timeout: potrivit pentru date care se schimbă (prețuri, stoc, notificări). performance răspunde întâi din cache și merge la rețea doar când nu există o copie; cu refreshAhead, o copie care ajunge atât de aproape de maxAge e tot afișată, dar e reîmprospătată în fundal înainte să expire: potrivit pentru date care se schimbă rar (un catalog, setări, traduceri).
{
"$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 cerere trimisă la rețea
- 3000 ms timeout după 3 s → răspuns din cache
- 6000 ms rețeaua răspunde târziu: cache-ul e actualizat pentru data viitoare
- conținut după
- 3000 ms
- sursă
- cache
- vechimea datelor
- 10 minute
Atenție la maxAge: e o limită fermă, nu o sugestie. Intrările mai vechi de atât se aruncă, chiar și când nu există rețea — o copie expirată nu e afișată niciodată, offline sau nu. Alege-l după cele mai vechi date pe care ai mai fi dispus să le arăți.
Versiuni noi: întrebi, nu schimbi codul sub picioarele utilizatorului
Când faci deploy, worker-ul observă la următoarea navigare și descarcă versiunea nouă în fundal. Tabul deschis continuă să ruleze versiunea veche — amestecul a două versiuni e ce strică aplicațiile. SwUpdate.versionUpdates emite VERSION_READY când versiunea nouă e completă; arată un mesaj și reîncarcă atunci când utilizatorul e de acord. unrecoverable înseamnă că fișierele versiunii care rulează nu mai sunt pe server: reîncarcă. Un tab care stă deschis zile întregi ar trebui să apeleze și checkForUpdate() din când în când.
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">
E gata o versiune nouă.
<button type="button" (click)="reload()">Reîncarcă</button>
</div>
}
`,
})
export class UpdateBanner {
private readonly updates = inject(SwUpdate);
protected readonly ready = signal(false);
constructor() {
if (!this.updates.isEnabled) return; // mod dev sau un browser fără 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)); // întrebăm; nu reîncărcăm în mijlocul unui formular
// fișierele versiunii care rulează au dispărut de pe server: singura ieșire e un reload
this.updates.unrecoverable
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(() => document.location.reload());
// un tab care stă deschis zile întregi trebuie și el să verifice din când în când
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(); // versiunea nouă pornește la următoarea încărcare a paginii
}
}ngsw.json și ngsw-worker.js nu au voie să fie ținute mult în cache de CDN sau de server (Cache-Control: no-cache). Dacă CDN-ul păstrează un ngsw.json vechi, worker-ul nu află niciodată că există o versiune nouă, iar utilizatorii rămân pe cea veche cât trăiește cache-ul CDN-ului. Fișierele cu hash în nume, în schimb, pot fi immutable.Scrieri offline: un outbox
Service Worker-ul pune în cache citirile; nu îți păstrează cererile POST pentru mai târziu. Modificările făcute offline merg într-un outbox: sunt salvate local, afișate ca în așteptare și pleacă una câte una, în ordine, când revine conexiunea.
- Notițele salvate apar aici, cu starea sincronizării.
- în așteptare
- 0
- sincronizate
- 0
Trei detalii fac un outbox de încredere. E persistat (aici în localStorage; IndexedDB pentru date mari sau dacă trebuie să-l citească un worker), așa că un reload nu pierde nimic. Trimite câte un element pe rând, așa că serverul primește modificările în ordinea în care au fost făcute. Și fiecare element poartă un Idempotency-Key generat când utilizatorul a salvat, așa că o trimitere reușită pe jumătate nu poate crea un duplicat — aceeași idee ca la actualizările optimiste. La navigator.onLine, doar false e sigur: true înseamnă că există o rețea, nu că ajunge la 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'; // vezi pagina despre actualizări optimiste
import { injectOnline } from './online';
interface Pending {
key: string; // Idempotency-Key, generată O SINGURĂ DATĂ, când utilizatorul salvează
url: string;
body: unknown;
}
const STORAGE = 'outbox';
@Service()
export class Outbox {
private readonly http = inject(HttpClient);
private readonly storage = inject(DOCUMENT).defaultView?.localStorage; // undefined în timpul 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() {
// rezistă la reload (IndexedDB dacă datele sunt mari sau trebuie citite dintr-un worker)
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();
}
/** Câte una, în ordine. Un eșec oprește coada; următorul "online" o reia. */
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 {
// tot offline sau serverul e căzut: elementul rămâne primul la rând
} finally {
this.flushing = false;
}
}
}Când DA, când NU
- Aplicații folosite pe teren sau din mers: inspecții, livrări, vizite de vânzări.
- Conținut pe care oamenii îl recitesc: documentație, articole, cataloage.
- Formulare care nu au voie să piardă date când cade rețeaua.
- Date periculoase când sunt vechi: tranzacționare pe bursă, dispecerat live, informații de urgență.
- Unelte interne folosite mereu pe rețeaua rapidă a biroului: complexitatea în plus aduce puțin.
- Aplicații în care nu poți investi în invalidarea cache-ului și în testare: măcar pune în cache doar shell-ul.
Capcane frecvente
- Un CDN care pune
ngsw.jsonîn cache. Utilizatorii rămân pe versiunea veche mult după deploy. performancepentru date care trebuie să fie proaspete. Un preț de ieri, afișat instant și cu toată încrederea.- Reload automat când e gata o versiune. Utilizatorul pierde formularul pe care îl completa.
- Încredere în
navigator.onLine === true. Și Wi-Fi-ul de hotel cu pagină de login spune „online”. - Un outbox doar în memorie sau fără chei de idempotență. Primul pierde date la reload, al doilea creează duplicate la reîncercare.
Fișă rapidă
ng add @angular/pwa worker + manifest + ngsw-config.json (doar build-uri de producție)
registrationStrategy 'registerWhenStable:30000': după primul render
assetGroups prefetch shell-ul aplicației: index.html, JS, CSS, disponibile offline
assetGroups lazy imagini, fonturi: puse în cache la prima folosire
dataGroups freshness + timeout întâi rețeaua; mai lent decât timeout → răspuns din cache
dataGroups performance întâi cache-ul; refreshAhead reîmprospătează în fundal înainte de maxAge
maxAge limită fermă: intrările mai vechi se aruncă, chiar și offline
SwUpdate.versionUpdates VERSION_READY → întrebi utilizatorul, reîncarci la click
SwUpdate.unrecoverable fișierele versiunii vechi au dispărut → reload
checkForUpdate() periodic, pentru taburi care stau deschise zile întregi
CDN / cache HTTP ngsw.json și ngsw-worker.js: no-cache, niciodată immutable
navigator.onLine false e sigur, true înseamnă doar „există o rețea”
scrieri offline outbox: ordonat, persistat, fiecare cu un Idempotency-Key