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

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

Чем простое хранилище ключей и строк платит за свою лёгкость

localStorage это словарь пар ключ-значение, привязанный к конкретному сайту. Интерфейс умещается в пять методов: setItem, getItem, removeItem, clear и key. Пишется строка, читается строка, никакого шаблонного кода, подключений и миграций. Для мелочей вроде темы оформления, размеров панелей или последнего открытого экрана это идеальный инструмент: одна строка на запись, одна на чтение.

Простота оплачена жёсткими ограничениями. Во-первых, API синхронный: каждая операция выполняется в главном потоке и блокирует остальной код, пока не завершится. Для строки на сто символов это незаметно, но черновик отчёта на пару мегабайт, сериализованный через JSON.stringify, способен заметно подвесить интерфейс на слабом телефоне.

Во-вторых, хранятся только строки. Любой объект проходит через JSON.stringify при записи и JSON.parse при чтении, а это значит, что обновление одного поля переписывает весь документ целиком. Поиск по записям превращается в цикл по ключам с разбором каждого значения: ни индексов, ни запросов, ни частичных обновлений. Если добавить к этому разбор мусора от неудачного JSON.parse, станет ясно, почему на больших объёмах механизм трещит.

В-третьих, объём. Лимит измеряется единицами мегабайт, обычно около 5 МБ на сайт, точная цифра зависит от браузера и режима. При превышении вызов setItem выбрасывает исключение QuotaExceededError, и если его не поймать, приложение молча теряет данные. В приватном режиме лимит может упасть почти до нуля: старые версии Safari в таких условиях вообще отказывались писать.

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

Устройство IndexedDB от хранилищ объектов до транзакций и индексов

IndexedDB устроена как полноценная встроенная база данных. Данные живут в хранилищах объектов, аналог таблиц. У каждого хранилища есть ключ: либо поле записи, заданное через keyPath, либо автоинкрементный счётчик. Записи это не строки, а структурированные значения: вложенные объекты, массивы, даты, а также Blob, File и ArrayBuffer, то есть результаты камеры или сканера ложатся в базу без перекодирования. Такой формат сохранения называется structured clone, и он не переваривает только функции и DOM-узлы, с обычными данными вопросов не возникает.

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

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

Схема версионируется. Открытие базы с номером версии выше текущей запускает колбэк обновления, в котором удобно создавать хранилища и индексы, именно здесь живут миграции от релиза к релизу. Честно про недостатки: низкоуровневый интерфейс многословен, события и колбэки родом из эпохи до промисов, поэтому всерьёз работать с ним напрямую почти никто не работает. Библиотека idb от Джейка Арчибальда оборачивает API в промисы, Dexie добавляет цепочки запросов и живые подписки, localForage предлагает единый интерфейс с подстраховкой на старых браузерах.

Код для обоих API от безопасной обёртки до миграции версий базы

Для localStorage главное правило: никогда не верить записи и чтению на слово. Запись может упасть по квоте, чтение может вернуть битый JSON. Обёртка из десятка строк закрывает обе проблемы.

const DRAFT_KEY = "draft:inspection";

function saveDraft(draft) {
  try {
    localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
  } catch (err) {
    if (err.name === "QuotaExceededError") {
      console.warn("Лимит хранилища исчерпан, черновик не сохранён");
    }
  }
}

function loadDraft() {
  const raw = localStorage.getItem(DRAFT_KEY);
  if (raw === null) return null;
  try {
    return JSON.parse(raw);
  } catch {
    localStorage.removeItem(DRAFT_KEY);
    return null;
  }
}

С IndexedDB запись выглядит иначе: сначала открытие базы с версией и миграциями, затем транзакционные операции. Библиотека idb сокращает шаблонный код в несколько раз и превращает события в промисы.

import { openDB } from "idb";

const db = await openDB("field-app", 2, {
  upgrade(db, oldVersion) {
    if (oldVersion < 1) {
      const visits = db.createObjectStore("visits", { keyPath: "id" });
      visits.createIndex("byClient", "clientId");
    }
    if (oldVersion < 2) {
      const outbox = db.createObjectStore("outbox", { keyPath: "opId" });
      outbox.createIndex("byStatus", "status");
    }
  },
});

const visit = { id: crypto.randomUUID(), clientId: 42, done: false };

await db.put("visits", visit);
const saved = await db.getAllFromIndex("visits", "byClient", 42);

Ветка oldVersion объясняет миграции: свежий пользователь получает версию 1 и 2 одной пачкой, а тот, кто заходил вчера, только вторую. Индексы создаются там же, и попытка открыть индекс до его появления обрушивается с ошибкой, поэтому порядок в колбэке важен. Ключ crypto.randomUUID удобен для офлайн-записей: идентификатор рождается на клиенте и не зависит от сервера, а значит две записи, созданные без сети, не столкнутся при синхронизации.

Квоты браузеров и заявка на постоянное хранилище данных

Объём IndexedDB в разы щедрее, но точные числа зависят от браузера и настроек. В Chrome браузеру отдаётся до 80% диска, а один сайт может занять до 60% всего диска. В режиме инкогнито лимит падает примерно до 5% диска, а если включена очистка данных при закрытии окон, квота сжимается примерно до 300 МБ. Firefox разрешает браузеру до 50% свободного места, причём группа связанных поддоменов одного сайта делит общий лимит около 2 ГБ. Safari держит примерно 1 ГБ и при исчерпании спрашивает пользователя, добавляя место шагами по 200 МБ. Числа плавают от версии к версии, поэтому надёжнее не запоминать их, а спрашивать у браузера в рантайме.

const { usage, quota } = await navigator.storage.estimate();
const percent = Math.round((usage / quota) * 100);
console.log("Занято", usage, "из", quota, "байт,", percent, "%");

const granted = await navigator.storage.persist();

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

Синхронизация вкладок и офлайн-очередь операций записи

Событие storage у localStorage приходит только в соседние вкладки того же сайта, вкладка-автор его не слышит. Это удобно: одна вкладка пишет, остальные обновляют интерфейс, и glue-код умещается в несколько строк.

window.addEventListener("storage", (event) => {
  if (event.key === DRAFT_KEY && event.newValue !== null) {
    renderDraft(JSON.parse(event.newValue));
  }
});

У IndexedDB такого события нет, и синхронизацию между вкладками строят на BroadcastChannel. Канал с одинаковым именем открывается в каждой вкладке, сообщение уходит всем слушателям.

const channel = new BroadcastChannel("draft-updates");
channel.onmessage = (event) => renderDraft(event.data);
channel.postMessage({ clientId: 42, savedAt: Date.now() });

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

Сервис-воркеры и разделение труда между слоями кэша

Полноценное офлайн-приложение держится на трёх слоях, и путать их роли не стоит. Service Worker перехватывает сетевые запросы, Cache API хранит оболочку приложения: разметку, стили, скрипты, картинки интерфейса. IndexedDB отвечает за деловые данные: записи, черновики, очередь на отправку. Логика простая, статика из кэша отдаётся мгновенно, а данные идут по сети с падением в базу.

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (url.pathname.startsWith("/api/")) {
    event.respondWith(networkFirst(event.request));
  } else {
    event.respondWith(cacheFirst(event.request));
  }
});

Разделение сохраняется и при обновлениях. Новая версия оболочки кладётся в кэш под другим именем, старая удаляется при активации воркера, а записи в базе остаются нетронутыми: у версии данных своя миграция через upgrade. Попытка смешать слои, например складывать ответы API в Cache API ради "офлайна", работает ровно до первого обновления данных: кэш запрещает точечные правки, и придётся переписывать ответ целиком, тогда как IndexedDB обновляет ровно одну запись.

Практические критерии выбора хранилища под конкретную задачу

Рабочая схема для офлайн-приложения почти всегда гибридная: localStorage берёт на себя настройки и лёгкие флаги, IndexedDB всё, что похоже на реальные данные. Быстрая проверка для конкретной задачи выглядит так:

  1. Настройки интерфейса, тема, последний экран и мелкие флаги живут в localStorage, одна строка на запись;
  2. Черновики форм и отчётов с восстановлением после разрыва связи уходят в IndexedDB, потому что данные структурированные, а объём непредсказуем;
  3. Очередь операций на отправку складывается в отдельное хранилище IndexedDB и обрабатывается при возврате сети;
  4. Кэш ответов API на сотни записей с поиском по полям требует индексов, без них каждый запрос перелопачивает весь массив;
  5. Медиафайлы, блобы и результаты камеры хранятся только в IndexedDB, Web Storage работает со строками;
  6. Мелкое состояние, которое должно мгновенно отражаться в соседних вкладках, пишется в localStorage с подпиской на событие storage.

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