The naive offline UI is a banner 'you are offline' on top of the usual interface. The problem is that offline is not a global mode of the app but a property of individual data and actions: one note is already in the local cache, another has not synced yet, a third cannot run at all without the network. What you design is the states of data and actions, not a banner: what happens to a specific note, a specific submission, a specific screen when the network is gone.
Optimistic UI means showing the result immediately, without waiting for the server to confirm. It is acceptable as long as the status does not lie. A note created in the field appears in the list at once but carries a 'pending sync' marker - the user sees that the record exists locally and has not yet reached the server. That is honest: the interface does not promise what is not there yet.
A delete can be hidden from the list right away and kept as a tombstone - a marker that the record is deleted but not yet acknowledged by the server - until sync closes the operation. A payment, however, cannot show an optimistic 'success': money cannot be quietly retried, and a false success is a lie the domain can no longer take back. The line runs along reversibility: a reversible action may be shown optimistically, an irreversible one may not.
Offline UX and app updates are the same discipline of honest state under uncertainty: in both, the interface must not claim more than it knows. The most damaging production bug in a PWA is mixing an old UI, a new worker and already-deleted chunks in one live tab, with the user doing nothing to cause it.
Why this happens is clear from the worker lifecycle: the new version goes through install and lands in waiting while the old one still controls the open tabs. If you force it active under a running tab, the old UI suddenly asks for chunks that no longer exist in the new build. That is why an update must be deliberate, not an automatic jolt.
The safe sequence is: detect the waiting worker; do not interrupt a draft, an upload or an unfinished transaction; show 'a new version is available'; on confirmation send the worker a SKIP_WAITING message; on the single controllerchange do one reload. The key point is not to activate the new version silently: control stays with the user, they choose the moment of reload themselves, and no unfinished operation is lost.
// page
updateButton.onclick = () =>
registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
// sw
self.addEventListener('message', event => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});skipWaiting() immediately promotes the waiting worker to active; the controllerchange event fires when the worker controlling the page changes. A guard against a repeated reload matters because controllerchange can fire more than once, and without a safety flag the page spins into a loop of reloads.
The server side matters just as much. Keep previous chunks around long enough, or make build assets immutable and do not purge them from the CDN instantly - otherwise an old tab cannot finish fetching its files. The IndexedDB schema must stay compatible with tabs open at the same time on different versions. And remember: a worker rollout cannot be stopped from the server - only a new deploy stops it, because the old worker already lives on users' devices.