Zum Inhalt springen

Progressive Web Apps: Offline-fähige Web-Erlebnisse

Praktischer Leitfaden zu Progressive Web Apps mit Service Workern, Caching-Strategien und Installierbarkeit, die offline zuverlässig funktionieren.

4 Min. Lesezeit
Mobilgerät, das eine Progressive Web App mit Offline-Anzeige zeigt

Progressive Web Apps sind kein Framework und keine Bibliothek. Sie sind eine Reihe von Fähigkeiten – Offline-Unterstützung, Installierbarkeit, Hintergrundsynchronisierung –, die Webanwendungen wie native Apps wirken lassen. Die Technologie ist seit Jahren stabil, doch die Verbreitung bleibt gering, weil Entwickler PWA als Alles-oder-nichts-Entscheidung behandeln statt als schrittweises Upgrade.

Man muss nicht sofort vollständig offline-first arbeiten. Schon ein Service Worker, der die App-Shell cached und Netzwerkfehler sauber abfängt, bringt einen an 90 % aller Webanwendungen vorbei.

Der Lebenszyklus des Service Workers

Ein Service Worker ist eine JavaScript-Datei, die in einem eigenen Thread läuft und Netzwerkanfragen zwischen der App und dem Server abfängt. Wer seinen Lebenszyklus versteht, vermeidet die häufigsten PWA-Fehler.

jsjavascript
// sw.js
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/offline.html',
];
 
// Install: pre-cache critical assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(STATIC_ASSETS);
    })
  );
  self.skipWaiting();
});
 
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys
          .filter((key) => key !== CACHE_NAME)
          .map((key) => caches.delete(key))
      );
    })
  );
  self.clients.claim();
});

Das Ereignis install wird einmalig ausgelöst, wenn der Service Worker zum ersten Mal registriert wird. Das Ereignis activate wird ausgelöst, nachdem der alte Service Worker entfernt wurde. Zwischen diesen beiden Ereignissen wartet der neue Worker – deshalb sehen Nutzer manchmal veraltete Inhalte, bis sie alle Tabs schließen.

skipWaiting() und clients.claim() erzwingen die sofortige Aktivierung. Setze sie für Cache-Updates ein, die nichts kaputt machen. Verzichte darauf, wenn sich die Cache-Struktur wesentlich ändert – sonst lieferst du unter Umständen einen neuen HTML-Shell mit Verweisen auf alte, zwischengespeicherte Ressourcen aus.

Caching-Strategien

Unterschiedliche Ressourcen brauchen unterschiedliche Caching-Strategien. Statische Assets ändern sich kaum. API-Antworten ändern sich ständig. Wendet man auf eines der beiden die falsche Strategie an, entstehen Probleme.

Cache First (statische Assets)

jsjavascript
// Best for: CSS, JS bundles, images, fonts
self.addEventListener('fetch', (event) => {
  if (event.request.destination === 'style' ||
      event.request.destination === 'script' ||
      event.request.destination === 'image') {
    event.respondWith(
      caches.match(event.request).then((cached) => {
        return cached || fetch(event.request).then((response) => {
          const clone = response.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, clone);
          });
          return response;
        });
      })
    );
  }
});

Network First (API-Daten)

jsjavascript
// Best for: API responses, user-specific data
async function networkFirst(request) {
  const cache = await caches.open('api-cache');
 
  try {
    const networkResponse = await fetch(request);
    // Only cache successful responses
    if (networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    const cachedResponse = await cache.match(request);
    if (cachedResponse) return cachedResponse;
    // Return a meaningful offline response
    return new Response(
      JSON.stringify({ error: 'offline', cached: false }),
      { headers: { 'Content-Type': 'application/json' } }
    );
  }
}

Stale-While-Revalidate (halbdynamische Inhalte)

jsjavascript
// Best for: blog posts, product listings, non-critical API data
async function staleWhileRevalidate(request) {
  const cache = await caches.open('content-cache');
  const cachedResponse = await cache.match(request);
 
  const fetchPromise = fetch(request).then((networkResponse) => {
    if (networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  });
 
  // Return cached immediately, update in background
  return cachedResponse || fetchPromise;
}

Das ist oft die beste Standardstrategie: Nutzer erhalten sofort eine Antwort aus dem Cache, während der Service Worker die zwischengespeicherte Version im Hintergrund für das nächste Mal aktualisiert.

Das Web App Manifest

Das Manifest macht die App installierbar. Ohne es zeigen Browser den Hinweis „Zum Startbildschirm hinzufügen" nicht an.

jsonjson
{
  "name": "My Application",
  "short_name": "MyApp",
  "description": "A fast, offline-capable web application",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#1a1a2e",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-maskable.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

Der Modus display: "standalone" blendet die Browser-Oberfläche aus, sodass die App nativ wirkt. Binde sowohl reguläre als auch maskable Icons ein – adaptive Android-Icons benötigen die maskable-Variante, um korrekt dargestellt zu werden.

Umgang mit dem Offline-Zustand

Die schlechteste Offline-Erfahrung ist gar keine Erfahrung – eine leere Seite oder ein Browserfehler. Die zweitschlechteste ist, so zu tun, als würde alles funktionieren, während Nutzeraktionen still und leise verworfen werden.

tstypescript
// ❌ Ignores network state — user thinks action succeeded
async function submitForm(data: FormData) {
  await fetch('/api/submit', { method: 'POST', body: data });
  showSuccess('Submitted!');
}
 
// ✅ Queues offline actions and provides honest feedback
async function submitForm(data: FormData) {
  if (!navigator.onLine) {
    await saveToOutbox(data);
    showInfo('Saved offline. Will submit when connection returns.');
    return;
  }
 
  try {
    await fetch('/api/submit', { method: 'POST', body: data });
    showSuccess('Submitted!');
  } catch {
    await saveToOutbox(data);
    showInfo('Network error. Queued for retry.');
  }
}

IndexedDB bietet zuverlässigen Offline-Speicher für in die Warteschlange gestellte Aktionen:

tstypescript
async function saveToOutbox(data: FormData) {
  const db = await openDB('app-db', 1, {
    upgrade(db) {
      db.createObjectStore('outbox', {
        keyPath: 'id',
        autoIncrement: true,
      });
    },
  });
 
  const serialized = Object.fromEntries(data.entries());
  await db.add('outbox', {
    url: '/api/submit',
    body: serialized,
    timestamp: Date.now(),
  });
}

Background Sync

Mit der Background Sync API kann der Service Worker fehlgeschlagene Anfragen wiederholen, sobald wieder eine Verbindung besteht – selbst wenn der Nutzer den Tab bereits geschlossen hat.

jsjavascript
// In your app code — register a sync
async function requestBackgroundSync() {
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('outbox-sync');
}
 
// In sw.js — handle the sync event
self.addEventListener('sync', (event) => {
  if (event.tag === 'outbox-sync') {
    event.waitUntil(processOutbox());
  }
});
 
async function processOutbox() {
  const db = await openDB('app-db', 1);
  const items = await db.getAll('outbox');
 
  for (const item of items) {
    try {
      await fetch(item.url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(item.body),
      });
      await db.delete('outbox', item.id);
    } catch {
      // Will retry on next sync event
      break;
    }
  }
}

Background Sync wird von Chromium-Browsern gut unterstützt. Für Safari und Firefox weicht man darauf aus, die Verbindung beim Laden der Seite zu prüfen und die Outbox dann zu verarbeiten.

Die wichtigsten Erkenntnisse

  1. Fang mit einem einfachen Service Worker an – das Cachen der App-Shell und ein sauberer Umgang mit dem Offline-Fall decken die meisten Anwendungsfälle ab
  2. Passe die Caching-Strategie an den Inhaltstyp an – Cache First für statische Assets, Network First für API-Daten, Stale-While-Revalidate für alles dazwischen
  3. Versioniere deine Caches – das Aufräumen alter Caches im activate-Ereignis verhindert, dass veraltete Assets ausgeliefert werden
  4. Sei ehrlich beim Offline-Zustand – stelle Aktionen in eine Warteschlange und informiere Nutzer darüber, statt stillschweigend zu scheitern
  5. Background Sync rundet Offline-Workflows ab – lass den Service Worker es erneut versuchen, sobald wieder eine Verbindung besteht
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX