Серверный код, который вы выкатываете, вы заменяете: старая версия исчезает, новая отвечает всем сразу. Service worker так не работает. Это фоновый скрипт, который браузер один раз устанавливает на устройство, и дальше он живёт там сам, перехватывая запросы страницы и работая без сети. Браузер перепроверяет sw.js при навигации в пределах scope, а также по функциональным событиям вроде push и sync - но по ним не чаще раза в сутки. Периодической проверки не существует: без навигации и без таких событий обновление не найдётся вовсе. И даже найдя новую версию, браузер держит её в состоянии waiting, пока не закроются все вкладки со старым worker. Значит, неудачный деплой - это не откат в одну команду: сломанный код уже крутится на тысячах устройств, на разных версиях, часами или днями. Выкат приходится проектировать как совместимость и аварийный выход, а не как переключатель.
Наивный ход - выложить новый sw.js и новые бандлы, и на этом закончить. Но захэшированные ассеты (chunk-a1b2.js) неизменяемы и кэшируются надолго, а версии клиентов перемешиваются. Новый sw.js ссылается на chunk-a1b2.js, но на устройстве всё ещё работает старый worker: он отдаёт старый index, тот просит chunk-old.js - и если вы уже удалили этот файл с CDN, клиент получает 404. В приложении заказов это значит: один пользователь на версии N, другой на N-1, оба стучатся в один API, и бэкенд обязан понять обоих.
Основной рычаг управления - HTTP-кэширование двух классов ассетов. Сам sw.js должен иметь короткий max-age или no-cache с ревалидацией, чтобы браузер быстро подхватил нового worker; регистрация с updateViaCache: 'none' дополнительно запрещает HTTP-кэшу отдавать устаревший скрипт worker при проверке обновления. Захэшированные ассеты - наоборот: immutable и max-age на год, потому что хэш в имени и есть версия, и файл безопасно кэшировать вечно. Старые захэшированные файлы держите на CDN как минимум на всё окно обновления активных клиентов.
Поскольку клиенты обновляются вразнобой, бэкенд обязан весь этот период понимать оба контракта, N и N-1. Изменения только аддитивные: новое поле добавляем, старое не удаляем, пока жив клиент, который его читает. То же с локальной схемой: миграция IndexedDB через upgradeneeded должна оставаться читаемой для старой вкладки в соседнем окне, иначе та ломается посреди сессии пользователя.
Выкатывайте постепенно. Canary - это проверка новой версии на узкой аудитории до общего релиза: поднимите нового worker на отдельном scope или origin, посмотрите на его частоту ошибок, и только потом раскатывайте на долю трафика (staged rollout). Сделайте версию worker полноценным сигналом: зашейте её в код, пишите в диагностику и в серверные логи, чтобы при росте ошибок видеть, какая именно версия сейчас разговаривает с сервером, а не гадать.
Когда деплой всё же оказался сломан, нужен путь отхода - kill switch. Выложите на тот же URL sw.js минимального worker: при активации он стирает все кэши, снимает регистрацию через unregister и перезагружает окна - чистый лист при следующей загрузке.
Две оговорки делают это безопасным. Первое: код сработает только после того, как браузер перепроверит sw.js и активирует нового worker - а это может случиться через часы или не случиться вовсе. Поэтому серверный или CDN-fallback (свежий index с правильными заголовками) всё равно обязателен: kill switch - это клиентская половина лечения, а не всё лекарство. Второе: никогда не стирайте IndexedDB в общем kill switch - там могут лежать несинхронизированные заказы пользователя, и очистка кэшей обратима, а уничтожение неотправленных записей - нет.
// deploy на прежний URL sw.js
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', event => {
event.waitUntil((async () => {
for (const key of await caches.keys()) await caches.delete(key);
await self.registration.unregister();
const windows = await self.clients.matchAll({ type: 'window' });
for (const client of windows) client.navigate(client.url);
})());
});