Статья · Личный блог
Local-first на практике: CRDT, IndexedDB и синхронизация без сервера
Как Slate хранит заметки локально и сводит правки с разных устройств — без конфликтов и без бэкенда.
Slate — мой редактор заметок, который обязан работать в самолёте, в метро и при упавшем бэкенде. Разбираю архитектуру local-first: лог операций вместо состояния, CRDT для слияния правок и IndexedDB как основное хранилище.
Почему local-first
Классическое веб-приложение — это тонкий клиент над REST API: источник истины живёт на сервере, у клиента — кэш в памяти. Стоит сети моргнуть, и интерфейс рассыпается: спиннеры, ретраи, «попробуйте позже». Для заметок это особенно обидно — мысль не ждёт, пока поднимется бэкенд.
Local-first переворачивает схему: источник истины — на устройстве, а сервер, если он вообще есть, — лишь транспорт для обмена изменениями. Открыть заметку — это чтение с диска, сохранить — запись на диск. Сеть исчезает из критического пути.
Оффлайн — не деградация, а нормальный режим работы. Синхронизация — фоновый процесс, о котором пользователь не думает.
События, а не состояние
Первое решение определяет всё остальное: хранить не документ, а лог операций. Каждая правка — маленькая запись: что изменилось, кто автор, логический таймстемп. Текущее состояние — это свёртка лога.
Такой формат даёт бесплатную историю изменений и undo, но главное — слияние. Чтобы объединить две реплики, достаточно объединить два лога и свернуть заново. Не нужно сравнивать документы и гадать, чья версия «правильнее».
- id операции — UUID, чтобы реплики не сталкивались;
- таймстемп — гибридные логические часы (HLC), а не
Date.now(): настенные часы на устройствах врут; - периодические снапшоты, чтобы не сворачивать лог с нуля при каждом запуске.
CRDT без магии
CRDT звучит академично, но для большинства приложений хватает двух примитивов: LWW-регистра («последняя запись побеждает») для скалярных полей и набора с tombstone для коллекций — удалённый элемент не стирается, а помечается.
// LWW-регистр: конфликт решается детерминированно
function merge(a, b) {
if (a.ts !== b.ts) return a.ts > b.ts ? a : b;
return a.actor > b.actor ? a : b; // тай-брейк
}
Две реплики, применив одни и те же операции в любом порядке, придут к одинаковому состоянию — без сервера-арбитра. Для совместного редактирования текста этих примитивов уже мало: там нужен полноценный sequence CRDT. Честный совет — берите Yjs или Automerge: свой RGA вы будете отлаживать год.
IndexedDB как хранилище
На вебе для local-first есть ровно одно подходящее хранилище — IndexedDB. localStorage не годится: синхронный, лимит в считанные мегабайты, только строки. В Slate два стора: ops — лог операций, источник истины, и snapshots — свёртки для быстрого старта.
const db = await openDB("slate", 1, {
upgrade(db) {
const ops = db.createObjectStore("ops", { keyPath: "id" });
ops.createIndex("byDoc", "docId");
db.createObjectStore("snapshots", { keyPath: "docId" });
},
});
await db.put("ops", op); // лог — источник истины
Ловушки, на которые я наступил:
- транзакция IndexedDB закрывается, как только вы сделали
awaitчего-то постороннего — все записи одной операции держите в одной транзакции без чужих промисов; - Safari может вычистить хранилище «неактивного» сайта — просите
navigator.storage.persist(); - версия схемы — единственный механизм миграций; продумайте формат операций до первого релиза.
Синхронизация без сервера
Когда истина локальная, синхронизация превращается в простую задачу: обменяться недостающими операциями. Реплики сравнивают векторы версий — «я видел твои операции до номера N» — и досылают друг другу хвосты логов.
Транспорт при этом взаимозаменяем: WebRTC напрямую между устройствами в одной сети, дешёвый relay-сервер, который видит только зашифрованные блобы, хоть файл на флешке. Merge идемпотентен и коммутативен — операции можно получить дважды и в любом порядке, итог одинаковый. Целый класс багов синхронизации снимается на уровне модели данных.
Что в итоге
- источник истины — на устройстве, сеть вне критического пути;
- лог операций + снапшоты вместо хранения состояния;
- LWW-регистр и tombstone-набор закрывают 90% случаев;
- совместный текст — только готовые библиотеки (Yjs, Automerge);
- транспорт синхронизации — деталь, заменяемая в любой момент.
В Slate вся эта механика умещается примерно в семьсот строк без единой строчки серверного кода: заметки открываются мгновенно, ноутбук и телефон синхронизируются напрямую по локальной сети, а бэкенда, который может упасть, просто нет.