AngularAtlased. 22.2

Ch. 15Real time & offline67 / 75

Offline-first: Service Worker & caching

The Angular Service Worker: caching strategies, update prompts and an outbox for changes made offline.

stable ~0 min read Official docs

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).

Why it matters
Offline isn't a rare case: lifts, trains, hotel Wi-Fi, one bar of signal. And a slow network is worse than none: the request doesn't fail, it just hangs. A timeout that falls back to the cache turns a 30-second spinner into an answer in three.

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"
      }
    }
  ]
}
LiveThe same request: three strategies, three networks
Strategy
Network
In the cache
  1. 0 ms request sent to the network
  2. 3000 ms timeout after 3 s → answered from the cache
  3. 6000 ms the network answers late: the cache is updated for next time
content after
3000 ms
source
cache
data age
10 minutes
Choose a slow network: without a worker you wait six seconds, with freshness three, with performance a few milliseconds. Then go offline with an empty cache.

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
  }
}
The cache in front of the cache
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.

LiveNotes saved offline
online your real connection: online
  • Saved notes appear here, with their sync status.
waiting
0
synced
0
Go offline, save two notes, then come back online: they leave in order. The demo also reacts to your real connection — turn Wi-Fi off and on.

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

When YES
  • 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.
When NO
  • 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.
  • performance for 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

Ch. 15 · 0 of 3 complete

Finished this lesson?

Mark it and it shows up on your map: in the contents and on the home page.

Marks itself as done once you have read the whole lesson.

  1. WebSockets: real time that survives the network
  2. Optimistic updates & rollback
  3. Offline-first: Service Worker & caching
See your whole map

All 75 pages · type to filter

  • 01What's new in Angular 21 & 22All the important changes in v21 and v22, briefly, with a link to the page that explains each one.Start
  • 01Your first app, step by stepA reading list built from an empty component to a finished app — signals, @for, inputs and outputs, a service and a form, one step at a time.Start
  • 01Quick decisions“Where do I put this?”, “Signal or Observable?”, “Shared or feature?” — short answers to everyday questions.Start
  • 01Angular interview questions60 real interview questions for junior, mid and senior, with the answer to give, the follow-up and the red flags. Plus a mock interview.Start
  • 02Standalone & bootstrapHow an app starts without NgModule: bootstrapApplication, app.config.ts, imports in the component.Modern fundamentals
  • 02Data binding & eventsInterpolation, property and attribute bindings, events with $event and key filters, and how [( )] is built — what goes into the DOM and what comes back.Modern fundamentals
  • 02Control flow: @if, @for, @switchThe block syntax that replaces *ngIf/*ngFor/ngSwitch, with track, @empty and exhaustive checks.Modern fundamentals
  • 02@let and the new template expressionsLocal variables with @let, arrow functions, spread, regex, instanceof, comments inside tags.Modern fundamentals
  • 02@defer — lazy loading in templatesLoad heavy components only when needed: triggers, prefetch, placeholder, loading, error.Modern fundamentals
  • 02Class & style bindingsWhy [class.x] and [style.x] replaced ngClass and ngStyle (with automatic migration from v21).Modern fundamentals
  • 02Component styles & encapsulation:host, the three encapsulation modes, theming with CSS variables, and why ::ng-deep is a last resort.Modern fundamentals
  • 02Pipes: when and howPure vs impure pipes, custom pipes, and how you choose between a pipe, computed() and a method.Modern fundamentals
  • 03signal() and computed()The reactive primitives: writable state, derived state, equality, untracked and immutability rules.Signals & reactivity
  • 03effect() — when YES, when NOSide effects synchronised with signals: localStorage, logging, imperative APIs. And their traps.Signals & reactivity
  • 03linkedSignal() — editable derived stateA signal that resets when its source changes, but that you can also set manually.Signals & reactivity
  • 03resource, rxResource, httpResourceAsync data as signals: status, value, error, reload, automatic cancellation. Stable since v22.Signals & reactivity
  • 03debounced() — delayed signalsThe “debounce” version of a signal, without RxJS. Perfect for search.Signals & reactivity
  • 03Lists: search, filter, sort, paginateA chain of computed signals that recalculates only what changed, with the filters kept in the URL so a link reopens the same view.Signals & reactivity
  • 03Observables from zeroWhat an Observable is, subscribe and unsubscribe, the operators you use every week, cold vs hot — and when a signal is enough.Signals & reactivity
  • 03Signals ↔ RxJStoSignal, toObservable, takeUntilDestroyed, outputFromObservable — and when RxJS remains the right choice.Signals & reactivity
  • 04input() and output()Inputs as signals (required, transform, alias) and typed outputs. Data down, events up.Components: communication
  • 04model() — two-way bindingBanana-in-a-box [( )] with a single API. When to use it, and when input + output is better.Components: communication
  • 04viewChild & contentChildQueries as signals: viewChild, viewChildren, contentChild, contentChildren, the read option.Components: communication
  • 04Lifecycle & DestroyRefWhich hooks still matter in the signals era, afterNextRender, DestroyRef and typed ngOnChanges.Components: communication
  • 04Content projectionng-content with select, multiple slots, default content and the trap of conditional projection.Components: communication
  • 04ng-template & ngTemplateOutletTemplates as values: render props in Angular, typed context and customising components.Components: communication
  • 05Smart vs DumbContainer vs presentational: who holds the state, who only displays, and how to split them properly.Component design
  • 05Generic components <T>A correctly typed DataTable<T> or Select<T>, with templates for cells.Component design
  • 05Dynamic componentsNgComponentOutlet, createComponent with inputBinding/outputBinding, and a dashboard built from config.Component design
  • 05Directives & host compositionAttribute directives, the host: {} metadata and the Directive Composition API (hostDirectives).Component design
  • 05Custom form controlsFormValueControl (Signal Forms) vs ControlValueAccessor: a star rating, in both variants.Component design
  • 05Change detectionOnPush is the default from v22, zone.js is gone from v21. What triggers rendering and how not to get burned.Component design
  • 06inject() & @Service()The new @Service() decorator from v22, inject() vs the constructor, and the injection context.Dependency Injection
  • 06Providers & the injector hierarchyRoot vs route vs component, useClass/useValue/useFactory/useExisting, and the optional/self/skipSelf modifiers.Dependency Injection
  • 06InjectionToken & provideX()Typed configuration, provideFeature() functions, makeEnvironmentProviders and initializers.Dependency Injection
  • 06injectAsync() — lazy servicesLoad a heavy service only when you need it, with prefetch on idle.Dependency Injection
  • 07Folder structurecore / shared / features, what goes into each, and what a feature looks like inside.Application architecture
  • 07Shared vs featureWhen to move a component into shared and (especially) when NOT to. A concrete checklist.Application architecture
  • 07Interfaces & modelsInline interface or separate file? DTO vs domain model, type vs interface, where to put them.Application architecture
  • 07State management with signalsSimple signal stores, facades, local vs global state, and when you need NgRx.Application architecture
  • 07Style guide & namingThe current official conventions: file names without suffixes, protected, readonly, member order.Application architecture
  • 07Barrels, aliases & boundariesindex.ts, path aliases, a feature’s public API and dependency rules that can be checked automatically.Application architecture
  • 07Errors: @boundary & ErrorHandlerThe new @boundary block from v22.2, the global ErrorHandler, HTTP errors and errors from resource.Application architecture
  • 08Modern routing & lazy loadingloadComponent, child routes, parameters as inputs and parameter inheritance (the new default in v22).Routing
  • 08Guards & resolverscanActivate, canMatch, canDeactivate and resolve as functions, with redirects via UrlTree / RedirectCommand.Routing
  • 09Signal Forms — the basicsForms built on signals: form(), [formField], schema-based validation. Stable since v22.Forms
  • 09Signal Forms — advancedCustom and cross-field validation, async/HTTP with debounce, dynamic lists, disabled/hidden, reusable schemas.Forms
  • 09Typed Reactive FormsTyped FormGroup, nonNullable, FormArray — and how they coexist with Signal Forms.Forms
  • 10HttpClient, interceptors & the API layerHttpClient without setup (v21), Fetch by default (v22), functional interceptors and an API layer with DTO mapping.HTTP & data
  • 11Performance — a checklistWhat really matters: OnPush + signals, track, @defer, lazy routes, images, bundles and hydration.Quality & performance
  • 11Testing with VitestVitest is the default runner from v21. TestBed, components with signals, services, HTTP and harnesses.Quality & performance
  • 11DevTools: look inside your appChrome DevTools and Angular DevTools step by step: what each panel shows, with exercises that work on this site.Quality & performance
  • 11Angular AriaHeadless, accessible components that you style however you like: tabs, accordion, menu, listbox...Quality & performance
  • 12SSR, prerender & render modesWhen to render on the server, when at build time and when only in the browser: RenderMode per route, app.routes.server.ts and the real costs.SSR & hydration
  • 12Hydration: event replay & incrementalHow the browser takes over server HTML without re-rendering it: full hydration, event replay, @defer (hydrate on …) and mismatch errors.SSR & hydration
  • 12Server-compatible codewindow doesn’t exist on the server: PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, the HTTP cache and PendingTasks.SSR & hydration
  • 13Micro frontendsWhen it is worth splitting an app into several deployments and when it is just complexity: Native Federation, Module Federation, iframes, Web Components.Architecture at scale
  • 13Web Components with Angular ElementscreateCustomElement: an Angular component used as an HTML tag anywhere, plus communication between apps through events and BroadcastChannel.Architecture at scale
  • 13Monorepo, libraries & boundariesA workspace with several apps and libraries: ng generate library, Nx, secondary entry points, publishing and dependency rules.Architecture at scale
  • 13Hexagonal architecture (ports & adapters)A domain without Angular, ports as abstract classes and interchangeable adapters: HTTP, localStorage or in-memory, without touching the UI.Architecture at scale
  • 14Signals under the hoodThe reactive graph: producers and consumers, push-pull, why computed is lazy and glitch-free, the diamond problem and when effects run.Advanced reactivity
  • 14Advanced RxJS: higher-order operatorsswitchMap, mergeMap, concatMap and exhaustMap on a live diagram, plus retry with backoff, shareReplay and how to avoid leaks.Advanced reactivity
  • 14NgRx SignalStoreLarge-scale state management on signals: withState, withComputed, withMethods, rxMethod, entities and hooks — and when a simple store is enough.Advanced reactivity
  • 14State machines with signalsNo more impossible states: a typed machine on signals, with transitions, guards and a live state diagram.Advanced reactivity
  • 15WebSockets: real time that survives the networkA typed socket as signals: reconnect with backoff and jitter, heartbeat, an outbox while offline, and SSR safety.Real time & offline
  • 15Optimistic updates & rollbackAn instant UI that stays honest: snapshot and rollback, out-of-order responses, idempotency keys.Real time & offline
  • 15Offline-first: Service Worker & cachingThe Angular Service Worker: caching strategies, update prompts and an outbox for changes made offline.Real time & offline
  • 16Web WorkersHeavy computation without blocking the UI: a real worker in the page, message passing and when it is not worth it.Advanced UI & performance
  • 16Huge lists: virtual scroll100,000 rows without lag: CDK virtual scroll, itemSize, trackBy and the alternatives (pagination, infinite scroll).Advanced UI & performance
  • 16Modern animations & View Transitionsanimate.enter and animate.leave instead of the deprecated @angular/animations, CSS transitions and View Transitions on navigation.Advanced UI & performance
  • 16Memory leaks: finding and fixing themSubscriptions, timers and listeners that outlive their component — how to spot them in DevTools and close them.Advanced UI & performance
  • 17Security: XSS, sanitisation & CSPWhat Angular does automatically against XSS, when bypassSecurityTrust becomes dangerous, plus CSP with nonce, Trusted Types and XSRF.Security & globalisation
  • 17Authentication: tokens & refreshAn interceptor that attaches the token, refreshes once on 401 and replays the queued requests — plus where you keep the token.Security & globalisation
  • 17OAuth 2.1 & OpenID Connect with PKCEThe authorization code flow with PKCE, step by step: verifier and challenge, state and nonce, the ID token, and why a BFF is safer.Security & globalisation
  • 17Internationalisation (i18n)$localize, the i18n attribute, ICU plural/select, a build per language vs runtime translations, and locale-based formatting.Security & globalisation