Как не упасть вместе с сервером: строим офлайн-режим на Service Worker и IndexedDB

Для кого: фронтенд-разработчики, которые слышали про Service Worker, но никогда не работали с ним на практике. Никакого предыдущего опыта не требуется — только базовый JavaScript и понимание того, как браузер делает HTTP-запросы.


🔥 Пролог

Представим: 9 утра, пик записей. Мастер открывает расписание — белый экран. Через минуту в поддержку летят первые тикеты, через тридцать — сотни. Администраторы не видят записи, мастера не знают расписание, клиенты не могут записаться.

А нам и представлять не нужно — мы через это прошли.

После первого такого инцидента мы поставили себе цель: следующий раз пользователи не должны заметить, что сервер упал. Никаких белых экранов, никаких потерянных расписаний — приложение продолжает показывать данные, пока мы разбираемся с проблемой. Вот что мы для этого построили.


Кратко о чём статья:

  • Service Worker перехватывает запросы — используем как прокси при инцидентах
  • Incident Manager в двух экземплярах: в main-thread и прямо внутри SW
  • Cache Storage для HTML, IndexedDB для данных
  • Thundering herd при выходе из офлайна — решаем через стратегии с сервера
  • Деплой SW — отдельная дисциплина с двухфазным релизом

⚙️ Часть 1. Прокси прямо в браузере

Что такое Service Worker

Service Worker — это скрипт, который браузер запускает в фоне, отдельно от вашей вкладки. Живёт по своим правилам: нет доступа к DOM, не может напрямую менять страницу, зато умеет кое-что важное — перехватывать все сетевые запросы.

Каждый раз, когда страница делает fetch — запрос к API, загрузка картинки, получение HTML — этот запрос проходит через Service Worker. Воркер сам решает, что с ним делать: пропустить к серверу, вернуть из кеша или вообще сформировать ответ самостоятельно.

                    ┌──▶  Сервер
  Браузер  ──▶  SW ─┼──▶  Кеш
                    └──▶  Свой ответ

Регистрация: постоянная. Процесс: нет

// В обычном JS вашей страницы
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/service_worker.js')
}

Здесь важно понять одну вещь, которая поначалу сбивает с толку. У Service Worker есть два разных понятия:

Регистрация — это запись в браузерной базе данных: «для этого сайта есть воркер вот с таким скриптом». Она живёт постоянно — переживает закрытие вкладок, закрытие браузера, перезагрузку компьютера. Удаляется только при явном unregister() или очистке данных сайта.

Процесс воркера — это уже выполняющийся код. И вот он эфемерен: браузер убивает его, когда воркер простаивает (конкретный таймаут у каждого браузера свой, стандарта нет). Закрыли все вкладки — через какое-то время процесс умрёт.

Но как только приходит новый fetch-запрос или postMessage — браузер поднимает процесс заново. И вот тут важный момент: при каждом перезапуске скрипт выполняется с нуля, все глобальные переменные сбрасываются. Хранить долгоживущее состояние в памяти воркера нельзя — оно не переживёт следующую «спячку».

Жизненный цикл: установка, ожидание, активация

Когда браузер обнаруживает изменённый файл воркера, он не заменяет старый немедленно — проходит три стадии:

installing → waiting → active

Installing — браузер скачивает новый скрипт и запускает событие install. Хорошее место, чтобы положить ресурсы в кеш.

Waiting — новый воркер установлен, но не активирован. Браузер ждёт, пока все вкладки под старым воркером будут закрыты. Логика: нельзя менять воркер у вкладки прямо посреди её работы.

Active — старые вкладки закрыты, новый воркер занял место и начинает перехватывать запросы.

Это значит: задеплоили новую версию воркера — пользователи, у которых открыта вкладка, будут работать на старом ещё долго. Иногда — до следующего дня.

Хм, а что если нам нужно обновить воркер срочно? Ждать, пока пользователь сам закроет вкладку?

Именно для этого существует skipWaiting() — команда, которая говорит воркеру «не жди, активируйся прямо сейчас». Как именно мы это используем — разберём в части про деплой. Сначала — нужно понять, что сервер вообще упал. И это уже нетривиально.


🚨 Часть 2. Кто первым узнает, что всё сломалось

Проблема: Service Worker не знает про инциденты

SW перехватывает запросы. Отлично. Но сам по себе он не знает, почему сервер не отвечает: временный таймаут, плановое обслуживание или инцидент на три часа?

Это критично, потому что поведение должно отличаться:

  • Сервер лагнул на секунду → повторяем запрос
  • Идёт инцидент → переключаемся в офлайн-режим, показываем данные из кеша

Нужен внешний источник правды о состоянии системы.

Incident Manager и polling

Мы сделали отдельный Incident API — лёгкий endpoint с доступностью на уровне CDN, независимый от основного бэкенда. Отвечает на один вопрос: система сейчас доступна или нет?

GET /incident/status → { "available": true/false, "strategy": {...} }

Incident Manager — модуль, который каждые 60 секунд спрашивает этот API и рассылает события:

enhanced-mode-activated   → всё, переходим в офлайн
enhanced-mode-deactivating → сервер восстанавливается, ждём...
enhanced-mode-deactivated  → можно работать в обычном режиме

А что если вкладки нет вообще?

Вот где нас первый раз поймал SW. Регистрация воркера живёт независимо от вкладки — пользователь закрыл журнал записи, но регистрация осталась. Когда он откроет /signin, воркер поднимется снова и перехватит запрос.

Инцидент начался именно в этот момент — кто про него знает, если main-thread не поднялся?

Ответ: SW должен знать про инцидент сам, без помощи вкладки.

Поэтому Incident Manager существует в двух экземплярах:

  1. В main-thread (обычный JS во вкладке) — управляет UI, показывает баннеры
  2. Прямо внутри Service Worker — работает автономно, влияет на логику перехвата запросов

Оба запускают свой цикл polling. Это не дублирование — это гарантия: даже если main-thread не запустился, SW всё равно знает про инцидент и правильно обработает следующий fetch.

Общаются они через postMessage:

// Main-thread → SW: сообщаем про состояние сети
navigator.serviceWorker.controller.postMessage({
  type: 'network-status-changed',
  payload: { isOnline: navigator.onLine }
})

// SW → все вкладки: рассылаем событие инцидента
clients.matchAll().then(clients => {
  clients.forEach(client => client.postMessage({ type: 'enhanced-mode-activated' }))
})

Диаграмма 1


📦 Часть 3. Cache-first: отдавай из кармана

Стратегия перехвата запросов

SW знает, что идёт инцидент. Что делать с входящими запросами?

Основная стратегия для HTML-страниц — Cache First:

  1. Пришёл запрос? Сначала смотрим в кеш
  2. Нашли — отдаём из кеша, не идём в сеть вообще
  3. Не нашли — идём в сеть, и если успешно — кладём в кеш на будущее
self.addEventListener('fetch', event => {
  if (isHtmlRequest(event.request)) {
    event.respondWith(handleHtml(event))
  }
})

async function handleHtml(event) {
  const cached = await caches.match(event.request)
  if (cached) return cached

  const response = await fetch(event.request)
  cache.put(event.request, response.clone()) // (1)
  return response
}

(1) Почему clone()? Объект Response — это поток (stream). Прочитать его можно только один раз: либо вы отдаёте его браузеру, либо кладёте в кеш. clone() создаёт копию потока — один экземпляр уходит в кеш, второй возвращается как ответ на запрос.

Страница расписания: чуть сложнее

Страница расписания — /timetable/:salonId — собирается из двух частей:

  • HTML-шаблон — структура страницы
  • Переводы i18n — тексты на нужном языке

В кеше они хранятся отдельно. Когда приходит запрос на /timetable/123, SW собирает ответ на лету:

Есть готовый кешированный HTML? → отдать сразу

Нет:
  1. Достать HTML-шаблон из кеша
  2. Достать переводы из кеша
  3. Встроить переводы в шаблон
  4. Вернуть собранный HTML

Что-то не нашлось → fallback (офлайн-заглушка)

На каждом шаге — fallback. Не удалось собрать ответ? Не 500-я ошибка, а специальная офлайн-страница с понятным сообщением.

Диаграмма 2

Куда кладём кеш

SW использует Cache Storage — браузерное хранилище специально для HTTP-ответов. Не localStorage, не sessionStorage — именно Cache Storage, который умеет хранить полноценные Response объекты.

const cache = await caches.open('my-app-v2.0.0')
await cache.put(request, response)

Версия в названии — не случайность. При обновлении воркера старый кеш удаляем, создаём новый. Подробнее — в части про деплой.

HTML отдаём. Но страница расписания без данных о мастерах и записях — красивая пустышка.


🗄️ Часть 4. Данные тоже хотят жить

HTML есть. Данных нет

Закешировали HTML-страницу расписания. Отлично, пользователь увидит структуру.

Но расписание — это не просто HTML-скелет. Это данные: мастера, услуги, записи клиентов. Они приходят через API и живут в памяти приложения.

Как сохранить их так, чтобы они пережили перезагрузку страницы и были доступны в офлайне?

Cache Storage здесь не поможет — он хранит HTTP-ответы, а не структурированные данные, с которыми удобно работать в JS. Нужна IndexedDB.

IndexedDB: локальная база прямо в браузере

IndexedDB — полноценная NoSQL база данных в браузере. Хранит данные постоянно, поддерживает транзакции, работает асинхронно, доступна и из main-thread, и из Service Worker.

У нас две базы:

salon-database — данные конкретного салона:

  • companies — информация о компании
  • resource_instances — мастера и оборудование
  • timetable_* — данные расписания

Структура хранения следующая: в каждом store лежит одна запись на салон — ключ salonId, значение — массив объектов. Проще, чем хранить каждый объект отдельно, и хорошо работает для сценария «загрузить всё сразу для одного салона».

dictionary-database — глобальные справочники:

  • countries — список стран
  • preloaded_salons — маркер того, что данные по салону успешно предзагружены

Preload: готовимся заранее, пока всё хорошо

Офлайн-данные нельзя достать из воздуха в момент падения сервера. Их нужно предзагрузить заранее.

При запуске приложения (в online-режиме):
  Для каждой нужной сущности:
    → Запрашиваем API с заголовком x-request-mode: offline
    → Сохраняем в IndexedDB

  Для нужных HTML-страниц:
    → Делаем fetch с x-request-mode: offline
    → SW перехватывает и кладёт в Cache Storage

  Помечаем салон как «предзагруженный»

Когда приложение переходит в офлайн, логика получения данных переключает источник:

const getResourceInstances = async (salonId) => {
  if (OfflineModeService.isOfflineMode) {
    return await getResourceInstanceService().getAll(salonId) // IndexedDB
  }
  return await resourceInstancesApi.getResourceInstances() // API
}

Диаграмма 3

А что если все клиенты стартанут preload одновременно?

Хороший вопрос, и мы о нём тоже подумали. Сервер восстановился после инцидента. Все вкладки одновременно получают сигнал «можно!» — и все 10 000 из них идут обновлять данные через API. Сервер только поднялся, но, видимо, не надолго.

Это называется thundering herd — «стадо громкого топота».

Решение — jitter-ожидание перед стартом preload:

MIN_WAITING_TIME = 30 секунд  (раньше не стартуем)
MAX_WAITING_TIME = 5 минут    (к этому моменту стартуем гарантированно)

Каждые 2 секунды:
  если прошло < MIN → ждём
  если прошло >= MAX → стартуем
  иначе:
    вероятность старта = прошло / MAX  (растёт с 0 до 1)
    бросаем монетку с этой вероятностью

Чем дольше ждёт клиент — тем выше вероятность старта. Нагрузка «размазывается» во времени вместо единого пика.

Текущее время ожидания сохраняется в localStorage. Пользователь перезагрузил страницу посреди ожидания — счётчик не обнуляется, продолжает с того места.

Важный момент: офлайн-режим у нас readonly

Всё, что мы описали — это про чтение данных: расписание, мастера, записи. В офлайн-режиме пользователи видят актуальные (на момент preload) данные, но не могут создавать новые записи или вносить изменения — запросы на запись блокируются и пользователь видит понятное объяснение.

Это осознанное решение. Потому что стоит разрешить запись — сразу возникают вопросы: а что если кто-то уже записал клиента в то же время из другого браузера? Как разрешать конфликты? Нужен ли optimistic UI? Это отдельная и очень нетривиальная тема — возможно, разберём в следующей статье.

Вход в офлайн отработал. Теперь — выход. И вот тут мы положили сервер второй раз.


🔄 Часть 5. Сервер ожил — не торопись

Thundering herd, второй раз

Мы научились входить в офлайн-режим. Теперь — выход.

Incident Manager получает от Incident API: available: true. Восстановились!

Казалось бы, переключаем флаг и работаем как раньше. Но тут та же проблема что с preload: если все клиенты одновременно пойдут к только что ожившему серверу — мы сами его положим обратно. Один раз мы уже так делали.

Поэтому Incident API вместе со статусом available присылает стратегию выхода:

Две стратегии

Randomized Jitter — каждый клиент получает случайную задержку от 0 до N секунд. Все равны: нет никаких приоритетов, просто равномерное размазывание нагрузки во времени.

Capped Exponential Backoff — задержка растёт экспоненциально в зависимости от того, сколько клиент уже провёл в офлайне: чем дольше ждал — тем дольше ещё подождёт перед выходом. Те, кто только что открыл браузер, выйдут первыми. Те, кто провёл в офлайне несколько часов, получат бо́льшую задержку. Сверху она ограничена — никто не ждёт вечно.

Ключевая деталь: параметры стратегии хранятся в IndexedDB. Пользователь перезагрузил страницу посреди ожидания — он продолжит ждать столько, сколько нужно, а не начнёт заново.

Диаграмма 4

Система работает. Но есть ещё одна проблема, о которой мы узнали на проде — и которую вы скорее всего не найдёте в документации.


🚀 Часть 6. Как задеплоить и не сломать мир

Service Worker — это не обычный JS-бандл

Привычная схема: задеплоил → пользователи получили новый код. С Service Worker так не работает, и мы это узнали на проде.

Проблема первая: агрессивное кеширование

Браузер кеширует service_worker.js очень агрессивно. Единственный способ заставить его обновить воркер — изменить содержимое файла. Поэтому URL должен оставаться стабильным — в отличие от обычного JS (app.a3f5c.js), где хеш меняется при каждом деплое.

Браузер периодически перечитывает файл по зарегистрированному URL и сравнивает байты. Изменились — запускает стандартный цикл installing → waiting → active. Если бы URL менялся при каждом деплое, браузер воспринимал бы это как новую регистрацию для того же scope — полный цикл переустановки без нужды.

Проблема вторая: воркер грузит дополнительные файлы

У нас логика разбита по файлам, и воркер загружает их через importScripts:

// service_worker.js
var SW_VERSION = "2.0.0"
importScripts(`./sw-static/version-${SW_VERSION}/router.js`)
importScripts(`./sw-static/version-${SW_VERSION}/loaders/incident_manager_loader.js`)

Проблема третья: поэтапная раскатка по серверам

У нас не один сервер — их много, балансировщик распределяет запросы между ними. Деплоим мы поэтапно.

Вот реальный сценарий, который нас поймал: обновили service_worker.js с SW_VERSION = "2.1.0" на половину серверов. Браузер скачал новый воркер с сервера A. Воркер пытается загрузить sw-static/version-2.1.0/router.js. Страница обновилась — пользователь попал на сервер B, где этой папки ещё нет.

importScripts падает с 404. Воркер сломан у части пользователей.

Двухфазный релиз

Решение простое, когда понимаешь причину:

Фаза 1 — сначала раскладываем новую статику:

Создаём: sw-static/version-2.1.0/ со всеми файлами
Деплоим на ВСЕ серверы
Проверяем: curl /sw-static/version-2.1.0/router.js → 200 OK

Фаза 2 — только после этого меняем версию воркера:

Обновляем SW_VERSION = "2.1.0" в service_worker.js
Деплоим на все серверы

К моменту, когда браузеры начнут скачивать новый воркер, все нужные файлы уже есть на каждом сервере.

Диаграмма 5

«Шторм перезагрузок»

Двухфазный релиз решает проблему с 404, но во время Фазы 2 есть другой эффект. Пока раскатка service_worker.js не завершена, разные вкладки могут попасть на разные серверы и скачать разные версии воркера.

Браузер держит только одну активную версию SW для домена. Когда вкладка скачала новый воркер — он пытается вытеснить старый. Срабатывает механизм sw-global-reload, остальные вкладки перезагружаются. Но при перезагрузке они снова могут попасть на другой сервер — скачать другую версию — снова перезагрузить соседние вкладки.

Пока все серверы не выкатят одинаковую версию — вкладки будут периодически перезагружаться.

Это ожидаемое поведение во время раскатки. Именно поэтому важно не затягивать Фазу 2 и доводить деплой до конца, а не оставлять «полкластера на старом, полкластера на новом». И лучше делать это в малонагруженное время.

Как новый воркер обновляет все вкладки

Установили новый воркер — нужно, чтобы все открытые вкладки перезагрузились и начали работать под ним. Без этого получаются «зомби-вкладки» на старой логике.

Механизм:

  1. ServiceWorkerManager отправляет в новый воркер { type: "app-version" }
  2. Воркер вызывает skipWaiting() — ускоряет активацию, не ждёт закрытия старых вкладок
  3. При активации делает clients.claim() и рассылает всем вкладкам force-reload
  4. Параллельно ServiceWorkerManager пишет токен в localStorage['sw-global-reload']
  5. Другие вкладки ловят storage событие и перезагружаются один раз на токен

Итог: все вкладки приходят к одной версии воркера автоматически.

Диаграмма 6


🔴 Часть 7. Аварийный рубильник

Всё это хорошо работает в штатном режиме. Но что если мы сами задеплоили баг в воркер?

Важно понять: воркер закеширован в браузере. Даже если вы уже задеплоили исправление на сервер, старый сломанный воркер продолжает жить у пользователей. Он перехватывает запросы и ломает работу — а пользователь ничего не может сделать, просто обновляя страницу.

Если нужно убить воркер вручную (например, вы разработчик и хотите почиститься):

  • DevTools → Application → Service Workers → Unregister
  • Или: DevTools → Application → Storage → Clear site data (удалит воркер + весь кеш)
  • Или консоль: navigator.serviceWorker.getRegistrations().then(rs => rs.forEach(r => r.unregister()))

Но это работает только для вас. Чтобы убить воркер у всех пользователей, нужен системный механизм.

На такой случай есть feature flags:

  • offline_mode_global — глобальный рубильник
  • offline_mode — для конкретного филиала

Если флаги выключены, приложение отправляет в SW команду на самоуничтожение:

// main.ts — если флаг выключен
swManager.postMessage({ type: 'destroy-service-worker' })

// service_worker.js — получает команду и удаляет себя
self.addEventListener('message', event => {
  if (event.data.type === 'destroy-service-worker') {
    self.registration.unregister()
    clients.matchAll().then(clients =>
      clients.forEach(c => c.navigate(c.url))
    )
  }
})

Выключили флаг в панели администратора → при следующей загрузке страницы воркер получит команду и удалит себя. Никаких ручных действий со стороны пользователей.


✅ Итого

Что мы построили:

Слой Что делает
🔍 Мониторинг Incident Manager в main-thread и SW опрашивает Incident API каждые 60 секунд. Знает про инциденты раньше, чем пользователь увидит ошибку.
📄 Кеширование SW перехватывает запросы и отдаёт HTML из Cache Storage, пока сервер недоступен. Пользователь видит заглушку, а не белый экран.
🗄️ Данные Preload заранее скачивает нужные сущности в IndexedDB. В офлайне приложение читает из локальной базы вместо API.
🐢 Плавный выход Jitter размазывает нагрузку равномерно, exponential backoff даёт приоритет тем, кто открыл браузер позже. Сервер не получает навал сразу после восстановления.
🛡️ Безопасный деплой Двухфазный релиз и feature flags дают контроль без риска сломать пользователей.

Что оказалось неочевидным

Когда начинали, казалось: «Service Worker — это просто кеш-прокси, делов-то». На практике:

🧩 Регистрация SW переживает вкладку, процесс — нет. Браузер убивает процесс воркера при простое и поднимает заново на каждый fetch. Глобальные переменные сбрасываются. Если нужно что-то сохранить между «спячками» — только IndexedDB или Cache Storage, не память.

🧩 Два инстанса одного модуля — Incident Manager в main-thread и в SW — это два разных окружения с разными транспортами для событий. Нужно проектировать модуль так, чтобы он адаптировался к обоим.

🧩 Деплой SW — отдельная дисциплина — двухфазный релиз, шторм перезагрузок, агрессивное кеширование. Привычный подход «задеплоили и забыли» тут не работает.

Подписаться на новые выпуски блога

Не пропустите последние обновления.
i.ivanov@yandex.ru
Подписаться