Мобильное приложение живёт в условиях, которых нет у десктопа: метро, лифт, роуминг, дешёвый тариф. Если приложение белеет при первом же обрыве сети — пользователь его удалит. Офлайн-first означает другое отношение к данным: приложение работает с локальной копией всегда, а сеть — это фоновый процесс синхронизации, а не условие запуска. Для продукт-инженера это две инженерные задачи: что кешировать (статика и ответы API) и где хранить данные на устройстве, чтобы пережить перезапуск и отсутствие связи.

Зачем офлайн

Офлайн — это не «экран с динозавриком». Это свойство: приложение читает и пишет данные без сети, а при появлении связи синхронизирует накопленные изменения с сервером. Граница проходит по двум вещам. Первая — оболочка приложения (HTML, JS, CSS, иконки): она должна открываться мгновенно из кеша, не дожидаясь сети, иначе при обрыве пользователь видит белый экран ещё до логики. Это решает service worker. Вторая — данные пользователя (списки, черновики, последние ответы API): они хранятся в локальной базе на устройстве, приложение работает с ней, а не с сетью напрямую. Эти два уровня независимы и решаются разными механизмами.

Service worker и стратегии кеша

Service worker — прокси между приложением и сетью, перехватывающий fetch. Базовый механизм офлайна в PWA и в любой WebView-обёртке. Какой ответ отдавать на запрос — выбор стратегии, и их три рабочих.

Cache-first — для статики (сборки, шрифты, иконки): сначала кеш, сеть только при промахе. Файлы версионированы по имени, поэтому отдавать их из кеша безопасно.

Network-first — для свежих данных (лента, баланс): сначала сеть, при обрыве — последний кешированный ответ. Свежесть в приоритете, но офлайн не ломает экран.

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

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

async function cacheFirst(request: Request): Promise<Response> {
  const cached = await caches.match(request);
  if (cached) return cached;
  const response = await fetch(request);
  const cache = await caches.open("static-v1");
  cache.put(request, response.clone());
  return response;
}

async function networkFirst(request: Request): Promise<Response> {
  try {
    const response = await fetch(request);
    const cache = await caches.open("api-v1");
    cache.put(request, response.clone());
    return response;
  } catch {
    const cached = await caches.match(request);
    if (cached) return cached;
    throw new Error("offline and no cache");
  }
}

Писать это руками для каждого маршрута утомительно — Workbox даёт готовые CacheFirst, NetworkFirst, StaleWhileRevalidate и роутинг по URL. Но понимать, что под капотом именно эти три стратегии, обязательно: неверный выбор кеширует баланс пользователя на сутки.

Хранилища: IndexedDB, Preferences, SQLite

Кеш ответов решает «показать последнее виденное». Данные приложения — другое: их нужно читать, фильтровать, писать офлайн. Три хранилища под разные объёмы.

IndexedDB — встроенная в браузер и WebView транзакционная база ключ-значение для структурированных данных, работает асинхронно и не блокирует UI. Подчиняется same-origin. Базовый выбор для веба, но низкоуровневый API многословен — на практике берут обёртку (idb, Dexie).

@capacitor/preferences — нативное key-value хранилище (UserDefaults на iOS, SharedPreferences на Android). Для мелочи: токен, настройки, флаги. Не база — большие массивы туда не кладут.

import { Preferences } from "@capacitor/preferences";

await Preferences.set({ key: "authToken", value: token });
const { value } = await Preferences.get({ key: "authToken" });
await Preferences.remove({ key: "authToken" });

SQLite через community-плагин @capacitor-community/sqlite — нативная реляционная база для больших объёмов: тысячи записей, сложные запросы, индексы, опциональное шифрование. Когда IndexedDB не тянет, а данных реально много.

import { CapacitorSQLite, SQLiteConnection } from "@capacitor-community/sqlite";

const sqlite = new SQLiteConnection(CapacitorSQLite);
const db = await sqlite.createConnection("app", false, "no-encryption", 1, false);
await db.open();
await db.execute(
  "CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, title TEXT, synced INTEGER);"
);
await db.run("INSERT INTO notes (title, synced) VALUES (?, ?);", ["draft", 0]);
const result = await db.query("SELECT * FROM notes WHERE synced = 0;");
await sqlite.closeConnection("app", false);

Правило выбора: настройки и токены — Preferences; структурированные данные среднего объёма — IndexedDB; десятки тысяч записей и сложные запросы — SQLite.

Синхронизация

Офлайн-first переворачивает поток данных. Запись идёт сначала в локальную базу, а изменение кладётся в очередь (outbox) с пометкой «не синхронизировано». UI обновляется мгновенно по локальной записи — это и есть отзывчивость. Отдельный процесс при наличии сети разбирает очередь и шлёт изменения на сервер, помечая отправленные. Момент «появилась сеть» даёт плагин @capacitor/network.

import { Network } from "@capacitor/network";

Network.addListener("networkStatusChange", async (status) => {
  if (status.connected) await flushQueue();
});

async function flushQueue(): Promise<void> {
  const pending = await db.query("SELECT * FROM notes WHERE synced = 0;");
  for (const note of pending.values ?? []) {
    await fetch("/api/notes", { method: "POST", body: JSON.stringify(note) });
    await db.run("UPDATE notes SET synced = 1 WHERE id = ?;", [note.id]);
  }
}

Ключевые свойства очереди: повтор при следующем онлайне (отправка может упасть на середине) и идемпотентность на стороне сервера — один и тот же элемент очереди при повторе не должен создавать дубль. Это та же дисциплина, что в загрузке данных, только запись идёт в обратную сторону и переживает обрыв.

Конфликты

Как только запись возможна офлайн на нескольких устройствах, появляются конфликты: один и тот же объект изменён в двух местах независимо. Простейшая стратегия — last-write-wins: у каждой записи есть updatedAt, при синхронизации побеждает более поздняя. Дёшево, но молча теряет одно из изменений — годится для заметок, опасно для денег и заказов.

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

Где это в UCP

Офлайн-first — это инженерная дисциплина границы между устройством и сервером: локальная база как источник правды для UI, сеть как фоновая синхронизация, явная стратегия разрешения конфликтов вместо «у кого последний запрос». Владея ею, продукт-инженер строит приложение, которое не разваливается в метро и не теряет данные пользователя при обрыве. Базовый уровень офлайна — оболочка из кеша — разбирается в статье про PWA, а внутри WebView-обёртки Capacitor те же хранилища получают нативные реализации; поток данных, который синхронизация замыкает на сервер, — в загрузке данных.