Пользователь правит заметку в туннеле метро, где сети нет. Наивная реализация отправила бы PUT сразу и потеряла бы правку: запрос упал. Надёжная переворачивает порядок: сначала атомарно фиксируем intent локально, затем доставляем серверу хотя бы один раз. Это outbox pattern - очередь исходящих мутаций в durable-хранилище, отдельная от самих данных. UI считает правку сохранённой, как только она легла в IndexedDB вместе с записью в outbox; сеть - уже задача доставки.
Операция в outbox - не вызов fetch, а описанное намерение, которое можно повторять. У неё есть форма: operationId, UUID от клиента; entityId заметки; kind вроде note.upsert или note.delete; payload с данными; baseVersion - версия, поверх которой сделана правка; createdAt; счётчик attempt и nextAttemptAt для backoff; и state - pending, sending, conflict или failed. Такая запись самодостаточна: worker, проснувшийся через час, восстановит из неё запрос, не полагаясь на состояние страницы.
Ключ к безопасному повтору - идемпотентность на сервере. Клиент шлёт operationId как Idempotency-Key; сервер сохраняет результат первой обработки под этим ключом и на повтор возвращает тот же результат, а не выполняет операцию заново. Без этого timeout создаёт неопределённость: клиент не знает, дошла команда или нет, повторяет её - и списывает деньги дважды или создаёт дубль. Idempotency-Key превращает хотя бы один раз в ровно один эффект и делает retry безопасным по определению.
Отдельная тонкость - порядок нескольких операций над одной entity. Если пользователь дважды поправил заметку офлайн, в очереди две записи. Есть два честных пути: либо compact, схлопнуть несколько upsert в одно финальное состояние перед отправкой, либо сохранять причинный порядок и слать по очереди, чтобы поздняя правка не легла раньше ранней. Чего нельзя - слать их конкурентно: без гарантии порядка получите гонку, где итог зависит от того, какой запрос пришёл первым.
Теперь доставка. Хочется, чтобы браузер сам дослал очередь при возвращении сети, даже если вкладка закрыта, - для этого есть Background Sync. One-off sync просит браузер разбудить service worker, когда связь вернётся: страница после durable-записи регистрирует тег через registration.sync.register, а worker слушает событие sync. Важная оговорка: этот API не Baseline, его нет в части браузеров, - поэтому он строится через feature detection, с фолбэком на отправку из foreground.
У Background Sync своя модель, и иллюзий она не терпит. Момент запуска и политику повторов определяет браузер, а не ваш код; сам worker живёт недолго и может быть остановлен между событиями. Отсюда требования к обработчику: он заново читает durable outbox из IndexedDB, а не полагается на глобальные переменные worker; он идемпотентен, потому что событие может повториться; и он оборачивает работу в Promise внутри event.waitUntil, иначе браузер усыпит worker посреди отправки.
Periodic Background Sync и Background Fetch решают смежные задачи, но поддержка у них ещё уже, а условия запуска ещё жёстче контролируются браузером. Вывод один: ни один из этих механизмов не должен быть единственным способом синхронизации. Стройте на feature detection и всегда держите foreground-путь, который дошлёт outbox, пока приложение открыто. Классический провал - положиться на sync как на гарантию: пользователь правит заметку офлайн, закрывает вкладку в браузере без API, и правка не уходит никогда.
type OutboxOperation = {
operationId: string; // UUID генерирует клиент
entityId: string;
kind: 'note.upsert' | 'note.delete';
payload: unknown;
baseVersion: number | null;
createdAt: string;
attempt: number;
nextAttemptAt: string;
state: 'pending' | 'sending' | 'conflict' | 'failed';
};// страница - после надёжной записи в outbox
const registration = await navigator.serviceWorker.ready;
if ('sync' in registration) {
await registration.sync.register('flush-outbox');
} else {
scheduleForegroundFlush();
}
// sw
self.addEventListener('sync', event => {
if (event.tag === 'flush-outbox') {
event.waitUntil(flushOutbox());
}
});