Офлайн-отправка форм с помощью Background Sync
Ставьте отправку форм в очередь в IndexedDB, воспроизводите ее через Background Sync и online-резерв, и предотвращайте дубли заказов с Idempotency-Key.
Чтобы отправка формы не терялась при отсутствии соединения или нестабильной сети, запишите данные в IndexedDB до обращения к сети, зарегистрируйте тег Background Sync и позвольте сервис-воркеру повторно отправить поставленный в очередь запрос из обработчика события sync при восстановлении связи — а затем добавьте резервный обработчик события online для браузеров, не поддерживающих Background Sync. Последнее условие не является опциональным: Background Sync API реализован только в браузерах на базе Chromium, поэтому очередь IndexedDB в сочетании с обработчиком online — это базовое решение, работающее повсеместно, а Background Sync — улучшение, надстраиваемое поверх него.
В этой статье на одном практическом примере — форме контакта/заказа — разбирается полный паттерн: надёжная очередь, регистрация синхронизации, воспроизведение в сервис-воркере, универсальный резервный вариант, предотвращение дублирования отправок и проблема подтверждения, о которой никто не предупреждает. Предполагается, что вы уже зарегистрировали сервис-воркер и уверенно работаете с промисами и fetch. Стандартное кэширование (cache-first, network-first) является предварительным условием, а не темой данной статьи — обратитесь к стратегиям кэширования на MDN и модулям стратегий Workbox.
Ключевые выводы
- Записывайте данные отправки в IndexedDB с клиентским идентификатором запроса, временной меткой
queuedAtи счётчиком повторных попыток до сетевого вызова — тогда прерванный POST уже будет сохранён, а не потерян. - Одноразовый Background Sync (
SyncManager) поддерживается только в браузерах на базе Chromium — Chrome, Edge, Opera и Samsung Internet — и отсутствует в Firefox, Safari и iOS, поэтому резервный обработчик событияonlineостаётся обязательным в 2026 году. - В обработчике
syncсервис-воркера отправляйте каждый элемент из очереди методом POST, удаляйте его при успехе и выбрасывайте исключение при ошибке — это сигнализирует браузеру о необходимости сохранить регистрацию и повторить попытку с управляемой браузером экспоненциальной задержкой. - Передавайте идентификатор запроса в заголовке
Idempotency-Key, чтобы сервер мог дедуплицировать уже принятые отправки при повторном воспроизведении, предотвращая дублирование заказов. BackgroundSyncPluginиз Workbox берёт на себя постановку в очередь и воспроизведение, однако повторяет попытки только при реальных сетевых ошибках — ответы с кодами 4xx или 5xx считаются доставленными и не будут воспроизведены повторно.
Почему обычный fetch при отправке не работает офлайн
Простой вызов fetch при отправке формы завершается молча, когда устройство не в сети: промис отклоняется, запрос не достигает сервера, и если вы не предусмотрели явную обработку ошибок, пользователь не получает никакого сигнала о произошедшем сбое. Сервис-воркеры решают задачу кэширования ресурсов, но не выполняют автоматический повтор новых запросов с данными, которые генерирует форма, — неудавшийся POST просто исчезает.
Молчаливо провалившийся офлайн-POST — это классическая ошибка «пользователь считает, что отправил; данные так и не поступили». Она также невидима для серверного мониторинга, поскольку запрос никогда не достигал бэкенда — логировать нечего. Воспроизведение сессий (session replay) — это техника, закрывающая данный пробел в видимости: воспроизведение восстанавливает клиентскую реальность — нажатие кнопки «Отправить», оптимистичный экран «Спасибо!», переход на другую страницу — которую затем можно сопоставить с тем, появилась ли в итоге запись на сервере. Именно такое сопоставление позволяет обнаружить разрыв доверия, для предотвращения которого и существует весь этот паттерн.
«Отправить и забыть» должно ощущаться мгновенным для пользователя и надёжным для вас: отправка фиксируется локально в момент нажатия кнопки, а доставка — это задача системы, а не пользователя.
Шаг 1: Постановка отправки в очередь IndexedDB до обращения к сети
Discover how at OpenReplay.com.
Сначала сохраните данные отправки в IndexedDB, затем попытайтесь доставить их. Сохранение до любого сетевого вызова означает, что неудавшийся, прерванный или офлайн-POST уже является устойчивым — вы воспроизводите данные из хранилища, а не из волатильного состояния страницы. Добавляйте три поля к каждому элементу очереди: клиентский идентификатор запроса (для последующей дедупликации), временную метку queuedAt и счётчик retryCount.
// db.js — тонкая обёртка на промисах вокруг IndexedDB
const DB_NAME = 'outbox';
const STORE = 'submissions';
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE, { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function enqueue(payload) {
const db = await openDB();
const item = {
id: crypto.randomUUID(), // идентификатор запроса для идемпотентности
payload,
queuedAt: Date.now(),
retryCount: 0,
};
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(item);
tx.oncomplete = () => resolve(item);
tx.onerror = () => reject(tx.error);
});
}
crypto.randomUUID() доступен во всех современных браузерах и в контексте сервис-воркера согласно справочнику MDN по Crypto.randomUUID(). Обработчик формы вызывает enqueue, отображает оптимистичное состояние «В очереди», а затем запрашивает синхронизацию.
Шаг 2: Регистрация Background Sync и обработка события sync
Background Sync позволяет зарегистрировать именованный тег со страницы; браузер генерирует событие sync в вашем сервис-воркере, когда считает соединение восстановленным, причём это событие может быть доставлено даже после того, как пользователь перешёл на другую страницу или закрыл вкладку. Именно это поведение — отложенная доставка после навигации — делает Background Sync надёжнее обработчика online, который срабатывает только пока страница открыта.
Перед использованием обязательно проверяйте наличие как serviceWorker, так и SyncManager, и немедленно переключайтесь на резервный вариант при отсутствии любого из них:
// обработчик формы, после вызова enqueue()
async function requestSync() {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
try {
await reg.sync.register('sync-forms');
return;
} catch {
// регистрация не удалась — переходим к резервному варианту
}
}
flushQueue(); // универсальный резервный вариант (Шаг 3)
}
Вызов register('sync-forms') и интерфейс SyncManager задокументированы в Background Synchronization API на MDN. В сервис-воркере сопоставьте тег, оберните работу в event.waitUntil(), чтобы воркер оставался активным, отправьте каждый элемент методом POST, удалите при успехе и выбросьте исключение при ошибке, чтобы браузер сохранил регистрацию и повторил попытку:
// service-worker.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-forms') {
event.waitUntil(replayQueue(event));
}
});
async function replayQueue(event) {
const items = await getAll(); // чтение из IndexedDB
for (const item of items) {
const res = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': item.id, // дедупликация на сервере
},
body: JSON.stringify(item.payload),
});
if (res.ok) {
await remove(item.id); // удаление при успехе
} else if (res.status >= 500) {
if (event.lastChance) await notifyFailure(item);
throw new Error('server error, retry'); // сохраняем регистрацию
}
// 4xx: некорректные данные — не повторяем слепо; вместо этого сообщаем об ошибке
}
}
Выброс исключения внутри waitUntil — это сигнал, который удерживает синхронизацию в ожидании. Браузеры, поддерживающие API, повторяют неудавшиеся запросы с интервалом, управляемым браузером, вероятно, с использованием экспоненциальной задержки между попытками. Количество попыток и точный интервал определяются браузером и не зафиксированы в контракте, поэтому не закладывайте жёстких предположений вроде «три повтора». Вместо этого проверяйте event.lastChance — задокументировано в справочнике MDN по SyncEvent — чтобы обнаружить последнюю попытку и сообщить пользователю об окончательном провале отправки, а не дать ей молча исчезнуть.
Шаг 3: Резервный вариант, работающий повсеместно
Поскольку Background Sync доступен только в Chromium, резервный обработчик события online — это не сноска, а базовое решение для всех браузеров. Два триггера покрывают путь для не-Chromium браузеров: сброс очереди при каждом срабатывании события online и однократный сброс при каждой загрузке страницы для обработки отправок, поставленных в очередь в предыдущей сессии.
// выполняется на странице, во всех браузерах
export async function flushQueue() {
if (!navigator.onLine) return;
const items = await getAll();
for (const item of items) {
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': item.id,
},
body: JSON.stringify(item.payload),
});
if (res.ok) await remove(item.id);
} catch {
await bumpRetryCount(item.id); // остаётся в очереди до следующего триггера
}
}
}
window.addEventListener('online', flushQueue);
window.addEventListener('load', flushQueue);
Честное ограничение резервного варианта: он работает только пока открыта страница вашего источника, поскольку зависит от событий уровня страницы. Он не может разбудить закрытую вкладку так, как это делает Background Sync. Это точный компромисс — универсальный охват, более слабые гарантии доставки — и именно поэтому Background Sync регистрируется в первую очередь, а резервный вариант используется во вторую.
Поддержка Background Sync браузерами в 2026 году
По состоянию на июнь 2026 года одноразовый Background Sync (SyncManager) является API, реализованным исключительно в Chromium. MDN классифицирует его как имеющий «ограниченную доступность» — не Baseline, поскольку он не работает в ряде наиболее широко используемых браузеров. Данные caniuse подтверждают следующее разделение.
| Браузер | Одноразовый Background Sync (SyncManager) |
|---|---|
| Chrome | ✅ Поддерживается |
| Edge (Chromium) | ✅ Поддерживается |
| Opera | ✅ Поддерживается |
| Samsung Internet | ✅ Поддерживается |
| Firefox | ❌ Не поддерживается |
| Safari (macOS) | ❌ Не поддерживается |
| Safari (iOS) | ❌ Не поддерживается |
| Android WebView | ❌ Не поддерживается |
Стоит отметить два важных нюанса. Microsoft Edge получил поддержку только после перехода на Chromium, поэтому любые утверждения 2019 года о том, что «Edge не поддерживает это», сейчас неверны. Android WebView — компонент, встраиваемый нативными приложениями для внутрибраузерного просмотра — не предоставляет SyncManager, поэтому оборачивание PWA в оболочку WebView лишает её нативной очереди синхронизации и переводит на путь с обработчиком online. Обратите внимание, что речь идёт об одноразовом Background Sync API; Periodic Background Sync — это отдельный, экспериментальный API для периодических обновлений. Не смешивайте эти два понятия.
Идемпотентность: предотвращение дублирования заказов при повторных отправках
Любой повторный POST рискует создать дубликат: первая попытка могла достичь сервера и зафиксироваться до разрыва соединения, поэтому ответ так и не вернулся, и клиент воспроизводит запрос, который сервер уже обработал. Предотвратите это с помощью клиентского идентификатора запроса, передаваемого в заголовке Idempotency-Key — тот самый item.id, сохранённый при постановке в очередь. Сервер использует его как ключ и возвращает исходный результат при повторном запросе вместо создания второй записи.
Idempotency-Key — устоявшееся соглашение в API, популяризированное Stripe и PayPal, и являющееся предметом интернет-черновика IETF draft-ietf-httpapi-idempotency-key-header. Рассматривайте его как развивающееся соглашение, а не ратифицированный стандарт: поле заголовка HTTP Idempotency-Key может использоваться для обеспечения отказоустойчивости неидемпотентных HTTP-методов, таких как POST или PATCH, однако сам черновик содержит стандартную оговорку о том, что на него нельзя ссылаться иначе как на работу в процессе. Серверная сторона проста: при получении повторного запроса с уже известным ключом идемпотентности сервер ресурсов должен вернуть результат ранее выполненной операции — успешный или ошибочный.
UX подтверждения после ухода пользователя
Сложная UX-проблема состоит в том, что отложенная доставка нередко завершается уже после того, как пользователь перешёл на другую страницу, поэтому необходим механизм согласования оптимистичного состояния «В очереди» с фактической доставкой запроса. Три подхода покрывают эту задачу:
- Обновление «В очереди» → «Отправлено» в реальном времени. Когда сервис-воркер успешно воспроизводит элемент, отправьте сообщение открытым клиентам через
postMessage, чтобы интерфейс обновился на месте. Подпишитесь на событие черезnavigator.serviceWorker.addEventListener('message', ...). - Согласование при следующей загрузке. При запуске читайте состояние опустошённой очереди: присутствующие элементы ожидают доставки; их отсутствие означает успешную доставку. Отображайте статус из хранилища, а не из флага, установленного в момент отправки.
- Сообщение о финальной ошибке. Используйте
event.lastChanceв обработчикеsync, чтобы отправить уведомление (или сохранить маркер «ошибка», который интерфейс считает при следующей загрузке) — тогда отправка, исчерпавшая все попытки, не исчезнет молча.
UX подтверждения — второе место, где воспроизведение сессий оправдывает своё применение: replay делает видимой временну́ю шкалу на стороне пользователя — поставленная в очередь отправка, увиденный экран, было ли когда-либо показано подтверждение «Отправлено» — что является единственным представлением, демонстрирующим разрыв доверия в случае, когда запрос так и не достиг сервера для логирования.
Workbox как производственный ярлык
Если вы предпочитаете не реализовывать очередь вручную, Workbox (текущая мажорная версия 7) предоставляет BackgroundSyncPlugin, который ставит неудавшиеся запросы в очередь IndexedDB и воспроизводит их при событиях sync. Важно отметить, что плагин поставляется с собственным резервным механизмом: в браузерах, не поддерживающих BackgroundSync API нативно, Workbox Background Sync автоматически попытается выполнить воспроизведение при каждом запуске сервис-воркера.
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
const bgSync = new BackgroundSyncPlugin('order-queue', {
maxRetentionTime: 24 * 60, // в минутах; в документированном примере элементы хранятся 24 часа
});
registerRoute(/\/api\/orders/, new NetworkOnly({ plugins: [bgSync] }), 'POST');
Два поведения, которые часто вызывают путаницу. Во-первых, BackgroundSyncPlugin подключается к колбэку плагина fetchDidFail, который вызывается только при выброшенном исключении — как правило, вследствие сетевой ошибки. Это означает, что запросы не будут повторяться при получении ответа с кодом 4xx или 5xx. Если вы хотите ставить ответы 5xx обратно в очередь, добавьте плагин fetchDidSucceed, выбрасывающий исключение при response.status >= 500. Во-вторых, при тестировании вы можете проверить постановку запросов в очередь в Chrome DevTools → Application → IndexedDB, а принудительно запустить воспроизведение — из DevTools → Application → Service Workers. Не проверяйте офлайн-поведение с помощью флажка «Offline» в DevTools — он блокирует запросы страницы, но пропускает запросы сервис-воркера, скрывая именно те ошибки, которые вы тестируете. Вместо этого отключайте реальную сеть.
Независимо от того, реализуете ли вы решение вручную или используете Workbox, архитектура остаётся одинаковой:
- фиксируйте данные локально до обращения к сети;
- отдавайте предпочтение Background Sync там, где он доступен;
- везде в остальных случаях используйте резервный вариант с событием
online; - дедуплицируйте повторные отправки с помощью ключа идемпотентности;
- согласовывайте интерфейс после фактической доставки.
Соедините эти пять элементов применительно к вашей форме, затем проверьте реальное воспроизведение: поставьте запрос в очередь в офлайн-режиме, восстановите соединение и убедитесь, что на сервере появилась единственная запись.
Часто задаваемые вопросы
В чём разница между одноразовым Background Sync и Periodic Background Sync?
Одноразовый Background Sync использует интерфейс SyncManager для воспроизведения одной отложенной задачи — например, поставленной в очередь отправки формы — при восстановлении соединения; браузер генерирует событие sync, которое может прийти даже после того, как пользователь перешёл на другую страницу. Periodic Background Sync использует отдельный интерфейс PeriodicSyncManager для периодических обновлений по расписанию, управляемому браузером, — например, для обновления контента. Это разные API, и Periodic Background Sync является экспериментальным, поэтому проверяйте таблицу совместимости перед использованием в продакшене.
Почему поставленный в очередь запрос по-прежнему не повторяется при получении от сервера ответа 400 или 500?
Background Sync и BackgroundSyncPlugin из Workbox повторяют только запросы, завершившиеся реальной сетевой ошибкой, поскольку плагин подключается к колбэку fetchDidFail, который срабатывает только при выброшенном исключении. Ответ с кодом 400 или 500 считается полученным ответом, то есть доставленным, и не будет воспроизведён повторно. Чтобы ставить ответы 5xx обратно в очередь, добавьте плагин fetchDidSucceed, выбрасывающий исключение при response.status равном 500 или выше, либо выбрасывайте исключение вручную в обработчике sync, реализованном самостоятельно.
Работает ли Background Sync внутри Android WebView, оборачивающего PWA?
Нет. Android WebView — компонент, встраиваемый нативными приложениями для внутрибраузерного просмотра — не предоставляет SyncManager, поэтому PWA, обёрнутое в оболочку WebView, лишается нативной очереди Background Sync. Отправка переходит на путь с обработчиком online, который срабатывает только пока открыта страница вашего источника и не может разбудить закрытую вкладку. По этой причине, а также из-за отсутствия поддержки в Firefox, Safari и iOS, очередь IndexedDB с резервным обработчиком online должна оставаться базовым решением.
Как надёжно протестировать офлайн-поведение Background Sync?
Отключайте реальную сеть, а не используйте флажок «Offline» в DevTools. Флажок Offline блокирует запросы страницы, но пропускает запросы сервис-воркера, скрывая именно те ошибки, которые вы тестируете. Проверяйте поставленные в очередь элементы в Chrome DevTools в разделе Application → IndexedDB, а принудительный запуск воспроизведения выполняйте из Application → Service Workers. Для проверки полного потока поставьте отправку в очередь в офлайн-режиме, восстановите соединение и убедитесь, что на сервере появилась единственная запись.