Наивная офлайн-вёрстка - это баннер 'вы не в сети' поверх обычного интерфейса. Проблема в том, что офлайн - не глобальный режим приложения, а свойство отдельных данных и действий: одна заметка уже в локальном кэше, другая ещё не синхронизирована, третью вообще нельзя выполнить без сети. Проектировать нужно состояния данных и действий, а не баннер: что происходит с конкретной заметкой, конкретной отправкой, конкретным экраном, когда сети нет.
Оптимистичный UI - это показать результат сразу, не дожидаясь подтверждения сервера. Он допустим, пока статус не врёт. Созданная в поле заметка появляется в списке мгновенно, но несёт маркер 'ожидает синхронизации' - пользователь видит, что запись существует локально и ещё не доехала до сервера. Это честно: интерфейс не обещает того, чего пока нет.
Удаление можно скрыть из списка сразу и оставить tombstone - пометку, что запись удалена, но подтверждение сервера ещё не получено, - пока синхронизация не закроет операцию. А вот для платежа оптимистичное 'успешно' недопустимо: деньги нельзя тихо повторить, а ложный успех - это ложь, которую домен уже не отыграет назад. Граница проходит по обратимости: обратимое действие можно показать оптимистично, необратимое - нет.
Офлайн-UX и обновление приложения - одна и та же дисциплина честного состояния под неопределённостью: и там, и там интерфейс не должен утверждать больше, чем знает. Самая разрушительная production-ошибка PWA - смешать старый UI, новый worker и уже удалённые чанки в одной живой вкладке, когда пользователь ничего для этого не делал.
Почему так выходит, видно из жизненного цикла worker'а: новая версия проходит install и попадает в waiting, пока старая всё ещё контролирует открытые вкладки. Если силой активировать её под работающей вкладкой, старый UI внезапно попросит чанки, которых в новом билде уже нет. Поэтому обновление должно быть осознанным, а не автоматическим рывком.
Безопасный сценарий такой: обнаружить waiting-worker; не прерывать черновик, загрузку или незавершённую транзакцию; показать 'доступна новая версия'; по подтверждению отправить worker'у сообщение SKIP_WAITING; на единственный controllerchange выполнить один reload. Ключевое здесь - не активировать новую версию молча: управление остаётся у пользователя, момент перезагрузки он выбирает сам, и ни одна незаконченная операция не теряется.
// страница
updateButton.onclick = () =>
registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
// sw
self.addEventListener('message', event => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});skipWaiting() немедленно переводит waiting-worker в active; событие controllerchange срабатывает, когда меняется контролирующий страницу worker. Защита от повторной перезагрузки важна потому, что controllerchange может сработать не один раз, и без флага-предохранителя страница уйдёт в цикл reload'ов.
Серверная сторона не менее важна. Храните предыдущие чанки достаточно долго или делайте build-ассеты immutable и не удаляйте их с CDN мгновенно - иначе старая вкладка не доберёт свои файлы. Схема IndexedDB должна оставаться совместимой с вкладками, открытыми одновременно на разных версиях. И помните: rollout worker'а нельзя остановить с сервера - его останавливает только новый deploy, потому что старый worker уже живёт на устройствах пользователей.