Мобильное приложение живёт в условиях, которых нет у десктопа: метро, лифт, роуминг, дешёвый тариф. Если приложение белеет при первом же обрыве сети — пользователь его удалит. В этой статье разбираем, как сделать так, чтобы приложение работало независимо от наличия связи.
Что такое офлайн-first и зачем это нужно
Раньше мобильные приложения строились по простому принципу: есть интернет — работаем, нет — показываем ошибку. Пользователь попал в метро — видит белый экран или сообщение «нет соединения».
Офлайн-first — другой подход. Приложение всегда работает с локальной копией данных на устройстве. Сеть — это фоновый процесс синхронизации, а не условие запуска. Открыл приложение в метро — оно работает. Появилась сеть — данные тихо отправились на сервер.
Для этого нужно решить две независимые задачи:
- Оболочка приложения (HTML, JS, CSS, иконки) должна открываться мгновенно из кеша, не дожидаясь сети. Это задача service worker.
- Данные пользователя (списки, черновики, последние ответы сервера) должны храниться в локальной базе на устройстве. Это задача хранилищ.
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 меняет направление потока данных. Раньше: пользователь нажал кнопку → отправили запрос на сервер → обновили интерфейс. Теперь: пользователь нажал кнопку → записали в локальную базу → интерфейс обновился мгновенно → при появлении сети отправили на сервер.
Механизм такой:
- Каждое изменение записывается в локальную базу и одновременно кладётся в очередь отложенных операций (outbox) с пометкой «не отправлено».
- Интерфейс работает с локальной базой — отзывчивость не зависит от сети.
- Отдельный процесс следит за состоянием сети. Появилась связь — разбирает очередь и шлёт изменения на сервер.
Момент «появилась сеть» даёт плагин @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-уведомления — ещё один канал общения с устройством при отсутствии активного экрана.