Offline-first architecture concept page. Local-first apps (Linear, Figma offline, Obsidian). IndexedDB / SQLite-WASM is source of truth, sync engine (Replicache/ElectricSQL) replays mutations to a Sync API backed by Postgres. Three scenarios: offline edit drained on reconnect, CRDT conflict merge across two devices, optimistic UI rollback when server rejects. Carries an ADR comparing Replicache vs ElectricSQL vs custom sync.
Offline-first нужен не только приложениям для самолета или метро. Он нужен любому продукту, где сеть не должна быть единственной точкой отказа пользовательского опыта. Если каждое действие зависит от round trip до сервера, то плохой 3G, закрытый ноутбук, captive portal, перегруженный API или краткий disconnect превращают интерфейс в спиннер. Offline-first меняет приоритет: пользователь работает с локальной моделью, а сеть становится асинхронным каналом синхронизации.
Это особенно важно для редакторов, CRM, задачников, заметок, мобильных приложений, field operations, collaboration tools и внутренних систем, где потеря работы недопустима. Хороший offline-first UI делает локальную запись сразу, показывает pending/sync state, отправляет mutations позже, получает remote changes и разрешает конфликты. Плохой UI говорит "No internet" и заставляет пользователя повторить действие.
Для frontend-архитектора offline-first важен потому, что он меняет границы ответственности. State management перестает быть только React state плюс server cache. Появляется локальная база, schema migrations, operation log, outbox, sync protocol, conflict resolution, server acknowledgement, retries, auth offline и privacy локальных данных.
Локальная база - source of truth для UX. Сеть - best-effort replication channel. Каждое пользовательское действие сначала записывается локально и сразу отражается в интерфейсе. Затем sync engine отправляет операцию на сервер, получает ack или reject, подтягивает remote operations и применяет merge. Если сети нет, outbox растет, но работа продолжается. Если сервер отказал, UI делает rollback или показывает конфликт.
Важное отличие от обычного cache: cache хранит копии ответов, offline-first хранит модель предметной области и журнал намерений пользователя. Cached page позволяет читать старую статью. Offline-first позволяет создать задачу, отредактировать документ, поменять статус, написать комментарий, а затем корректно синхронизировать эти действия.
Связь с CRDT и realtime тесная, но не обязательная. Для одиночного пользователя можно использовать ordered mutation log и server reconciliation. Для совместного редактирования нужны CRDT, OT или domain-specific merge. См. ::concept{slug="realtime-collab-architecture"} и ::concept{slug="crdts"}.
Диаграмма показывает браузер как полноценный клиентский runtime. Внутри него есть React UI, sync engine, IndexedDB или SQLite-WASM, Service Worker и outbox. Снаружи - CDN для assets, Sync API, backend и server database. UI обращается не напрямую к API как к единственному источнику, а к sync engine. Sync engine пишет в локальную базу, обновляет UI, ставит mutations в очередь и общается с API, когда сеть доступна.
Такой рисунок подчеркивает write-through локальную модель. Пользователь нажал "создать задачу" - задача появляется сразу в local DB с syncStatus=pending. Service Worker или sync engine отправляет mutation на сервер. Сервер подтверждает, присваивает canonical id/version, возвращает изменения. Клиент помечает запись synced. Если сервер вернул conflict или validation error, клиент либо применяет merge, либо показывает conflict UI, либо откатывает optimistic change.
Отдельно виден путь reconnect: queued mutations уходят батчем, затем клиент получает remote ops since last sync token. Это важно, потому что полноценная синхронизация не должна каждый раз скачивать весь dataset. Нужны cursors, version vectors, lastPulledAt, changelog или server-defined diff protocol.
Первый сценарий: online write. UI отправляет действие sync engine, тот мгновенно пишет в IndexedDB/SQLite и обновляет экран. Затем mutation уходит в Sync API и Postgres. Ack возвращается, pending flag исчезает. Урок: даже online путь строится как local-first, чтобы offline не был отдельным кодовым путем.
Второй сценарий: offline write. Пользователь редактирует без сети. Mutation сохраняется локально и в outbox, UI показывает изменение и аккуратный статус вроде "pending" или "will sync". Никакого вечного spinner. Урок: offline-first не означает скрывать проблемы сети; он означает, что пользовательский intent не теряется.
Третий сценарий: reconnect and catchup. Когда сеть вернулась, sync engine отправляет queued mutations с idempotency keys, получает ack, затем запрашивает remote operations since last known version. Remote changes применяются через CRDT, 3-way merge или domain reducer. Урок: reconnect - это не просто retry последнего HTTP-запроса, а протокол согласования двух историй.
Четвертый сценарий: conflict. Два устройства изменили одну сущность. Last-write-wins прост, но может потерять данные. CRDT хорошо подходит для текста, списков и совместных документов. 3-way merge хорош для бизнес-объектов, где сервер знает base version, client patch и current server version. App-specific reducer нужен там, где domain важнее универсального алгоритма: например, списание денег нельзя "смёржить" как текст.
Replicache vs ElectricSQL vs custom sync. Replicache дает понятную модель push/pull, client-side mutators и server reconciliation; он хорош для приложений вроде Linear, где бизнес-логика сложная, а сервер остается арбитром. ElectricSQL и PowerSync тянут мир ближе к local SQL: Postgres реплицируется в SQLite, UI читает локально, sync идет на уровне данных. Это удобно для CRUD-heavy приложений, но требует дисциплины вокруг permissions, partial replication и schema migrations. Custom sync дает максимальный контроль, но почти всегда дороже, чем кажется: changelog, retries, deduplication, auth, conflict UI, migrations, observability, backfill и support старых клиентов.
IndexedDB vs SQLite-WASM/OPFS. IndexedDB встроен, async и достаточно мощен, но API неудобен; обычно берут Dexie, idb, RxDB или Tinybase. SQLite в браузере через OPFS удобен командам с SQL-моделью, сложными queries и mobile/desktop паритетом, но добавляет WASM, migrations и browser support considerations. localStorage годится только для маленьких settings: он синхронный, блокирует main thread и не подходит для модели данных.
Optimistic UI vs pessimistic confirmation. Optimistic делает UX быстрым, но требует rollback, pending state и error semantics. Pessimistic проще: дождались сервера, показали результат. Но при плохой сети он превращает каждое действие в паузу. Практичный компромисс: безопасные и обратимые actions делать optimistic, рискованные actions вроде payment, irreversible delete или permission changes подтверждать сервером до финального UI.
Conflict strategy - главный trade-off. LWW дешевый, но теряет намерения пользователей. CRDT автоматически сходится, но имеет overhead: tombstones, vector clocks, larger payloads, сложность compacting. 3-way merge понятен для документов и records, но требует хранить base version и patches. Manual conflict UI честен, но дорог для пользователя; его стоит оставлять для редких и важных конфликтов.
Auth offline также требует выбора. Длинный TTL улучшает offline usability, но увеличивает риск при краже устройства. Encrypted local storage через Web Crypto помогает, но key management сложен. Для чувствительных данных нужна возможность remote wipe, re-auth for risky actions и минимизация локальной репликации.
Linear известен сильной sync-моделью: интерфейс мгновенный, mutations локальные, сервер reconciliation, realtime и offline ощущаются как один продукт. Obsidian ближе к pure local-first: файлы пользователя живут локально, sync опционален. Apple Notes и Reminders используют локальную работу плюс CloudKit sync. Google Docs offline позволяет редактировать документы без сети и догонять изменения позже. Figma хранит локальную модель и синхронизирует операции, хотя offline для collaboration ограничен.
Notion mobile кеширует recent pages и дает partial offline, что показывает важный продуктовый компромисс: не вся система обязана быть full offline с первого дня. Частичный offline-read может дать 80% пользы для content-heavy продукта, тогда как full offline-write потребует sync engine и conflict resolution.
Для CloudArch offline-first связан с ::concept{slug="pwa-service-workers"}: Service Worker дает cache и background events, но сам по себе не решает merge. Связан также с ::concept{slug="realtime-collab-architecture"}, потому что collaborative offline требует operations, vector clocks и CRDT/OT. Для frontend state продолжайте через ::concept{slug="state-management"}: локальная база становится источником, а React state - проекция.
"Offline" как retry button - не offline-first. Если пользователь заполнил форму и получил ошибку без сохранения, intent потерян. Второй частый дефект - хранить outbox в памяти: вкладка закрылась, pending действия исчезли. Третий - отправлять mutations без idempotency key: retry после reconnect создает дубли.
LWW для совместной работы почти всегда опасен. Он допустим для неважных preferences, но плох для текста, комментариев, задач и заказов. Еще одна ошибка - не иметь local schema migrations. Клиент может быть offline неделями, затем открыть старую IndexedDB schema с новым кодом. Нужны versioned migrations, backward compatibility или clear recovery.
Плохие UI-паттерны: глобальный banner "offline" без per-item state; скрытые pending mutations; отсутствие stale indicators; rollback без объяснения; conflict modal на каждую мелочь; spinner на local write. Без наблюдаемости sync engine становится черным ящиком: нужны метрики queue length, sync latency, conflict rate, failed retries, oldest pending mutation.
Security anti-patterns: токены и секреты в plain localStorage, слишком широкая локальная репликация, отсутствие encryption для чувствительных данных, невозможность logout очистить local DB, XSS, который получает доступ ко всей локальной базе.
Не стоит строить full offline-first для продукта, где данные должны всегда быть строго свежими и операции нельзя безопасно отложить: биржевые сделки, банковские переводы, покупка последнего билета, медицинские назначения, inventory с жесткой консистентностью. Можно кешировать shell и drafts, но commit должен требовать server confirmation.
Не надо начинать с CRDT, если задача - простые личные настройки или read-only контент. HTTP cache, Service Worker и drafts в IndexedDB могут быть достаточны. Не надо реплицировать весь Postgres в браузер: выбирайте subset данных по workspace, user, permissions и recent activity.
Не используйте offline-first как оправдание скрывать сетевые ошибки. Пользователь должен понимать, что данные pending, stale или failed. И не делайте offline-write для действий, которые юридически или финансово требуют явного подтверждения в момент совершения.
Начните с Local-first software от Ink & Switch и работ Мартина Клеппмана. Практические технологии: Replicache, PowerSync, ElectricSQL, RxDB, Dexie, Tinybase, PouchDB/CouchDB, Yjs и Automerge. Для браузерного storage изучите IndexedDB, OPFS и SQLite-WASM. Внутри курса переходите к ::concept{slug="realtime-collab-architecture"}, ::concept{slug="crdts"}, ::concept{slug="pwa-service-workers"} и ::case{slug="linear-offline"}, если case доступен в вашей программе.