Фоновая доставка из прошлой главы удобна, но строить на ней весь продукт нельзя: Background Sync есть в Chromium и отсутствует, например, на iOS. Надёжное приложение обязано досылать отложенные операции и без него - силами самой открытой вкладки. Это foreground sync: та же очередь (outbox) в IndexedDB, но запускают её обычные события страницы, а не системный планировщик.
Поводов запустить отправку несколько, и полагаться на один опасно. Разумный набор: старт приложения, событие online, возврат вкладки в видимое состояние, кнопка Повторить и момент сразу после успешного ответа API. При этом online - лишь повод попытаться, а не доказательство связи: между вами и сервером может стоять captive portal или мёртвый мобильный канал. Поэтому online не меняет состояние очереди напрямую - оно вызывает попытку, исход которой решает реальный запрос.
Триггеров много, вкладок и worker'ов тоже - нужна защита от параллельной отправки, иначе две вкладки возьмут одну операцию и создадут на сервере дубль. Single-flight lock гарантирует, что flushOutbox() в каждый момент выполняет только один участник. Практично взять Web Locks API (navigator.locks.request) с fallback на lease-запись в IndexedDB - короткую аренду с истечением, которую держатель продлевает, чтобы зависшая вкладка не держала очередь навсегда.
Внутри блокировки перед отправкой перечитайте (reread) операцию и убедитесь, что её не отменили и не отправили в другой вкладке. Каждый запрос снабжайте Idempotency-Key, чтобы повтор после обрыва не создал вторую заметку. Ответы делятся на классы: 4xx обычно permanent или конфликт и повтором не чинятся, а 408, 429 и 5xx retryable, причём для 429 и 503 нужно уважать Retry-After. Между попытками - экспоненциальная задержка с полным джиттером, иначе все клиенты ударят по серверу разом (thundering herd).
Досыл решает доставку, но не согласие. Как только одну заметку правят с двух устройств, возникает конфликт, и простейший ответ - last-write-wins, прав тот, кто записал последним, - опаснее, чем кажется. Он тривиален в коде и молча уничтожает чужую работу: правка исчезает без следа. Для низкоценных данных это терпимо, для заметок и заказов - нет.
Чтобы обсуждать конфликт, клиент должен помнить base - снимок записи и его версию (baseVersion), иначе видно два разных текста, но не понять, что и с чьей стороны изменилось. Сервер на несовпадение версий отвечает 409 Conflict и отдаёт свою версию и общего предка, и вместо пугающего Ошибка 409 приложение показывает conflict screen: вот ваш вариант, вот серверный, вот исходный. Удаление требует аккуратности: стирать запись физически нельзя, иначе старый клиент воскресит её при следующем sync - вместо этого ставят tombstone, помеченную удалённой запись.
Единой верной политики нет - выбор зависит от ценности данных и модели совместной работы. Server wins и client wins просты, но каждый теряет одну из сторон. LWW по timestamp годится лишь там, где данные дёшевы, а часами управляет сервер. Для обычных CRUD-сущностей разумна оптимистическая блокировка (optimistic concurrency) по версиям - ценой UI разрешения конфликта. Слияние по независимым полям спасает при непересекающихся правках, а CRDT честно решает совместное редактирование ценой сложности и метаданных. Показательный провал одинаков: двое работали офлайн, синхронизировались - и один молча потерял час труда, потому что система решила за него.
| Политика | Когда подходит | Риск |
|---|---|---|
| Server wins | Локальная правка не критична | Потеря локального intent |
| Client wins | Один владелец, полная замена допустима | Затирание remote update |
| LWW timestamp | Низкая ценность, часы контролируются сервером | Потеря причинности |
| Optimistic concurrency | Обычные CRUD-сущности | Нужен conflict UI |
| Field merge | Независимые поля | Семантические конфликты |
| CRDT | Совместное редактирование | Сложность и метаданные |
PUT /notes/n-42
X-Base-Version: 7
Idempotency-Key: 7bf4...
409 Conflict
{
"serverVersion": 8,
"server": { "title": "Remote", "body": "..." },
"base": { "title": "Old", "body": "..." }
}const triggerFlush = debounce(() => flushOutbox(), 300);
addEventListener('online', triggerFlush);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') triggerFlush();
});
function retryDelay(attempt) {
const cap = Math.min(60_000, 1000 * 2 ** attempt);
return Math.random() * cap; // full jitter
}