Оффлайн в прогрессивных веб-приложениях давно перестал быть экзотикой: service worker отдаёт разметку и скрипты из кэша, IndexedDB хранит черновики, интерфейс не рассыпается в вагоне метро. Узким местом остаётся обмен с сервером. Письмо, отправленное в тоннеле, умирает вместе с запросом, а свежая лента грузится только после открытия, и первые секунды приложение крутит спиннер вместо контента. Семейство Sync API достраивает офлайн-контур с двух сторон: одноразовая синхронизация доводит исходящие запросы до сервера, когда сеть вернётся, а периодическая заранее подтягивает свежий материал в кэш. Обе механики живут в service worker, и у каждой свои условия запуска, свой контракт и своя цена ошибки. Ниже обе разобраны на коде, который почти без правок встаёт в проект.
Разные судьбы исходящих и входящих данных при потере сети
Исходящие и входящие данные страдают в офлайне по-разному. Исходящая беда наступает в момент нажатия: кнопка отправки упирается в пустоту, запрос ломается об отсутствие сети, и классическому вебу остаётся извиниться и попросить повторить попытку позже. Входящая беда тише: приложение открывается, показывает вчерашний кэш и лишь потом начинает тянуть обновления, тратя время и трафик человека.
Sync API отвечает на обе беды, но разными инструментами. Background Sync регистрирует одноразовое отложенное задание из открытой страницы, а выполняет его service worker, когда браузер видит устойчивое соединение. Periodic Background Sync работает в обратную сторону: по решению браузера воркер просыпается на несколько минут, забирает свежие данные в кэш и снова засыпает. Первая механика про доставку написанного, вторая про своевременность прочитанного.
Без периодической синхронизации свежесть данных обычно держат на push-уведомлениях: сервер будит service worker специальным сообщением, человек видит вставку вроде "появились новые материалы", а сама подгрузка оказывается побочным эффектом уведомления. Периодическая синхронизация убирает лишнее звено: данные приходят без будоражащего пинга, а важные события остаются за push. Классический сценарий одноразовой механики - почтовый клиент: человек пишет письмо и жмёт отправку в любой момент, даже без сети, фронтенд лишь регистрирует синхронизацию, а воркер получает сигнал при появлении соединения и доставляет письмо сам.
Общим для обеих остаётся фундамент: страница открывается по HTTPS в защищённом контексте, service worker зарегистрирован, а расписание принадлежит браузеру, а не сайту. Без service worker ни одно из событий не сработает, поэтому Sync API достаётся именно прогрессивным приложениям, а не обычным сайтам с парой скриптов.
Регистрация однократной синхронизации из открытой страницы
Всё начинается с одного вызова из контекста страницы. Свойство sync у объекта регистрации возвращает SyncManager, а тот принимает имя задания, так называемый тег:
async function scheduleOutboxSync() {
const registration = await navigator.serviceWorker.ready;
try {
await registration.sync.register('sync-outbox');
} catch {
console.log('Синхронизацию зарегистрировать не удалось');
}
}
Тег обязан быть уникальным именем конкретного задания, и вокруг него построена экономия: повторная регистрация того же тега, пока старое задание висит в очереди, не плодит дубликаты, а сливается с существующим. Практический смысл простой: на каждое нажатие кнопки отправки можно смело регистрировать sync-outbox, воркер проснётся один раз и разберёт всю очередь целиком. Проверить, что уже стоит в очереди, помогает getTags:
const registration = await navigator.serviceWorker.ready;
const tags = await registration.sync.getTags();
if (tags.includes('sync-outbox')) {
console.log('Очередь уже ждёт сети');
}
Регистрация живёт прямо в обработчике кнопки: интерфейс сразу рисует письмо отправленным, потому что смысл кнопки не в мгновенном ответе сервера, а в попадании письма в очередь.
Механика появилась в Chrome 49 и с тех пор работает в семействе Chromium, за пределами браузеров на этом движке массовой поддержки нет. Её сильная сторона не видна в коде: адресат события - service worker, поэтому отправка доедет до сервера, даже если вкладку давно закрыли или приложение выгрузили из памяти. Событие придёт при следующем устойчивом соединении.
Приём события sync в service worker и контракт waitUntil
Воркер слушает событие sync и сверяет тег. Вся работа заворачивается в event.waitUntil с промисом на входе: пока промис жив, браузер держит воркер в памяти, а по промису судит об успехе задания:
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-outbox') {
event.waitUntil(sendOutbox());
}
});
Контракт честный и жёсткий одновременно. Промис разрешился - задание закрыто. Промис отвергся - браузер запланирует повтор, и повтор тоже дождётся сети, а паузы между попытками растут по экспоненте. Разработчик не выбирает момент и число попыток, зато получает гарантию: сообщение, попавшее в очередь, не потеряется из-за закрытой вкладки или внезапного тоннеля.
Флаг event.lastChance подсказывает, что за текущей попыткой новых не последует. С ним логика становится умнее: на последнем круге стоит пометить очередь сбойной, чтобы при следующем открытии приложения показать честный значок вместо молчаливой надежды. Свойство определено у SyncEvent и существует только внутри service worker, страница его не увидит:
self.addEventListener('sync', (event) => {
if (event.tag !== 'sync-outbox') {
return;
}
if (event.lastChance) {
event.waitUntil(markOutboxFailed());
}
event.waitUntil(sendOutbox());
});
Исходящая очередь в IndexedDB и порядок операций при отправке
Промис сам себя не отправит, поэтому сердце паттерна - исходящая очередь в IndexedDB. Кнопка отправки в офлайне не колдует над сетью, а кладёт сообщение в хранилище и сразу регистрирует синхронизацию, а интерфейс мгновенно показывает письмо в переписке. Воркер при пробуждении читает очередь и гоняет отправку по правилу: сервер подтвердил запись - удалить элемент из очереди, любой сбой - бросить исключение и позволить браузеру повторить всё позже.
async function sendOutbox() {
const items = await readOutbox();
for (const item of items) {
const response = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.payload),
});
if (!response.ok) {
throw new Error('Сервер отклонил сообщение');
}
await removeFromOutbox(item.id);
}
}
Порядок операций принципиален: удалять элемент из очереди можно только после ответа сервера, иначе повторная попытка отправит дубликат, и адресат получит одно письмо дважды. Тот же запрет работает в обратную сторону: одна неудачная запись обязана остановить весь проход исключением, чистить очередь частями нельзя.
Стык страницы и воркера собирается в обработчике формы: письмо сначала ложится в хранилище, затем страница просит браузер о синхронизации, а при отсутствии поддержки пытается отправить сама:
form.addEventListener('submit', async (event) => {
event.preventDefault();
const item = await saveToOutbox({
text: input.value,
createdAt: Date.now(),
});
const registration = await navigator.serviceWorker.ready;
if ('sync' in registration) {
await registration.sync.register('sync-outbox');
} else {
await sendOutbox();
}
renderMessageAsSent(item.id);
});
Статус у семейства скромный: ограниченная доступность. Поэтому очередь в IndexedDB остаётся не костылём, а ядром архитектуры: она одинаково работает и рядом с Sync API, и в браузерах без него, где отправку прогоняет обычный код при следующем запуске.
Периодическая синхронизация и её условия в браузере Chrome
Вторая механика решает задачу входящих данных. PeriodicSyncManager регистрирует тег тем же жестом, но принимает ещё и минимальный интервал в миллисекундах:
const registration = await navigator.serviceWorker.ready;
if ('periodicSync' in registration) {
try {
await registration.periodicSync.register('content-sync', {
minInterval: 24 * 60 * 60 * 1000,
});
} catch {
console.log('Периодическая синхронизация недоступна');
}
}
Ключевое слово здесь - минимальный: minInterval задаёт нижнюю границу, а не расписание. Браузер сам выбирает момент и волен выбрать позже, ориентируясь на поведение человека и состояние устройства. В Chrome периодическая синхронизация вышла из испытаний origin trial в стабильный канал с релизом 80 в начале 2020 года, и правила доступа жёсткие. Требования браузера к периодической синхронизации собираются в четыре пункта:
- приложение установлено на устройстве и запускалось как отдельное, вкладкой обойтись нельзя;
- оценка вовлечённости выше нуля, и её величина влияет на частоту пробуждений;
- устройство подключено к сети, которой человек уже пользовался раньше;
- состояние питания и связи позволяет фоновую работу.
Отдельное разрешение проверяется через Permissions API до любой регистрации:
const status = await navigator.permissions.query({
name: 'periodic-background-sync',
});
if (status.state === 'granted') {
console.log('Разрешение есть');
}
Провал любого пункта не порождает громких ошибок: события просто не придёт, и приложение обязано переживать это молча. Очередь исходящих при этом никуда не девается, свежий контент придётся грузить при запуске.
Обновление кэша по событию periodicsync с учётом сети
Воркер принимает событие periodicsync тем же маршрутом: сверить тег, завернуть работу в waitUntil. Типичная нагрузка - освежить кэш со списком статей или лентой:
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'content-sync') {
event.waitUntil(updateContent());
}
});
async function updateContent() {
const cache = await caches.open('articles');
await cache.add('/api/articles');
}
Вызов завёрнут в waitUntil не для красоты: без промиса браузер вправе завершить воркер раньше, чем статьи скачаются и лягут в хранилище. Результат ощущается при запуске: человек открывает приложение, а свежая лента уже лежит в кэше, спиннер не нужен. По такой схеме работает и витринный пример от команды браузера: приложение с подсказками по инструментам разработчика подкачивает новые советы раз в день и держит их в кэше, поэтому в метро они открываются без сети и без ожидания. Окно работы короткое, счёт идёт на минуты, поэтому внутри обработчика стоит сверяться с обстановкой: типом сети через Network Information API, режимом экономии трафика и остатком квоты хранилища, который оценивают методом navigator.storage.estimate() по полям usage и quota. На слабом соединении разумно тянуть урезанный набор данных, а не весь архив.
Управление регистрациями тоже в комплекте. Метод getTags возвращает список активных тегов, по нему строят экран настроек, где человек включает и выключает фоновые обновления по типам. Метод unregister снимает тег с обслуживания:
const tags = await registration.periodicSync.getTags();
if (!tags.includes('content-sync')) {
updateContentOnPageLoad();
}
await registration.periodicSync.unregister('content-sync');
Проверка поддержки и аккуратная деградация без синхронизации
Оба менеджера опознаются одинаково: свойство проверяется в объекте регистрации до любого вызова. Такая проверка избавляет от исключений в браузерах, где Sync API нет вовсе:
const registration = await navigator.serviceWorker.ready;
if ('sync' in registration) {
await registration.sync.register('sync-outbox');
}
if ('periodicSync' in registration) {
await registration.periodicSync.register('content-sync', {
minInterval: 24 * 60 * 60 * 1000,
});
}
Деградация обязана быть продуманной. Без однократной синхронизации очередь в IndexedDB продолжает жить, и приложение само прогоняет отправку при следующем запуске. Без периодической свежесть ложится на обычный запрос при старте, чуть медленнее, зато предсказуемо. Самый грубый просчёт - молча полагаться на Sync API как на единственный путь доставки: в браузере без поддержки сообщение застрянет в очереди навсегда, и человек не узнает, что письмо не ушло.
Связка раскрывает архитектуру офлайна целиком: кэш отвечает за чтение, очередь за запись, периодическая синхронизация за свежесть. Sync API не отменяет проверенные механики, он лишь снимает с человека обязанность держать стабильную сеть на связи. Прогрессивное приложение с такой прослойкой ведёт себя как нативное: отправляет в тоннеле, обновляется в фоне и открывается мгновенно, а граница между вебом и установленными приложениями стирается ещё на один шаг.