Курьер приезжает на склад, где связи нет вовсе. Он открывает приложение на планшете, отмечает принятые позиции, фотографирует повреждения и закрывает смену. Через час по дороге в город сеть появляется, и все данные уходят на сервер сами. Такая схема работает не на нативном приложении, а на обычной странице, и держится на нескольких браузерных механизмах, о которых стоит знать.

Встроенное хранилище прошло путь от простого к полноценной базе

Первые попытки дать странице постоянное хранилище были скромными. Данные хранились парами ключ и значение, а размер ограничивался несколькими мегабайтами на сайт.

Позже появился отдельный стандарт, описывающий встроенную базу объектов. История развития выглядит так. Поддержка появилась в браузерах начиная с 2011 года, первая версия стандарта стала рекомендацией в январе 2015 года, вторая в январе 2018 года, третья находится в разработке с марта 2021 года.

Ключевое отличие от простого хранилища состоит в объёме и структуре. Простое хранилище подходит для настроек и небольших записей. Встроенная база рассчитана на значительные объёмы структурированных данных, включая файлы и двоичные объекты. Ограничения по размеру у неё обычно существенно выше, а часто отсутствуют вовсе, определяясь свободным местом на устройстве.

Второе отличие это индексы. Данные хранятся по ключу, а дополнительные индексы позволяют искать по другим полям без полного перебора.

Третье отличие это транзакции. Набор операций выполняется целиком или не выполняется вовсе. Это защищает от состояния, когда половина изменений записалась, а половина нет.

Четвёртое отличие в том, что хранится не текст, а объекты. Сохранять можно почти любые структуры, поддерживаемые механизмом клонирования, включая вложенные объекты, массивы, даты и двоичные данные.

Схема базы и версии требуют аккуратного обращения

Работа начинается с открытия базы. Каждое действие выполняется через запрос, а результат приходит асинхронно через событие.

const request = indexedDB.open("warehouse", 2);

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  if (!db.objectStoreNames.contains("orders")) {
    const store = db.createObjectStore("orders", { keyPath: "id" });
    store.createIndex("by_status", "status");
    store.createIndex("by_created", "createdAt");
  }
};

request.onsuccess = (event) => {
  const db = event.target.result;
  console.log("Хранилище открыто, версия", db.version);
};

request.onerror = (event) => {
  console.error("Не удалось открыть хранилище", event.target.error);
};

Номер версии в вызове открытия это не декорация. При увеличении номера срабатывает обработчик обновления, в котором можно создать хранилища и индексы. Если версия в коде меньше установленной на устройстве, открытие завершится ошибкой. Поэтому номер версии меняют только вместе с изменениями схемы.

Первая тонкость связана с одновременными вкладками. Если приложение открыто в двух окнах, а в одном из них выполняется обновление схемы, второе получит событие о блокировке. Обработку этого события стоит предусмотреть, иначе обновление зависнет.

Вторая тонкость касается удаления. Удаление хранилища или индекса выполняется в том же обработчике обновления и требует осторожности: данные исчезнут безвозвратно.

Третья тонкость связана с областью видимости. Данные привязаны к источнику страницы. Один домен не увидит хранилище другого, а смена адреса с одного на другой означает потерю доступа к старым данным.

Запись и чтение выполняются в транзакциях

Вся работа с данными идёт через транзакции. Транзакция открывается на нужные хранилища в определённом режиме.

Режим только для чтения допускает параллельные обращения и потому быстрее. Режим чтения и записи блокирует хранилище для других операций записи, но позволяет нескольким читателям.

function saveOrder(db, order) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction("orders", "readwrite");
    const store = tx.objectStore("orders");
    store.put(order);
    tx.oncomplete = () => resolve(order.id);
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(new Error("Транзакция прервана"));
  });
}

function readOrder(db, id) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction("orders", "readonly");
    const store = tx.objectStore("orders");
    const request = store.get(id);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

Обёртка в промис не украшение, а необходимость. Встроенный интерфейс работает на событиях, и без обёртки код превращается в лестницу обработчиков.

Вторая тонкость связана со временем жизни транзакции. Она завершается автоматически, когда обработчик перестаёт выполнять запросы. Если внутри обработчика запустить асинхронную операцию и ждать её, транзакция успеет закрыться, и следующий запрос завершится ошибкой. Все операции внутри транзакции выполняются подряд, без ожиданий.

function countByStatus(db, status) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction("orders", "readonly");
    const index = tx.objectStore("orders").index("by_status");
    const request = index.count(status);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

Третья тонкость это массовые операции. Чтение всех записей по одному через цикл работает медленно. Существует метод получения всех записей сразу, добавленный во второй версии стандарта, и он ускоряет работу заметно.

Очередь изменений позволяет работать без сети

Теперь о главной задаче. Приложение должно принимать изменения без связи и отправлять их позже.

Первый элемент это очередь. Каждое действие записывается в отдельное хранилище с признаком состояния: ожидает отправки, отправлено, отклонено.

async function enqueue(db, action) {
  const item = {
    id: crypto.randomUUID(),
    payload: action,
    status: "ожидает",
    attempts: 0,
    createdAt: Date.now(),
  };
  await saveTo(db, "queue", item);
  return item;
}

Второй элемент это порядок. Изменения отправляются в том порядке, в котором были сделаны. Иначе удаление позиции уйдёт раньше её добавления, и на сервере останется мусор.

Третий элемент это повторы. Отправка может не удаться из-за сети. Число попыток ограничивается, а интервал между ними растёт. После нескольких неудач запись помечается как требующая внимания.

async function flush(db) {
  const pending = await readAllByStatus(db, "queue", "ожидает");
  for (const item of pending.sort((a, b) => a.createdAt - b.createdAt)) {
    try {
      await send(item.payload);
      await updateStatus(db, item.id, "отправлено");
    } catch (error) {
      const attempts = item.attempts + 1;
      if (attempts >= 5) {
        await updateStatus(db, item.id, "требует внимания");
      } else {
        await updateAttempts(db, item.id, attempts);
        break;
      }
    }
  }
}

Обратите внимание на прерывание цикла после первой ошибки. Отправлять следующие изменения, пока предыдущее не ушло, нельзя: порядок нарушится.

Четвёртый элемент это слияние. Данные, полученные с сервера, объединяются с локальными. Если одна и та же запись менялась в обоих местах, нужно правило разрешения конфликта. Простейшее правило это приоритет сервера, более сложное предполагает сравнение версий.

Автоматическая отправка освобождает пользователя от лишних действий

Ручная кнопка отправки работает, но её легко забыть. Есть механизм, который берёт эту заботу на себя.

Промежуточный слой между страницей и сетью позволяет зарегистрировать задачу и выполнить её, когда связь появится. Приложение только сообщает, что нужно синхронизировать, а браузер сам решает, когда это возможно.

async function requestSync() {
  const registration = await navigator.serviceWorker.ready;
  if ("sync" in registration) {
    await registration.sync.register("flush-queue");
    return;
  }
  // запасной вариант для браузеров без поддержки
  window.addEventListener("online", () => flushQueue(), { once: true });
}

Обработчик живёт в фоновом сценарии и получает событие по имени задачи:

self.addEventListener("sync", (event) => {
  if (event.tag === "flush-queue") {
    event.waitUntil(flushQueueInWorker());
  }
});

Важная тонкость: поддержка механизма неполная. Поэтому запасной вариант с обработкой события появления сети обязателен. Без него часть пользователей останется без автоматической отправки.

Второй момент касается надёжности фоновой отправки. Браузер не гарантирует, что задача выполнится сразу. Он может отложить её до удобного момента. Поэтому интерфейс не должен показывать состояние "отправлено" до подтверждения от сервера.

Объём и производительность требуют планирования

Локальное хранилище кажется бездонным, но это не так.

Первое ограничение это свободное место на устройстве. Браузер может запросить у пользователя разрешение на постоянное хранение, если данных много. Без разрешения система вправе очистить хранилище при нехватке места.

async function ensurePersistent() {
  if (!navigator.storage || !navigator.storage.persist) {
    return false;
  }
  const already = await navigator.storage.persisted();
  if (already) {
    return true;
  }
  return navigator.storage.persist();
}
async function showUsage() {
  const estimate = await navigator.storage.estimate();
  const usedMb = (estimate.usage / 1048576).toFixed(1);
  const quotaMb = (estimate.quota / 1048576).toFixed(1);
  console.log("Занято " + usedMb + " МБ из " + quotaMb + " МБ");
}

Второе ограничение это скорость. Каждая операция проходит через слои безопасности браузера, и это медленнее, чем работа с памятью. Тысяча отдельных чтений выполняется заметно дольше, чем одно чтение всех записей.

Третье ограничение это размер одной записи. Очень крупные объекты лучше хранить по частям. Например, фотографию грузить отдельно, а в записи держать только ссылку.

Четвёртое ограничение это индексы. Каждый индекс ускоряет поиск, но замедляет запись и занимает место. Заводить их стоит только под реально используемые запросы.

Очистка и миграция защищают от накопления мусора

Данные копятся, и без уборки хранилище превращается в свалку.

Первое правило это срок хранения. Отправленные изменения не нужно держать вечно. Через несколько дней их стоит удалять.

Второе правило это ограничение объёма очереди. Если изменений накопилось слишком много, стоит предупредить пользователя и предложить синхронизироваться.

Третье правило это миграция при смене схемы. Новые поля заполняются значениями по умолчанию, старые переносятся. Миграцию выполняют в том же обработчике обновления версии.

Четвёртое правило это обработка ошибок хранилища. Иногда база оказывается повреждена. Приложение должно уметь определить это и предложить пересоздать локальные данные, не теряя серверные.

async function resetLocalData(dbName) {
  await new Promise((resolve, reject) => {
    const request = indexedDB.deleteDatabase(dbName);
    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
    request.onblocked = () => reject(new Error("Закройте другие вкладки"));
  });
  location.reload();
}

Такая функция пригодится в редких случаях, но её наличие избавляет от ситуации, когда пользователь не может работать вообще.

Пятое правило это понятный интерфейс состояния. Человек должен видеть, сколько изменений ждёт отправки и когда последний раз проходила синхронизация.

Сравнение с простым хранилищем показывает границы применимости

Полезно понять, когда встроенной базы достаточно, а когда хватит более простого механизма.

Простое хранилище годится для настроек интерфейса, темы оформления, последнего открытого раздела. Это небольшие значения, читаемые целиком при запуске. Работает синхронно, без транзакций и индексов, но объём ограничен несколькими мегабайтами.

Встроенная база нужна, когда данных много или когда требуется поиск по полям. Список из тысяч записей, журнал действий, вложения. Здесь без индексов и транзакций не обойтись.

Практическое правило простое: если данных меньше сотни записей и поиск по ним не нужен, применяется простое хранилище. Всё остальное ложится на встроенную базу.

Второй критерий касается надёжности. Простое хранилище не даёт гарантий целостности. Прерывание записи может оставить частично обновлённое состояние. Транзакции решают эту проблему.

Третий критерий связан с объёмом вложений. Двоичные данные в простом хранилище приходится кодировать в текст, что увеличивает размер примерно на треть. Встроенная база хранит их как есть.

Отдельно стоит сказать о кэше приложения

Локальное хранилище и кэш оболочки часто путают, хотя это разные вещи.

Кэш оболочки хранит файлы приложения: разметку, стили, сценарии, изображения. Он наполняется при установке сервис-воркера и обновляется при выходе новой версии.

Локальное хранилище содержит данные: заказы, черновики, очередь изменений. Оно живёт дольше и не сбрасывается при обновлении приложения.

Разница проявляется в поведении при сбое. Если кэш повреждён, приложение не откроется вовсе. Если повреждены данные, приложение работает, но показывает пустые списки.

Отсюда практический вывод: у этих двух механизмов должны быть разные стратегии очистки. Кэш можно сбрасывать смело, данные удаляются только по явной команде пользователя или при подтверждённой ошибке.

Итоговые рекомендации для внедрения

Первая рекомендация: начинать с простого. Сначала кэш оболочки, затем локальные данные, затем очередь изменений. Каждый шаг добавляет ценность отдельно.

Вторая рекомендация: не изобретать правило слияния на ходу. Оно определяется до начала работы, иначе конфликты будут решаться случайно.

Третья рекомендация: показывать состояние. Пользователь должен знать, синхронизировано ли, сколько ждёт отправки, когда обновлялось в последний раз.

Четвёртая рекомендация: тестировать в условиях плохой связи. Ограничение скорости и периодические отключения выявляют ошибки, незаметные при стабильной сети.

Пятая рекомендация: предусмотреть восстановление. Возможность сбросить локальные данные и начать заново спасает в случаях, когда хранилище повреждено.

Шестая рекомендация: не хранить лишнего. Чем меньше данных лежит на устройстве, тем меньше риск утечки при потере планшета.

Локальное хранилище в браузере получилось механизмом, который незаметен, пока всё работает. Курьер на складе без связи не думает о транзакциях и индексах, он просто делает свою работу. Вся сложность остаётся на стороне разработчика, и именно она определяет, переживёт ли приложение пропажу сети или превратится в источник потерянных данных.