Перейти к содержимому
Skein
← Все проекты

frontend · advanced · 7d

Офлайн-синхронизация PWA

Notes PWA с офлайн-приоритетом: локальная очередь записей (IndexedDB), которая синхронизируется при переподключении через last-writer-wins, service worker для кеширования ресурсов и background sync для пропущенных записей.

Офлайн-first легко обещать и сложно сделать правильно. Ты построишь архитектуру очереди записей на IndexedDB, переживающую перезагрузки, service worker, который делает оболочку устанавливаемой и быстрой с дифференцированным кешированием, стратегию разрешения конфликтов, объяснимую менеджеру продукта (LWW и где она ломается), и оптимистичный UI, ощущающийся мгновенным. Background Sync делает переподключение автоматическим даже при закрытой вкладке — а финальный этап превращает PWA в продукт: дашборд синхронизации, инцидент нестабильной сети, обнаруживаемый по метрикам, и пост-мортем, чья превенция — не 'никогда не уходи офлайн'.

Результат

Notes PWA, полностью работающая офлайн (очередь IndexedDB переживает перезагрузку), синхронизирующаяся при переподключении через LWW (per-field merge в стретче), отдающая оболочку через версионированный service worker и сливающая через Background Sync даже при закрытой вкладке — со статусом синхронизации и метриками.

Этапы

0/5 · 0%
  1. 01Локальная очередь записи (IndexedDB)

    Проведи все чтения и записи через очередь в IndexedDB и отрисуй UI из локального состояния — чтобы приложение работало при DevTools в Offline и запись переживала перезагрузку. Очередь — append-лог `{id, op: 'create'|'update'|'delete', payload, local_updated_at, status: 'pending'|'synced'}`, ключ по id заметки; чтения мёрджат ожидающую очередь поверх последнего синкнутого снапшота, так что UI всегда оптимистичный. Критический инвариант — долговечность: запись, сделанная офлайн, синхронно пишется в IndexedDB (в том же тике что обновление UI) и остаётся в очереди после перезагрузки страницы — если она живёт только в памяти, перезагрузка до переподключения её теряет. Докажи: уйди офлайн, создай/отредактируй заметку, перезагрузи страницу оставаясь офлайн и покажи, что заметка всё ещё там и всё ещё pending; затем уйди онлайн и покажи синк.

    Критерии готовности
    • Все чтения/записи идут через очередь в IndexedDB; UI рендерится из локального состояния и работает при DevTools Offline.
    • Запись офлайн переживает перезагрузку (всё ещё в очереди, не потеряна), число ожидающих видно в UI — проверено тестом офлайн → запись → перезагрузка → всё ещё pending.
    Самопроверка

    Покажи офлайн → запись → перезагрузка → всё ещё pending, затем онлайн → синкнуто. Senior-ревьюер проверяет очередь в IndexedDB (не в памяти) и мёрдж pending поверх последнего синкнутого снапшота.

  2. 02Кеширование service worker (SWR vs cache-first)

    Добавь service worker с дифференцированными стратегиями кеширования и корректным жизненным циклом обновления. Оболочка приложения (HTML) — stale-while-revalidate: всегда быстро (отдай из кеша немедленно, обнови фоном) с окном устаревания в один визит, приемлемым для оболочки. Версионированная статика (JS/CSS с хэш-именами) — cache-first: она по-настоящему неизменяема, отдавай из кеша без ревалидации. Новый деплой должен обновить кешированную оболочку при следующем визите без жёсткого обновления: новый воркер устанавливается, ждёт в 'waiting' пока все вкладки не выгрузятся (или пока не вызван skipWaiting), затем активируется и захватывает клиентов. Задокументируй компромисс: ожидание закрытия вкладок — 'устаревший до перезапуска', но без рассогласования в середине сессии; skipWaiting — немедленная активация, но риск отдать новую оболочку против старого набора ресурсов. Укажи окно устаревшего контента для каждой стратегии и почему жизненный цикл делает тайминг обновления неочевидным.

    Критерии готовности
    • Оболочка грузится из service worker офлайн; оболочка — stale-while-revalidate, хешированная статика — cache-first — проверено офлайн через DevTools и инспекцию cache storage.
    • Новый деплой обновляет кешированную оболочку при следующем визите без жёсткого обновления; жизненный цикл (install → waiting → activate) и компромисс skipWaiting задокументированы с числами окон устаревания.
    Самопроверка

    Покажи загрузку оболочки офлайн из SW и обновление нового деплоя при следующем визите (cache storage до/после). Senior-ревьюер проверяет дифференциацию SWR vs cache-first, корректность жизненного цикла и указание компромисса skipWaiting.

  3. 03Синхронизация с LWW при переподключении

    Сливай очередь записей при переподключении, разрешай конфликты через last-writer-wins и регистрируй тег Background Sync как запасной для пропущенных сливов. При переподключении (событие online + visibilitychange + Background Sync) итерируй ожидающую очередь по порядку и POSTь каждую операцию на сервер; сервер сравнивает `server_updated_at` против `local_updated_at` и оставляет более позднюю (LWW). Ни одна правка не теряется молча — проигравшая версия хотя бы логируется и показывается в UI как конфликт. Регистрируй тег Background Sync (`sync-pending-writes`) на каждой записи, чтобы браузер запускал sync-событие даже если вкладка была закрыта в момент переподключения. Затем учти оговорку поддержки браузеров: частичная поддержка Background Sync в Safari означает, что очередь должна сливаться и по page-visible и online как фолбэк. Задокументируй, где LWW на уровне документа ломается: два пользователя редактируют разные поля одной заметки — более поздний слив затирает изменение поля другого, потому что ключ слияния — документ + метка времени, а не поле + метка времени.

    Критерии готовности
    • При переподключении очередь сливается по порядку; конфликты решаются LWW сравнением server vs local updated_at, проигравшие версии логируются/показываются, ни одна правка не теряется молча — проверено фикстурой конкурентного конфликта.
    • Тег Background Sync регистрируется при записи и сливает очередь даже если вкладка была закрыта в момент переподключения; фолбэк-слив по online/visibilitychange реализован, оговорка Safari задокументирована.
    Самопроверка

    Покажи фикстуру конфликта (параллельное редактирование) с разрешением LWW и логированием проигравшего и регистрацию тега Background Sync. Senior-ревьюер проверяет упорядоченность слива, сравнение LWW по updated_at и объяснение провала LWW документа против поля.

  4. 04Оптимистичный UI и статус синхронизации

    Сделай офлайн-опыт мгновенным и читаемым. Каждая локальная запись немедленно обновляет UI (оптимистично) до подтверждения синком — без спиннера на создании/редактировании/удалении. Статус синхронизации всегда видим: постоянный индикатор показывает 'Всё синкнуто' / 'Синхронизация…' / 'Офлайн — N ожидает' / 'Конфликт в заметке X', каждая заметка показывает своё состояние синка (синкнуто vs ожидает). Растущая офлайн очередь показана счётчиком, конфликт, разрешённый через LWW, показывает какая версия победила и почему (сравнение меток времени). Измерь воспринимаемую латентность: с оптимистичным UI создание-до-видимости <16 мс (один кадр); без него — RTT сети + время сервера. Докажи, выключив оптимистичность и показав задержку, и уйдя офлайн, поставив 5 правок и показав мгновенное появление всех 5 с корректными переходами статуса при синке на переподключении.

    Критерии готовности
    • Локальные записи оптимистичны (UI обновляется до подтверждения синка); индикатор статуса показывает Всё синкнуто / Синхронизация / Офлайн N ожидает / Конфликт, каждая заметка показывает своё состояние синка.
    • Постановка 5 правок офлайн мгновенно показывает все 5 с бейджами ожидания; при переподключении они переходят в синкнуто/конфликт с объяснением победителя по метке времени — проверено офлайн-батч тестом.
    Самопроверка

    Покажи воспринимаемую латентность оптимистично vs не-оптимистично и 5 офлайн-правок с переходами статуса при переподключении. Senior-ревьюер проверяет живость счётчика очереди, отображение выигравшей метки времени в конфликте и юзабельность UI офлайн без спиннера на записи.

  5. 05Наблюдай синк и отработай инцидент

    Сделай синк наблюдаемым и докажи выживание при инциденте. Снимай метрики офлайн-подсистемы: глубина ожидающей очереди, rate успеха/неудачи слива, длительность синка p50/p99, rate конфликтов, hit rate кеша service worker. Покажи их на маленьком дашборде или панели консоли. Затем отработай инцидент: симулируй нестабильную сеть (периодические сбои эндпоинта слива) на середине синка и наблюдай стопор очереди — глубина pending перестаёт падать, включается retry backoff, накапливаются конфликты если сервер мутировал те же заметки. Обнаружи по дашборду (глубина pending плоская, rate неудач вверх), смягчи (retry с backoff, покажи конфликты пользователю, не дублируй очередь) и напиши пост-мортем на 5 строк, чья превенция — не 'никогда не уходи офлайн'. Задокументируй режимы отказа Background Sync: браузер запускает sync когда считает сеть доступной, но не гарантирует время доставки; неудачный sync перепоставляется с экспоненциальным backoff под контролем браузера; если браузер убьёт SW до завершения синка, тег остаётся и ретрайнится — но только где Background Sync поддерживается.

    Критерии готовности
    • Дашборд/панель показывает глубину pending, rate успеха/неудачи слива, p50/p99 синка и hit rate кеша SW; инцидент нестабильной сети воспроизведён, обнаружен по дашборду и смягчён backoff без дублирования очереди.
    • Пост-мортем называет корневую причину и превенцию, которая не 'никогда не уходи офлайн' или 'отключи Background Sync', режимы отказа Background Sync задокументированы.
    Самопроверка

    Вставь дашборд и пост-мортем инцидента нестабильной сети. Senior-ревьюер проверяет, что глубина pending + rate слива локализовали стопор, backoff экспоненциальный (не tight loop), режимы отказа Background Sync указаны.

Стартер

fallowlone/skein-projects

projects/offline-pwa-sync

Открыть на GitHub ↗
  • README.md
  • src/sync.ts
  • test/sync.test.ts
Забрать только этот проект npx degit fallowlone/skein-projects/projects/offline-pwa-sync offline-pwa-sync

Реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Форкни репозиторий и запушь свою работу — workflow grade прогонит тесты и статические проверки на твоих раннерах.

Рубрика

Джуниор Миддл Сеньор
Стратегия кэширования service worker Service worker зарегистрирован и перехватывает fetch, но кэширование единообразно: одна стратегия для оболочки, API-ответов и статики. Оболочка использует stale-while-revalidate (всегда быстро, обновляется фоном), статика — cache-first (неизменяемые хэшированные имена файлов), а новый деплой вызывает замену кэша при следующем визите без жёсткого обновления. Ты можешь назвать окно устаревшего контента для каждой стратегии и объяснить, почему жизненный цикл service worker (install → waiting → activate) делает тайминг обновления неочевидным: новый воркер ждёт закрытия всех вкладок перед захватом, а skipWaiting меняет компромисс с «устаревший до перезапуска» на «потенциальная рассогласованность в середине сессии».
Надёжность очереди записи и фоновая синхронизация Правки хранятся в памяти офлайн; перезагрузка до переподключения теряет их. Очередь записей живёт в IndexedDB и переживает перезагрузки; при переподключении очередь сбрасывается на сервер, и ни одна правка не теряется молча. Тег Background Sync регистрируется при записи, чтобы сброс сработал даже если вкладка была закрыта в момент переподключения; ты можешь назвать оговорку по поддержке браузеров (частичная поддержка Safari) и поведение при отсутствии Background Sync.
Разрешение конфликтов при переподключении При переподключении локальная запись молча затирает сервер, или сервер побеждает без проверки меток времени — данные одной из сторон исчезают незаметно. Конфликты разрешаются сравнением server_updated_at и local_updated_at (last-writer-wins); ни одна правка не выбрасывается молча — проигравшая версия хотя бы логируется. Ты можешь указать, где LWW на уровне документа ломается (два пользователя редактируют разные поля одной заметки; более поздний сброс затирает правку другого) и объяснить слияние на уровне полей из стретча — изменения разных полей никогда не перезаписывают друг друга, потому что ключ слияния — поле + метка времени, а не документ + метка времени.
Эталонный разбор (спойлер)

Выбор стратегии кэша: stale-while-revalidate подходит для оболочки, потому что пользователи всегда получают быстрый ответ, а окно устаревания (один визит) приемлемо; cache-first подходит для версионированной статики (JS/CSS с хэш-именами файлов), потому что она по-настоящему неизменяема. Cache-first для оболочки грозит бесконечной раздачей устаревшей версии при сбое логики обновления.

Тайминг обновления service worker: новый воркер ждёт в состоянии 'installing', пока все открытые вкладки не выгрузятся, затем переходит в 'active'. skipWaiting форсирует немедленную активацию, но рискует рассогласованием в середине сессии, если старый и новый кэши различаются. Правильный выбор зависит от того, вызывает ли несовпадение версий жёсткий сбой или только устаревший UI.

LWW на уровне документа против поля: last-writer-wins по updated_at чисто разрешает конкурентные сбросы, когда каждая заметка принадлежит одному пользователю. Молча теряет работу, когда два пользователя редактируют разные части одного документа, потому что более поздний сброс заменяет весь документ. Слияние на уровне полей — ключевание разрешения конфликтов по идентичности поля, а не документа — предотвращает это без полноценного CRDT.

Режимы отказа Background Sync: браузер запускает sync-событие, когда считает сеть доступной, но не гарантирует доставку в конкретном временном окне. Неудачная синхронизация повторно ставится в очередь с экспоненциальной паузой, управляемой браузером. Если браузер убьёт service worker до завершения синхронизации, тег остаётся зарегистрированным и будет повторён — но только если браузер поддерживает Background Sync; частичная реализация Safari означает, что очередь должна сбрасываться и при переходах page-visible как запасной вариант.

Сделай по-сеньорски

  • Замени last-writer-wins на слияние на уровне полей: изменения в разных полях одной заметки не затирают друг друга (поле + метка времени как ключ слияния).
  • Добавь стек undo, работающий через переходы офлайн/онлайн без порчи очереди синхронизации — undo не должен порождать новую ожидающую операцию, конфликтующую с собой.
  • Добавь сквозное шифрование заметок, чтобы сервер никогда не видел открытый текст, а конфликты разрешались на зашифрованных payload по метке времени.

Навыки

IndexedDB write queue (durable, reload-safe)Service Worker caching strategies (SWR vs cache-first)Background Sync API + fallbacklast-writer-wins & per-field mergeoptimistic UI & sync observability

Рекомендуемый стек

preactworkboxhono

Материалы