← назад к разделу

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

Что такое офлайн-first и зачем это нужно

Раньше мобильные приложения строились по простому принципу: есть интернет — работаем, нет — показываем ошибку. Пользователь попал в метро — видит белый экран или сообщение «нет соединения».

Офлайн-first — другой подход. Приложение всегда работает с локальной копией данных на устройстве. Сеть — это фоновый процесс синхронизации, а не условие запуска. Открыл приложение в метро — оно работает. Появилась сеть — данные тихо отправились на сервер.

Для этого нужно решить две независимые задачи:

  1. Оболочка приложения (HTML, JS, CSS, иконки) должна открываться мгновенно из кеша, не дожидаясь сети. Это задача service worker.
  2. Данные пользователя (списки, черновики, последние ответы сервера) должны храниться в локальной базе на устройстве. Это задача хранилищ.

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

Service worker — это файл JavaScript, который браузер запускает отдельно от страницы. Он перехватывает все сетевые запросы приложения и решает: отдать из кеша или пойти в сеть.

Это базовый механизм офлайна в PWA и в WebView-обёртках.

Стратегий три, и выбирать их нужно осознанно — неверный выбор может закешировать баланс пользователя на сутки.

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

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 — встроенная в браузер транзакционная база для структурированных данных. Работает асинхронно, не блокирует интерфейс. Привязана к домену. Базовый выбор для веба, но низкоуровневый API многословен — на практике берут обёртку (idb, Dexie).

@capacitor/preferences — нативное хранилище ключ-значение: 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 через @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 меняет направление потока данных. Раньше: пользователь нажал кнопку → отправили запрос на сервер → обновили интерфейс. Теперь: пользователь нажал кнопку → записали в локальную базу → интерфейс обновился мгновенно → при появлении сети отправили на сервер.

Механизм такой:

  1. Каждое изменение записывается в локальную базу и одновременно кладётся в очередь отложенных операций (outbox) с пометкой «не отправлено».
  2. Интерфейс работает с локальной базой — отзывчивость не зависит от сети.
  3. Отдельный процесс следит за состоянием сети. Появилась связь — разбирает очередь и шлёт изменения на сервер.

Момент «появилась сеть» даёт плагин @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, клиент отправляет изменение с известной ему версией. Если на сервере версия выше — это конфликт. Его разрешают явно: показывают пользователю обе версии, объединяют на уровне полей или применяют доменное правило.

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

Коротко

  • Офлайн-first: приложение работает с локальными данными всегда, сеть — фоновая синхронизация.
  • Задача два уровня: оболочка из кеша (service worker) + данные в локальной базе (хранилище).
  • Стратегии кеша: cache-first (статика), network-first (свежие данные), stale-while-revalidate (скорость важнее актуальности).
  • Хранилища: Preferences — токены и настройки; IndexedDB — средний объём структурированных данных; SQLite — большие объёмы и сложные запросы.
  • Синхронизация через outbox: пишем локально → помечаем «не отправлено» → при сети отправляем → помечаем «отправлено».
  • Очередь должна переживать обрывы, сервер должен быть идемпотентным.
  • Конфликты: last-write-wins — просто, но теряет данные; версионирование — надёжно, требует явной логики разрешения.

Что почитать дальше

  • PWA: что такое и как работает — как service worker регистрируется и активируется.
  • Capacitor: нативные функции в веб-приложении — как хранилища получают нативные реализации на iOS и Android.
  • Push-уведомления — ещё один канал общения с устройством при отсутствии активного экрана.