PWA Weather: offline ist ein Feature
Eine kleine Wetter-App als Progressive Web App zeigt, warum Offline-Verhalten eine Produktentscheidung ist und wie ehrlich eine Oberfläche mit veralteten Daten umgehen sollte.
U-Bahn zwischen zwei Stationen, kein Empfang. Die Wetter-App öffnet sich, zeigt kurz einen Ladekreisel und dann: nichts. Ein weißer Bildschirm. Dabei hätte die Wetterlage von heute Morgen völlig gereicht – die Frage war, ob der Schirm mit muss, nicht ob es in dieser Minute 21,3 oder 21,4 Grad hat. Die App hatte diese Daten sogar schon einmal geladen. Sie hat sie nur weggeworfen, weil niemand entschieden hat, dass sie ohne Netz etwas wert sind.
Dieser weiße Bildschirm ist kein technischer Defekt. Er ist das Ergebnis einer Nicht-Entscheidung. Irgendwo im Backlog stand vermutlich „Offline-Support“ als technisches Nice-to-have, hinter dem nächsten Feature und dem übernächsten Redesign. Genau das halte ich für den Denkfehler: Offline ist kein Add-on, das man später an eine fertige App schraubt. Offline ist ein Feature mit Produktfragen – was soll ohne Netz funktionieren, was ausdrücklich nicht, und wie ehrlich zeigt die Oberfläche den Zustand der Daten an.
Ein kleines Demo als Prüfstand
Um das für mich selbst durchzuspielen, habe ich im April dieses Jahres ein kleines Repository angelegt: pwa-weather-demo. Es ist bewusst kein Produkt, sondern eine Nachbau-Übung entlang von Googles Tutorial „Your First Progressive Web App“ – eine Wetterkarte pro Stadt, eine öffentliche Wetter-API dahinter, ein Service Worker davor. Lokal startet das Ganze mit einem simplen http-server, mehr Infrastruktur braucht es nicht. Der Wert liegt nicht im Funktionsumfang, sondern darin, dass man an einem überschaubaren Beispiel jede Entscheidung einzeln anfassen kann.
Und Entscheidungen gibt es mehr, als der Begriff „Caching“ vermuten lässt. Beim Durcharbeiten ist mir aufgefallen, dass die eigentliche Arbeit nicht im Service-Worker-Code steckt. Der ist nach einem Nachmittag geschrieben. Die Arbeit steckt in der Frage, welche Antwort die App einem Menschen ohne Netzverbindung schuldet.
Offline ist eine Produktfrage, keine Cache-Option
Für die Wetter-App sieht meine Antwort so aus:
- Die zuletzt geladene Wetterlage der gemerkten Städte wird angezeigt – immer, sofort, auch ohne Netz.
- Die Oberfläche selbst (Layout, Icons, Schrift, Logik) lädt vollständig aus dem lokalen Cache.
- Die Suche nach neuen Orten funktioniert offline nicht – und sagt das auch, statt einen Spinner drehen zu lassen.
- Jede angezeigte Wetterkarte trägt einen Zeitstempel: „Stand: vor 2 Stunden“ statt stillschweigend alter Daten.
Der dritte Punkt ist mir wichtig. Es wäre technisch möglich, auch Suchergebnisse zu cachen und offline irgendetwas anzuzeigen. Aber eine Ortssuche gegen einen zwei Tage alten Cache liefert keine Suche, sondern eine Attrappe. Nicht alles, was cachebar ist, gehört gecacht. Die Grenze verläuft entlang der Frage, ob eine veraltete Antwort noch eine ehrliche Antwort ist: Die Wetterlage von heute Morgen ist eine ehrliche, klar datierte Information. Ein Suchergebnis von vorgestern ist es nicht.
Der vierte Punkt ist die zweite Hälfte derselben Entscheidung. Wer alte Daten anzeigt, ohne sie als alt zu kennzeichnen, lügt per Interface. Ein kleiner Zeitstempel verwandelt denselben Datensatz von einer potenziellen Falschinformation in eine brauchbare Auskunft. Das kostet eine Zeile UI und ein Datum – und es ist der Unterschied zwischen „die App zeigt irgendwas“ und „die App sagt mir, was sie weiß und wie sicher sie sich ist“.
Der Service Worker: ein Proxy mit Lebenszyklus
Technisch hängt das alles am Service Worker – einem Skript, das der Browser getrennt von der Seite ausführt und das als programmierbarer Proxy zwischen App und Netzwerk sitzt. Jede Anfrage der Seite läuft durch seinen fetch-Handler, und dort entscheidet Code statt Zufall, ob eine Antwort aus dem Netz, aus dem Cache oder aus einer Kombination von beidem kommt.
Wer zum ersten Mal damit arbeitet, stolpert weniger über die API als über den Lebenszyklus. Ein Service Worker wird registriert, installiert, wartet gegebenenfalls auf das Ende der alten Version und übernimmt erst dann die Kontrolle über die Seite:
flowchart LR A[register] --> B[install<br/>App-Shell precachen] B --> C[waiting<br/>alte Version läuft noch] C --> D[activate<br/>alte Caches aufräumen] D --> E[fetch<br/>Requests kontrollieren]
Die Registrierung selbst ist unspektakulär und gehört ans Ende des App-Starts:
// app.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/service-worker.js')
.then(function (registration) {
console.log('service worker registered, scope:', registration.scope);
})
.catch(function (error) {
console.error('service worker registration failed', error);
});
}
Das if ist keine Formsache, sondern Teil der Architektur: In Browsern ohne Service Worker läuft die App einfach als normale Website weiter. Dazu später mehr, denn dieser Fall ist 2017 alles andere als exotisch.
Zwei Eigenheiten des Lebenszyklus haben mich im Demo tatsächlich Zeit gekostet. Erstens: Ein aktualisierter Service Worker übernimmt standardmäßig erst, wenn alle Tabs der alten Version geschlossen sind – wer beim Entwickeln nur neu lädt und sich wundert, warum der neue Code nicht greift, kämpft gegen waiting, nicht gegen einen Bug. Zweitens: Der activate-Schritt ist der richtige Ort, um alte Cache-Versionen zu löschen. Wer das auslässt, sammelt bei jedem Deployment einen weiteren Cache an und wundert sich Monate später über den Speicherverbrauch.
App-Shell: das Gerüst kommt immer aus dem Cache
Die erste Cache-Entscheidung betrifft die App-Shell – also alles, was die Anwendung zum Anzeigen braucht, bevor auch nur ein Byte Wetterdaten da ist: HTML, CSS, JavaScript, Icons. Diese Dateien ändern sich nur mit einem Deployment, deshalb werden sie beim install einmal komplett in den Cache gelegt (Precache) und danach immer von dort ausgeliefert:
// service-worker.js
var SHELL_CACHE = 'weather-shell-v1';
var DATA_CACHE = 'weather-data-v1';
var SHELL_FILES = [
'/',
'/index.html',
'/styles/app.css',
'/scripts/app.js',
'/images/icons/icon-192.png'
];
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open(SHELL_CACHE).then(function (cache) {
return cache.addAll(SHELL_FILES);
})
);
});
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
keys
.filter(function (key) {
return key !== SHELL_CACHE && key !== DATA_CACHE;
})
.map(function (key) {
return caches.delete(key);
})
);
})
);
});
Die Versionsnummer im Cache-Namen ist das Deployment-Konzept in seiner einfachsten Form: neue Shell, neuer Name, alte Caches fliegen beim activate raus. Werkzeuge wie sw-precache generieren diesen Teil aus dem Build heraus, und mit Workbox ist gerade ein frischer Nachfolger erschienen, der die gängigen Strategien als Bibliothek mitbringt. Für das Demo habe ich es bewusst von Hand geschrieben – nicht aus Prinzip, sondern weil man eine Strategie erst dann guten Gewissens einem Generator überlassen sollte, wenn man sie einmal selbst formuliert hat.
Zwei Datenarten, zwei Strategien
Die interessantere Entscheidung fällt im fetch-Handler, denn App-Shell und Wetterdaten brauchen entgegengesetzte Strategien. Die Shell soll so schnell wie möglich kommen und darf ruhig einen Deployment-Zyklus alt sein: Cache zuerst. Die Wetterdaten sollen so frisch wie möglich sein, und der Cache ist nur das Sicherheitsnetz für den U-Bahn-Moment: Netzwerk zuerst, mit der letzten bekannten Antwort als Rückfallebene.
flowchart TD
R[fetch-Event] --> Q{Pfad beginnt mit<br/>/api/weather?}
Q -- ja --> N[Netzwerk zuerst]
N -- Antwort ok --> C1[Kopie in weather-data-v1<br/>Antwort an die App]
N -- kein Netz --> C2[letzte bekannte Antwort<br/>aus dem Cache]
Q -- nein --> S[Cache zuerst:<br/>App-Shell]
S -- Treffer --> C3[sofort ausliefern]
S -- kein Treffer --> C4[ans Netzwerk durchreichen]
Im Code sieht diese Weiche so aus:
self.addEventListener('fetch', function (event) {
var url = new URL(event.request.url);
if (url.pathname.indexOf('/api/weather') === 0) {
// network first, cached data as offline fallback
event.respondWith(
fetch(event.request)
.then(function (response) {
var copy = response.clone();
caches.open(DATA_CACHE).then(function (cache) {
cache.put(event.request, copy);
});
return response;
})
.catch(function () {
return caches.match(event.request);
})
);
return;
}
// app shell: cache first, network as fallback
event.respondWith(
caches.match(event.request).then(function (cached) {
return cached || fetch(event.request);
})
);
});
Eine Alternative für die Datenroute wäre Stale-while-revalidate: sofort die gecachte Antwort ausliefern und parallel im Hintergrund die frische holen, die dann beim nächsten Rendern bereitsteht. Für eine Wetter-App ist das eine legitime Wahl – die Anzeige erscheint augenblicklich, und die Aktualisierung trudelt nach. Ich habe mich im Demo trotzdem für Network-first entschieden, weil bei Wetterdaten die frische Antwort den Vorrang verdient, solange das Netz da ist. Der Punkt ist nicht, welche der beiden Strategien „richtig“ ist. Der Punkt ist, dass es eine Wahl mit sichtbaren Konsequenzen ist – und dass sie pro Route getroffen wird, nicht pauschal für die ganze App. Jake Archibalds Offline Cookbook buchstabiert diese Muster einzeln durch und ist dafür seit Jahren meine Referenz.
Der Datenstand gehört in die Oberfläche
Damit aus „alte Daten anzeigen“ kein stilles Unterschieben wird, muss die App wissen, wie alt eine Antwort ist. Praktischerweise steckt diese Information bereits in der gecachten Response – der Date-Header der ursprünglichen Server-Antwort wird mitgespeichert:
// show data age when rendering a cached response
caches.match('/api/weather?city=berlin').then(function (cached) {
if (!cached) {
return;
}
var fetchedAt = new Date(cached.headers.get('date'));
var ageInMinutes = Math.round((Date.now() - fetchedAt.getTime()) / 60000);
showLastUpdated(ageInMinutes); // "Stand: vor 120 Minuten"
});
Das sind zehn Zeilen, und sie verändern den Charakter der App. Aus „warum zeigt die mir Sonnenschein, draußen regnet es“ wird „ah, das ist der Stand von heute Morgen“. In einem Projekt-Review würde ich diese zehn Zeilen höher gewichten als die gesamte Cache-Logik darunter – die Cache-Logik entscheidet, ob etwas angezeigt wird; diese Zeilen entscheiden, ob man dem Angezeigten trauen kann.
Manifest, HTTPS und der Home-Bildschirm
Zur PWA gehören neben dem Service Worker noch zwei Bausteine. Das Web App Manifest beschreibt, wie sich die App verhält, wenn sie auf dem Home-Bildschirm landet:
{
"name": "PWA Weather",
"short_name": "Weather",
"start_url": "/index.html",
"display": "standalone",
"background_color": "#3f51b5",
"theme_color": "#3f51b5",
"icons": [
{
"src": "images/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}
Chrome auf Android bietet bei Manifest plus registriertem Service Worker plus wiederholter Nutzung von sich aus „Zum Startbildschirm hinzufügen“ an – die App startet dann ohne Browser-Chrome, mit eigenem Icon, und fühlt sich erstaunlich nach installierter Anwendung an. Ob eine Seite die Kriterien erfüllt, prüft Lighthouse; der PWA-Audit meckert zuverlässig über fehlende Icons, fehlendes Offline-Verhalten der Startroute oder fehlendes HTTPS.
HTTPS ist dabei keine Empfehlung, sondern Bedingung: Service Worker registrieren Browser ausschließlich über sichere Verbindungen (mit localhost als Entwicklungs-Ausnahme). Das ist auch gut so – ein Skript, das sämtliche Requests einer Domain abfangen und beantworten darf, will man nicht über eine manipulierbare Verbindung ausliefern.
Die ehrliche Grenze: Safari
Jetzt der Teil, der in vielen PWA-Artikeln dieses Jahres auffällig leise vorkommt: Safari hat keinen Service Worker. Weder auf dem Mac noch – und das wiegt schwerer – auf iOS. Chrome, Firefox und Opera unterstützen die API, bei Microsoft Edge ist sie in Arbeit, und im WebKit-Statusboard ist der Eintrag kürzlich immerhin auf „In Development“ gewandert. Aber Stand heute, August 2017, ist eine PWA auf einem iPhone schlicht eine normale Website: kein Offline-Cache, kein Abfangen von Requests, kein Install-Banner.
Das ist kein Grund, die Sache zu lassen – aber es gehört zwingend in die Produktentscheidung, nicht in die Fußnote. Für das Wetter-Demo heißt das konkret: Die App muss ohne Service Worker vollständig funktionieren, nur eben ohne Offline-Komfort. Der Service Worker ist eine Verbesserungsschicht (Progressive Enhancement im ursprünglichen Wortsinn), keine Voraussetzung. Wer seine App so baut, dass sie ohne Service Worker bricht, hat auf iOS gar nichts. Wer sie so baut, dass der Service Worker Verhalten hinzufügt statt trägt, hat auf iOS eine ordentliche Website und auf Android eine App, die den U-Bahn-Moment übersteht.
Je nach Zielgruppe verschiebt das die Rechnung erheblich. Bei einem Publikum mit hohem iOS-Anteil kauft man sich mit dem PWA-Aufwand heute vor allem bessere Ladezeiten über den Shell-Cache in Chrome und Firefox – auch nicht nichts, aber ein anderes Versprechen als „funktioniert offline“. Diese Rechnung sollte im Team offen auf dem Tisch liegen, bevor jemand „offline-fähig“ auf eine Folie schreibt.
Unterm Strich
Die Technik hinter dem Demo ist überschaubar: ein Lebenszyklus, zwei Cache-Strategien, ein Manifest, HTTPS. Was hängen bleibt, ist etwas anderes. Der weiße Bildschirm in der U-Bahn entsteht nicht, weil Caching schwer wäre – er entsteht, weil „offline“ nie als Feature behandelt wurde, mit denselben Fragen wie jedes andere Feature: Was genau soll es können? Was bewusst nicht? Woran erkennt der Mensch davor, was gerade passiert?
Meine Antworten für die Wetter-App – letzte bekannte Lage ja, Ortssuche nein, Zeitstempel immer – sind nicht universell. Eine Banking-App zieht die Grenze anders, ein Nachrichtenangebot wieder anders. Universell ist nur, dass diese Grenze gezogen werden muss, bevor der erste fetch-Handler entsteht. Der Service Worker führt eine Produktentscheidung aus. Er ersetzt sie nicht.
Weiterführende Quellen
- Repository: MikeBild/pwa-weather-demo
- Jake Archibald: The Offline Cookbook
- MDN: Service Worker API
- MDN: Web App Manifest
Kommentare