Содержание
Пользователь нажал «Сохранить» в подвале, в цеху или в поезде. Сеть пропала на полпути. Обычное приложение на React в этот момент либо показывает ошибку, либо делает вид, что запись ушла. Оба исхода плохие: данные введены, а на сервере их нет, и после перезагрузки вкладки их нет и локально.
offline-first — это не проверка navigator.onLine. Это архитектура, в которой локальная база становится надёжным слоем данных, а сеть — способом синхронизации. Интерфейс обновляется сразу, изменение переживает закрытие вкладки, уходит на сервер в том порядке, в каком его сделали, и повторная отправка не создаёт второй документ.
Ниже — рабочая схема на TypeScript: TanStack Query как реактивный кэш, Dexie поверх IndexedDB как локальная истина, очередь исходящих операций и сервер, который узнаёт повтор по ключу. Идея соседствует с заметкой Наиника Мехты на Dev.to; текст ниже — самостоятельная сборка для продакшена, включая полевые и учётные системы. Рядом по клиентской архитектуре: карта React-стека, каркас Bulletproof React и переход на TanStack.
Ключевые выводы
Сначала запись в локальную базу, потом доставка. Если интерфейс уже показывает строку, которой нет в IndexedDB, перезагрузка её сотрёт.
Кэш TanStack Query не заменяет очередь. Он восстанавливает экран. Функцию мутации после перезапуска процесса в базу не положить: останутся ключ, аргументы и статус, но не сам код отправки.
У сущности и у операции разные идентификаторы. Клиентский UUID позволяет создать объект без ответа сервера. Отдельный идентификатор операции нужен, чтобы повтор того же запроса не породил дубль.
Порядок внутри одной сущности строгий, между разными сущностями — можно параллелить. Обновление не должно обогнать создание того же объекта.
Надёжная доставка и разрешение конфликта — разные задачи. Очередь гарантирует, что запрос дойдёт. Она не решает, чья правка победит, если тот же объект изменили на другом устройстве.
Чем offline-first отличается от «показать кэш»
В обычном приложении цепочка короткая. React читает и пишет через TanStack Query, тот ходит в HTTP, сервер кладёт строку в свою базу. Пока сеть есть, схема честная: кнопка ждёт ответ, и только потом интерфейс уверен в результате. Когда сети нет, операция просто не выполняется. Пользователь видит спиннер, потом ошибку, и введённый текст остаётся только в памяти компонента.
Приложение в режиме offline-capable умеет показать что-то без сети: прошлый список, заглушку, сохранённый экран. Это полезно для чтения. Для записи этого мало. Инспектор, кладовщик или врач, который заполнил карточку и закрыл крышку ноутбука, должен найти ту же карточку после открытия. И сервер должен получить её позже, один раз, в правильном виде.
offline-first переворачивает стрелку. Интерфейс говорит с локальной базой. Локальная база держит и сами объекты, и очередь того, что ещё не подтверждено сервером. Сеть появляется — очередь проигрывается. Сервер остаётся авторитетом для проверок, прав доступа и итогового состояния, но он больше не обязан быть в сети в ту секунду, когда человек нажал кнопку.
React → TanStack Query → Dexie / IndexedDB
├── объекты
└── очередь операций → API → сервер
Почему сохранённый кэш запроса не довозит запись
TanStack Query умеет переживать перезагрузку, если кэш положили в постоянное хранилище. При старте приложение читает IndexedDB, собирает QueryClient заново, и React видит списки и статусы, которые были на экране. Пауза мутаций тоже может сохраниться: ключ, переменные, статус, метаданные.
Ломается другое. Мутация — это данные плюс функция. Типичный обработчик выглядит так:
mutationFn: async (data) => api.items.create(data)
Функцию в IndexedDB не сериализовать. После перезапуска процесса замыкание мертво. В хранилище остаются ключ и аргументы, а кода, который знает, как отправить эти аргументы, нет. Пока приложение не зарегистрирует функцию заново, возобновлять мутацию нечем.
Для этого и нужны значения по умолчанию для мутаций: queryClient.setMutationDefaults. Стабильный ключ вроде ['items', 'create'] сопоставляется с функцией, которая снова жива в этом запуске. Восстановленная мутация находит функцию по ключу и продолжает работу.
Порядок запуска здесь жёсткий. Если сначала восстановить кэш и сразу вызвать resumePausedMutations, а функции ещё не зарегистрированы, мутация не сможет продолжиться. Сначала импорт правил мутаций, потом клиент, потом гидратация, потом возобновление.
Ключ нельзя делать случайным. ['create-item', Math.random()] после перезагрузки не совпадёт ни с одним зарегистрированным правилом. Ключ должен описывать вид операции, а не конкретный клик.
Три уровня, без которых запись теряется
Имеет смысл не выбирать «или кэш, или очередь», а сложить три уровня, у каждого своя гарантия.
Первый — постоянный кэш запросов. Он нужен, чтобы после открытия вкладки человек увидел последние списки и не смотрел в пустой экран, пока сеть молчит.
Второй — восстановимые мутации. Пауза TanStack Query плюс заново зарегистрированная функция закрывают случай «запрос уже был в полёте, вкладку перезагрузили, функция должна ожить».
Третий — долговечная очередь, outbox. Каждая пользовательская запись порождает локальную операцию, которая обязана доехать до сервера. Очередь лежит в IndexedDB, а не в памяти мутации. Ей всё равно, жив ли ещё тот вызов useMutation, который её создал.
Повторы внутри одной мутации слабее. Они живут, пока выполняется конкретный вызов. Закрыли вкладку, упал процесс, сервер ответил с опозданием и ответ потерялся — повтор мог не пережить это. Очередь переживает перезагрузку, падение, закрытие вкладки, пропажу сети и временную ошибку сервера.
интерфейс
↓
TanStack Query
↓
Dexie
├── данные
└── очередь → работник синхронизации → API → сервер
Почему localStorage не тянет очередь
localStorage синхронный, маленький и хранит строки. Для пары настроек этого хватает. Для списка заявок, фотографий осмотра и очереди операций — нет. Запись блокирует основной поток, индекса нет, выбрать «все несинхронизированные операции этой сущности по времени» неудобно, а транзакции «объект и операция вместе» нет.
IndexedDB асинхронный, держит структурированные записи и индексы, нормально живёт с очередью. Dexie — тонкая оболочка с типами: таблицы, версии схемы, транзакции. Не второй источник правды, а способ не писать сырой API браузера на каждом экране.
Минимальная схема для учебного контура учёта:
items
├── id клиентский UUID
├── title
├── updatedAt
└── syncStatus synced | queued | syncing | failed | conflict
outbox
├── id идентификатор операции
├── entityId
├── action create | update | delete
├── mutationKey
├── variables
├── createdAt
├── attempts
└── status
Две таблицы нарочно. Объект — то, что видит человек. Операция — то, что должен получить сервер. Смешивать их в одну запись значит потерять историю «создали, потом поправили, потом удалили» и не понять, что ещё не доехало.
Где лежит истина после клика
Есть два соблазна. Первый: TanStack Query — источник истины, Dexie только снимок кэша. Второй: Dexie — локальная истина, TanStack Query — реактивное окно в неё.
Для чтения кэша хватает первого. Для записи нужен второй. Иначе легко получить экран, на котором строка уже есть, а в базе её ещё нет: оптимистичное обновление кэша прошло, транзакция не записалась, вкладку закрыли. После открытия строки нет, и человек уверен, что программа «съела» работу.
Правильный порядок клика:
правка
↓
транзакция Dexie: обновить объект и добавить операцию
↓
инвалидация или подписка `TanStack Query`
↓
интерфейс
Сервер по-прежнему авторитетен для правил: валидация, доступ, идемпотентность, обнаружение конфликта. Локальная база авторитетна для того, что пользователь уже сделал на этом устройстве и что ещё не подтверждено. Путать эти две роли — либо блокировать кнопку сетью, либо показывать успех, которого нет ни локально, ни на сервере.
Как поднять постоянный кэш
Пакеты, без которых схема не собирается:
npm install @tanstack/react-query
npm install @tanstack/react-query-persist-client
npm install @tanstack/query-async-storage-persister
npm install dexie
Персистер — адаптер хранилища с getItem, setItem и removeItem. Его можно положить на таблицу Dexie, не обязательно на localStorage. Дальше провайдер оборачивает приложение:
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24,
}}
>
<App />
</PersistQueryClientProvider>
Сутки в maxAge — пример, не норма. Важнее связка со сборщиком мусора кэша. gcTime у QueryClient должен быть не меньше срока жизни сохранённого кэша. Иначе персистер честно хранит запись, а клиент выбрасывает её как просроченную сразу после гидратации, и экран снова пустой.
Порядок старта, если смотреть на всё приложение, а не только на кэш:
старт
↓
чтение IndexedDB
↓
сборка QueryClient и правил мутаций
↓
гидратация кэша
↓
возобновление пауз
↓
проигрывание очереди
↓
React
Кэш здесь восстанавливает чтение. Очередь восстанавливает обязательства по записи. Оба шага нужны, и второй не вытекает из первого автоматически.
Как вернуть мутацию после перезагрузки
Кэш запроса — это данные. Мутация — данные и ссылка на функцию. Ссылка умирает вместе с процессом. Поэтому ключ должен быть декларативным: ['items', 'create'], ['items', 'update'], ['items', 'delete']. В аргументах лежит идентификатор сущности и тело. В ключе — только вид работы.
Регистрация выглядит так:
queryClient.setMutationDefaults(['items', 'create'], {
mutationFn: async (variables) => api.items.create(variables),
})
Эту регистрацию нельзя прятать внутрь компонента, который монтируется «когда-нибудь». Она должна выполниться при создании клиента, до гидратации. Иначе гонка стабильная: восстановление раньше, чем модуль с правилами успел выполниться, и пауза зависает без функции.
Сама по себе восстановленная мутация всё ещё хрупка, если тело запроса живёт только в памяти TanStack Query. Очередь в Dexie дублирует обязательство нарочно. Мутация может быть удобным способом дернуть сеть, когда она есть. Очередь — способ не потерять намерение, когда мутации уже нет.
Очередь операций
outbox — локальный список того, что сервер ещё не подтвердил. Для одной сущности он может выглядеть так: создать A, обновить A, удалить B. Пока сети нет, клики только дописывают этот список и правят локальные объекты. Когда сеть появилась, работник читает список и шлёт запросы.
Почему это надёжнее повтора внутри мутации: повтор привязан к живому вызову. Очередь привязана к строке в базе. Ей не важно, какой компонент её создал и жива ли вкладка, в которой нажали кнопку.
Минимальный проигрыватель:
async function drainOutbox() {
const pending = await db.outbox.orderBy('createdAt').toArray()
for (const operation of pending) {
await processOperation(operation)
}
}
Успех — ответ 200 или 201: операцию удаляют из очереди, локальный объект помечают как синхронизированный. Временный сбой оставляют в очереди. К временным относятся обрыв сети, таймаут, 408, 429, 500, 502, 503, 504. К постоянным — 400, 401, 403 и ошибки проверки полей. 404 зависит от контракта: для обновления отсутствующего объекта это часто конец, для удаления — иногда уже успех. Классификацию нельзя скопировать из чужого списка вслепую: она следует из вашего API.
Бесконечно повторять 400 значит жечь батарею и забивать журнал. Повтор раз в секунду на 503 похож на самодельный отказ в обслуживании собственного сервера. Экспоненциальная пауза — 1, 2, 4, 8, 16, 32 секунды — плюс случайный сдвиг, чтобы сто вкладок не проснулись в одну миллисекунду. Формула паузы: база умножить на два в степени попытки, плюс случайная добавка.
Связанные операции одной сущности идут строго по времени. Создать, потом обновить, потом удалить. Если отправить обновление раньше создания, сервер честно ответит, что объекта нет, и очередь застрянет или создаст мусор. Разные сущности можно вести параллельно: у A своя цепочка, у B своя. Параллелить шаги внутри A нельзя.
Клиентский идентификатор и повтор без дубля
Классическое создание ждёт, пока сервер выдаст id. Без сети этот ответ не придёт, и локальную строку не к чему привязать. Следующая правка, удаление и сам экран разъедутся. Идентификатор нужно выдавать на клиенте: crypto.randomUUID(). Объект, операция и будущий запрос ссылаются на него сразу.
Этого мало для повтора. Разведите два идентификатора. entityId — кто этот объект. operationId — какая это попытка изменить мир. Одна операция сохраняет один operationId на все повторы.
Сценарий потери ответа типичный. Клиент послал POST, сервер создал строку, ответ не дошёл. Клиент считает, что запрос не дошёл, и шлёт ещё раз. Без ключа на сервере два объекта. С заголовком Idempotency-Key: <operationId> сервер узнаёт повтор и не делает второй побочный эффект: возвращает прежний результат.
Даже если две вкладки ошиблись и отправили одну операцию дважды, сервер остаётся последним запором. Клиентские замки уменьшают гонку, но не заменяют идемпотентность.
Атомарная запись и путь создания
Создание обязано сделать два шага вместе: вставить объект и вставить операцию. Если записать только объект, интерфейс показывает строку, которую никто никогда не отправит. Если записать только операцию, очередь попытается отправить то, чего на экране нет, или отправит тело без локальной строки для статуса.
await db.transaction('rw', db.items, db.outbox, async () => {
await db.items.add(item)
await db.outbox.add(operation)
})
Транзакция Dexie откатывает обе записи, если вторая не прошла. После неё интерфейс имеет право обновиться. Не наоборот.
Полный путь:
клик «Создать»
↓
UUID сущности и UUID операции
↓
транзакция: объект + операция
↓
экран обновляется сразу
↓
сеть есть? отправить с Idempotency-Key
нет? ждать
↓
успех → удалить операцию, пометить synced
Обновление и удаление идут тем же коридором. Меняется только действие в очереди и метод HTTP: создание — POST, правка — PUT или PATCH, удаление — DELETE. Локально все три видны сразу. Отдельный «особый» путь для удаления без очереди снова дырявит гарантию.
Удаление больнее создания. Жёсткое стирание локальной строки до подтверждения сервера оставляет дыру, если отправка не удалась и объект нужно показать снова как «не удалился». Мягкое удаление — поле deletedAt — прячет строку из списка и оставляет след для очереди. Отдельно решите, что будет, если тот же объект уже изменили на другом устройстве: удаление не отменяет чужую более новую правку само собой.
Что видит человек и когда звать сеть
Без явного состояния синхронизации offline-first ощущается как поломка: кнопка молчит, строка «как будто сохранилась», а через час её нет на сервере. Пять состояний закрывают почти все разговоры с пользователем: synced, queued, syncing, failed, conflict. На экране это «синхронизировано», «ждёт отправки», «отправляется», «ошибка», «конфликт». Компонент статуса читает поле объекта, а не глобальный флаг «мы онлайн».
Событие online — повод попробовать очередь, не доказательство, что API жив. navigator.onLine === true бывает и в сети, где ваш сервер не отвечает. Поэтому обработчик не помечает всё синхронизированным. Он вызывает проигрывание, а успех решает ответ API.
window.addEventListener('online', () => {
void drainOutbox()
})
Ту же попытку стоит делать при старте, при возвращении на вкладку и, если очередь длинная, по таймеру с той же растущей паузой. Иначе ноутбук, который ни разу не поймал событие online, будет хранить операции до ручного обновления страницы.
Фоновая синхронизация через service worker умеет дотолкать очередь, когда вкладка уже закрыта. Это ускорение, не фундамент. Поддержка в браузерах разная, у Safari на iOS ограничения жёсткие. Приложение обязано довозить операции при следующем открытии, даже если фонового API нет.
Две вкладки и чужая правка
Две вкладки могут одновременно прочитать очередь и отправить одну операцию дважды. Лечится на клиенте замком: Web Locks API, выбор одной ведущей вкладки, BroadcastChannel, чтобы остальные не начинали тот же проход. Лечится на сервере ключом идемпотентности. Второе обязательно, первое желательно. Если замок не взялся, а ключа нет, дубль уже в базе.
Конфликт — другая ось. Два человека поправили одну заявку, или этот клиент сидел офлайн на старой версии, а сервер уже ушёл вперёд. Очередь тут бессильна: она доставит ваш запрос. Она не знает, можно ли затереть чужое.
Практические стратегии, и их нельзя смешивать в одну галочку «разрешение конфликтов»:
| Подход | Когда уместен | Цена |
|---|---|---|
| Последняя запись побеждает | Черновики, низкая цена затирания | Тихая потеря чужой правки |
Номер версии или ETag / If-Match |
Учётные документы | 409, нужна реакция в интерфейсе |
| Слияние по полям | Справочники и независимые поля | Сложные правила, не для всех сущностей |
| Разбор на сервере | Регламент, аудит | Дольше, зато есть журнал |
Надёжная доставка не равна разрешению конфликта. Если в продукте два устройства редактируют одно и то же, стратегия нужна до запуска очереди, а не «потом добавим».
Поле, склад и учёт
Тот же рисунок живёт не только в учебном списке задач. Он нужен там, где человек не может потерять ввод из-за связи: склад, обход оборудования, чек-лист инспекции, мобильное PWA, CRM в дороге, цех с мёртвой зоной. Локальная транзакция, IndexedDB, очередь, служба синхронизации, API учётной системы, PostgreSQL или другая серверная база.
Для крупных документов одной очереди outbox мало. Понадобятся версии, журнал аудита, серверная последовательность и оптимистическая блокировка. Фото и подпись — тоже операции очереди, только тело тяжелее: их нельзя молча обрезать, когда кончилось место в браузере. Справочники имеет смысл качать заранее и хранить локально для чтения, а наружу отправлять только факты работы: заявку, результат осмотра, технологическую отметку.
Как резать ответственность в таком контуре, удобно помнить таблицей:
| Слой | Держит у себя |
|---|---|
Dexie |
Долговечное состояние, очередь, транзакции |
TanStack Query |
Реактивность, загрузку, кэш, жизненный цикл мутации |
| Работник синхронизации | Проигрывание, повтор, порядок, паузу |
| Сервер | Проверку, доступ, идемпотентность, конфликт |
Это не замена учётной системе и не повод тащить всю ERP в браузер. Это способ не потерять действие пользователя на краю сети. Про устройство платформы учёта — в проекте современной учётной платформы и интеграции с учётной системой. Про клиент, который уезжает с рабочего места, — в архитектуре мобильного клиента.
Как проверить и что считать
Ручная проверка «выключил вайфай, вроде работает» не ловит потерю ответа и две вкладки. Минимальный сценарий сквозного теста, его можно собрать на Playwright: открыть приложение, выключить сеть, создать объект, перезагрузить, убедиться, что объект на месте, включить сеть, дождаться одной строки на сервере.
Дальше тот же коридор стоит прогнать отдельно: закрытие вкладки, таймаут, 500, 429, повтор того же ключа, нарушение порядка внутри сущности, две вкладки, конфликт версии, удаление без сети. Пока эти случаи не красные в тесте, схема не готова, даже если счастливый путь зелёный.
Метрики имеют смысл как наблюдаемость очереди, не как обещание чужого процента. Полезно видеть долю успешных проигрываний, среднее и максимальное время жизни операции, число повторов, долю постоянных отказов, долю конфликтов, сколько операций висит и не раздувается ли база. Имена счётчиков могут быть такими: outbox.pending, outbox.failed, sync.duration, sync.retries, sync.conflicts. Это список того, что стоит завести у себя, а не результат чужого стенда.
Каркас файлов, если собирать пример с нуля:
src/
├── api/items.ts
├── db/database.ts
├── db/items.ts
├── db/outbox.ts
├── mutations/itemMutations.ts
├── sync/outboxWorker.ts
├── sync/retry.ts
├── sync/network.ts
├── query/client.ts
├── query/persister.ts
├── components/SyncStatus.tsx
└── App.tsx
Типичные ошибки
Хранить только кэш TanStack Query и считать, что запись «тоже сохранилась». Класть серьёзное состояние в localStorage. Ждать идентификатор только от сервера. Не ставить ключ идемпотентности. Сохранять мутации без setMutationDefaults. Звать resumePausedMutations до регистрации функций. Писать объект и операцию двумя независимыми await без транзакции. Отправлять цепочку одной сущности параллельно. Повторять 400, 401 и 403 без конца. Верить, что navigator.onLine означает живой API. Не показывать статус синхронизации. Забывать про вторую вкладку.
Любая одна из этих дыр выглядит мелочью на демо и теряет документ в поле.
Частые вопросы
Достаточно ли включить сохранение кэша у TanStack Query?
Нет, если вам нужна запись. Сохранение кэша возвращает экран и может вернуть паузу мутации. Функцию отправки и обязательство доставить изменение он не заменяет. Для записи нужна очередь в IndexedDB.
Можно ли оставить localStorage, если данных мало?
Для короткого черновика одной формы иногда да. Как только появляется очередь из нескольких операций, индекс по сущности или транзакция «строка плюс операция», localStorage начинает мешать. Порог лучше не проверять на проде.
Что делать, если сервер сам выдаёт идентификаторы?
Для offline-first клиент всё равно заводит свой UUID и передаёт его серверу как идентификатор или как внешний ключ. Ждать серверный id, чтобы продолжить правку, значит снова зависеть от сети в момент клика.
Нужен ли service worker?
Нет как обязательная часть. Он может отправить очередь в фоне. Приложение должно уметь то же самое при следующем открытии. Иначе на iOS схема молча не работает.
Чем доставка отличается от конфликта?
Доставка отвечает на вопрос «дошёл ли наш запрос». Конфликт отвечает на вопрос «можно ли применить его к тому, что уже лежит на сервере». Очередь решает первое. Версия, ETag или правила слияния решают второе.
Как не создать дубль, если ответ потерялся?
Один operationId на операцию и заголовок Idempotency-Key. Сервер помнит обработанные ключи и на повтор возвращает прежний результат, не выполняя эффект второй раз.
Что почитать дальше
Клиентский каркас вокруг этой схемы: стек React в 2026 году, каркас Bulletproof React, TanStack вместо связки, собранной генератором. Установка веб-приложения без лишнего шага: элемент install. Учётный край, куда очередь в итоге стучится: платформа учёта, интеграция с учётной системой, архитектура мобильного клиента.
Исходный конспект, от которого оттолкнулся этот разбор: заметка Наиника Мехты на Dev.to.
Заключение
Надёжное приложение без сети строится не вокруг флага «онлайн», а вокруг гарантии, что локальная операция будет доставлена. Кэш переживает перезапуск экрана. Правила мутаций заново связывают функцию с ключом. Очередь переживает смерть вкладки. Клиентский UUID даёт имя объекту до ответа сервера. Ключ идемпотентности гасит повтор. Порядок не даёт обновлению обогнать создание. Стратегия конфликта решает, что делать, когда доставленный запрос уже не единственный.
Четыре вопроса, которыми стоит проверять любой такой контур. Где данные сразу после правки? В локальной базе. Как они переживают перезапуск? IndexedDB и очередь. Как они попадают на сервер? Работник проигрывания. Что будет, если запрос ушёл дважды? Ключ операции и дедупликация на сервере. Если на любой вопрос ответ «посмотрим, когда появится сеть», это ещё не offline-first.



Комментарии