Приложение заметок должно работать без сети - и первый вопрос: где на клиенте держать данные. Инстинкт тянет к localStorage, но он синхронный, хранит только строки и рассчитан на килобайты настроек. IndexedDB - это transactional object database: встроенная в браузер база для значительных объёмов структурированных данных. Не большой localStorage, а база - с транзакциями, ключами, индексами и версионированной схемой. От этого зависит, переживут ли черновики перезагрузку вкладки и внезапный офлайн.
IndexedDB хранит значения, совместимые со structured clone - алгоритмом, которым браузер копирует данные между потоками: объекты, массивы, даты, blob и файлы. Единица хранения - object store, аналог коллекции. Key path задаёт, какое поле служит primary key, а index - вторичный ключ для быстрой выборки по другому полю. API асинхронный и работает и в window, и в service worker - последнее важно: синхронизацию запускает именно worker, когда страница уже закрыта.
Ошибка новичка - складывать в базу сырые серверные ответы. Храните сущность в форме, удобной для локального запроса, а не копию каждого response. К полям заметки добавьте поля синхронизации: serverVersion и localVersion, чтобы отличать чужую правку от своей, syncState для состояния записи, updatedAt и при необходимости tombstone - метку удаления, которую нельзя стереть сразу, иначе сервер не узнает об удалении. И не берите серверный timestamp как единственный порядок событий: часы клиента врут.
Всё, что база делает с данными, происходит внутри транзакции, и у неё неочевидное свойство: она живёт ровно столько, сколько к ней привязаны активные запросы. Как только очередь микрозадач опустела и ни один запрос не ждёт, транзакция авто-коммитится. Отсюда классическая ловушка: произвольный await между двумя запросами одной транзакции закрывает её раньше времени, и к следующему put вы обращаетесь уже к завершённой транзакции - получаете ошибку.
Режим readonly допускает параллельные чтения; readwrite применяет связанные изменения атомарно - либо все, либо ни одного. Это и делает офлайн-запись надёжной. Оптимистичная мутация - два durable-шага: обновить заметку и записать операцию в outbox, очередь на отправку серверу. Разными транзакциями возможен сбой: заметка изменена, а операция потеряна. Одна readwrite-транзакция на оба store закрывает это окно - интент и очередь всегда согласованы.
Схема меняется через upgrade. При открытии базы с версией выше текущей срабатывает событие upgradeneeded, и его обработчик выполняется в особой versionchange-транзакции - единственном месте, где можно создавать и удалять object store и индексы. Сложность в многовкладочности: если в другой вкладке открыто старое соединение, апгрейд блокируется. Правильный сценарий - слушать versionchange, закрывать старое соединение и просить пользователя обновиться, а в новой вкладке обрабатывать событие blocked.
Сами миграции должны быть быстрыми: versionchange держит базу заблокированной, и тяжёлый перебор всех записей заморозит запуск. Для больших данных делайте миграцию resumable или ленивой - приводите запись к новой форме при первом чтении. Типичный отказ: релиз в upgrade переписывает десятки тысяч заметок; пользователь с забитой базой открывает вторую вкладку, апгрейд встаёт в blocked, первая висит на белом экране. Лечит это разбиение миграции и честная обработка blocked, а не надежда, что у всех данных мало.
const request = indexedDB.open('offline-notes', 3);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('notes')) {
const notes = db.createObjectStore('notes', { keyPath: 'id' });
notes.createIndex('updatedAt', 'updatedAt');
notes.createIndex('syncState', 'syncState');
}
if (!db.objectStoreNames.contains('outbox')) {
const outbox = db.createObjectStore('outbox', { keyPath: 'operationId' });
outbox.createIndex('createdAt', 'createdAt');
}
};function saveNoteAndQueue(db, note, operation) {
return new Promise((resolve, reject) => {
const tx = db.transaction(['notes', 'outbox'], 'readwrite');
tx.objectStore('notes').put(note);
tx.objectStore('outbox').put(operation);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}