12k
All articles

Офлайн-отправка форм с помощью Background Sync

Ставьте отправку форм в очередь в IndexedDB, воспроизводите ее через Background Sync и online-резерв, и предотвращайте дубли заказов с Idempotency-Key.

OpenReplay Team
OpenReplay Team
Офлайн-отправка форм с помощью Background Sync

Чтобы отправка формы не терялась при отсутствии соединения или нестабильной сети, запишите данные в 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 до обращения к сети

Сначала сохраните данные отправки в 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, архитектура остаётся одинаковой:

  1. фиксируйте данные локально до обращения к сети;
  2. отдавайте предпочтение Background Sync там, где он доступен;
  3. везде в остальных случаях используйте резервный вариант с событием online;
  4. дедуплицируйте повторные отправки с помощью ключа идемпотентности;
  5. согласовывайте интерфейс после фактической доставки.

Соедините эти пять элементов применительно к вашей форме, затем проверьте реальное воспроизведение: поставьте запрос в очередь в офлайн-режиме, восстановите соединение и убедитесь, что на сервере появилась единственная запись.

Часто задаваемые вопросы

В чём разница между одноразовым 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. Для проверки полного потока поставьте отправку в очередь в офлайн-режиме, восстановите соединение и убедитесь, что на сервере появилась единственная запись.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.