Offline-First и Background Sync (PWA)
Коротко: Действия пользователя (например, «добавить в корзину») при отсутствии интернета не отбрасываются, а сохраняются локально в браузере и автоматически воспроизводятся, как только соединение вернётся.
Подробнее: Используется IndexedDB (вместо localStorage), потому что service worker должен иметь к нему доступ в фоне, даже когда сама страница вообще не открыта — localStorage недоступен из контекста service worker. API Background Sync (только Chrome/Edge/Android) позволяет service worker запускать воспроизведение даже при закрытой странице; в качестве универсального запасного варианта (работает и в Safari/iOS) дополнительно используется простой слушатель события браузера 'online'.
Наш контекст: app/utils/offlineQueue.ts в Emzett (CART_QUEUE_STORE) для корзины — с идемпотентной синхронизацией через /api/cart/sync, чтобы повторная попытка синхронизации (Background Sync И событие 'online') не создавала дублирующих записей.
Подробности
Идемпотентность как ключевое требование
«Идемпотентный» — ключевое слово: поскольку и API Background Sync, и запасной вариант с событием 'online' могут запускать одну и ту же синхронизацию (теоретически оба могут даже сработать одновременно, если браузер выходит в сеть именно в этот момент), сервер нужно построить так, чтобы двукратная отправка одного действия НЕ давала двойного эффекта — обычно через уникальный идентификатор, создаваемый клиентом для каждого действия (чаще всего UUID, генерируемый локально при создании действия), который сервер узнаёт при втором поступлении и игнорирует, вместо того чтобы добавить товар в корзину второй раз. Этот идентификатор должен быть создан ДО самой синхронизации (а не только при отправке), чтобы оставаться одинаковым при каждой попытке синхронизации — если бы при каждой попытке создавался новый идентификатор, проверка идемпотентности была бы неэффективна.
Типичный процесс
Пользователь нажимает «добавить в корзину» без соединения → действие сразу попадает в очередь IndexedDB И оптимистично отображается в интерфейсе, как будто уже прошло → как только navigator.onLine снова становится true (или service worker пробуждается через Background Sync), обработчик синхронизации по порядку обрабатывает очередь и отправляет каждое действие на сервер → при успехе запись очереди удаляется, при неудаче сохраняется для более поздней попытки. Оптимистичное обновление интерфейса — осознанный выбор: пользователь, делающий покупки офлайн, не должен ощущать, что приложение «сломано» — вместо этого действие сразу выглядит успешным, с ненавязчивой подсказкой («будет синхронизировано, когда вы снова будете онлайн»), если что-то действительно не удалось.
self.addEventListener("sync", (event) => {
if (event.tag === "cart-sync") {
event.waitUntil(processQueuedCartActions());
}
});Почему IndexedDB, а не localStorage
localStorage синхронен и ненадолго блокирует основной поток, что при больших объёмах данных может вызывать заметные подтормаживания; IndexedDB же асинхронен и рассчитан на структурированные, большие объёмы данных (хранилища объектов с индексами, транзакции). Но решающий момент здесь другой: localStorage привязан к основному потоку документа и попросту недоступен из контекста service worker — service worker работает в собственном потоке, независимом от документа, именно чтобы работать и когда сама страница вообще не открыта. IndexedDB — один из немногих API хранения, одинаково доступных из обоих контекстов (документа И service worker).
Ограничения API Background Sync
API Background Sync намеренно не гарантирует немедленное выполнение — браузер сам решает, когда реально запустить синхронизацию (в зависимости от качества сети, уровня заряда и т. д.), но обычно в течение нескольких секунд после переподключения. Поддержка браузерами тоже неодинакова: Safari (а значит, и все браузеры на iOS, поскольку Apple принудительно использует там собственный движок WebKit) по-прежнему не поддерживает этот API — поэтому запасной вариант с событием 'online' — не необязательное дополнение, а строго необходим для кроссплатформенной надёжности.
См. также: proxy.ts, Протокол без установления соединения