PWA promises a lot, and that breeds myths. A Progressive Web App is not a framework and not a checkbox in your bundler that instantly makes an app installable and network-independent. It is a set of browser capabilities and an approach to using them. Underneath sits an ordinary web application: the same URLs, HTTP, semantics, accessibility, progressive enhancement. A PWA does not cancel that; it builds on top.
The value comes down to three properties. Reliability - the main user path does not fall apart on a bad network. Installability - the app lives in a launcher and opens in its own window. Integration - access to OS icons, shortcuts, and notifications. Different mechanisms are responsible: the Web App Manifest for OS integration, the Service Worker for a programmable network layer and background events, Cache Storage and IndexedDB for local data. None of them on its own makes a product offline-first.
It is natural to assume the opposite: add a manifest, register a service worker, and the app seemingly becomes offline-capable. Tooling reinforces the illusion: a plugin generates the worker, Lighthouse awards a badge, DevTools shows the registration. But a worker that merely exists guarantees nothing. It affects reliability only when it deliberately intercepts the requests that matter and serves them from cache. An empty worker is decoration, not fault tolerance.
Everything breaks on a wrong model of the network. The network is an external service with variable latency, not an on/off switch. Hence the main trap - navigator.onLine. The property only says the browser has some connection. It does not prove that DNS resolved, that TLS was established, that you are not behind a captive portal, and that your API responds. A device in the subway is formally online, yet every request dies on timeout.
It is more useful to keep in mind what each signal actually proves - and, more importantly, what it does not.
| Signal | What it proves | What it does not prove |
|---|---|---|
| navigator.onLine | The browser has some network connection | That DNS, TLS, captive portal, and your API work |
| fetch('/health') |
| A specific endpoint responded just now |
| That the next request will go through |
| Timeout / error | The operation is not confirmed by the client | That the server definitely did not perform the mutation |
|---|
Hence offline-first as a failure model. The interface works with local state first, and the network serves to deliver and reconcile changes. This is not strict local-first: the server stays the source of truth, but its unavailability does not block every action. Take the Offline Notes app, our example throughout the book. A user writes a note on a train with no signal. Right: it is saved locally at once, marked as pending, and sent when the network returns. Wrong: a spinner and an error.
For this to work, failure is designed, not caught after the fact. It helps to plan four modes: online - the network responds normally; degraded - slowly and unreliably; offline - not at all; reconnecting - the connection has just returned. And state is shown per operation, not globally. A single green dot in the header is useless: it says nothing about a specific note. You need the record's status - saved locally, pending, synced, conflict.
The difference is not cosmetic. Suppose Offline Notes trusts navigator.onLine and, seeing true, fires a POST at once with no local record. A user in a weak-signal area taps save, the request times out - and the text is lost, there was no durable copy. In offline-first the note is already in storage, sending is deferred, the user notices nothing. Offline is not needed by every product, but where data is created on the move, it is the difference between a tool and a demo. Share Target, shortcuts, and notifications are only an addition on top of a reliable core, not a replacement.