Оффлайн-режим и отложенная синхронизация

Оффлайн-режим в современных веб-приложениях опирается на локальное хранилище браузера, позволяющее сохранять данные без постоянного доступа к сети. В экосистеме JavaScript наиболее структурированным способом работы с IndexedDB выступает Dexie.js, предоставляющая удобный слой абстракции над низкоуровневым API.

Ключевая задача оффлайн-архитектуры заключается в обеспечении непрерывности пользовательского опыта при отсутствии соединения и последующей корректной синхронизации изменений при восстановлении сети. Dexie.js выступает центральным элементом такого подхода благодаря поддержке транзакций, индексов и асинхронных операций.


Базовая модель оффлайн-данных

Оффлайн-режим строится вокруг локальной базы данных, в которой хранится вся необходимая информация для работы приложения без сети. Dexie.js позволяет описывать структуру данных декларативно:

import Dexie fr om "dexie";

const db = new Dexie("AppDatabase");

db.version(1).stores({
  notes: "++id, title, updatedAt, isSynced",
  outbox: "++id, type, payload, createdAt, status"
});

В данной структуре:

  • notes — основная таблица пользовательских данных
  • outbox — очередь действий для последующей синхронизации
  • isSynced — флаг синхронизации состояния записи

Такое разделение формирует основу паттерна Offline First + Outbox.


Паттерн Outbox для отложенной синхронизации

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

Пример добавления записи:

async function createNote(title) {
  const timestamp = Date.now();

  await db.transaction("rw", db.notes, db.outbox, async () => {
    const id = await db.notes.add({
      title,
      updatedAt: timestamp,
      isSynced: false
    });

    await db.outbox.add({
      type: "CREATE_NOTE",
      payload: { id, title, updatedAt: timestamp },
      createdAt: timestamp,
      status: "pending"
    });
  });
}

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


Очередь синхронизации и обработка событий

Обработка outbox-очереди выполняется отдельным процессом, который активируется при появлении сети или по таймеру.

async function processOutbox() {
  const pendingItems = await db.outbox
    .wh ere("status")
    .equals("pending")
    .toArray();

  for (const item of pendingItems) {
    try {
      await sendToServer(item);

      await db.outbox.update(item.id, {
        status: "sent"
      });

      await markAsSynced(item);
    } catch (e) {
      await db.outbox.update(item.id, {
        status: "failed"
      });
    }
  }
}

Функция sendToServer инкапсулирует транспортный слой (fetch, WebSocket или GraphQL-mutation).


Определение состояния синхронизации

Контроль состояния данных критичен для оффлайн-режима. Dexie.js позволяет хранить метаинформацию о каждой записи:

  • локально созданные
  • изменённые оффлайн
  • ожидающие отправки
  • успешно синхронизированные

Пример обновления статуса:

async function markAsSynced(outboxItem) {
  const { id, payload } = outboxItem;

  await db.notes.update(payload.id, {
    isSynced: true
  });
}

Расширенная модель может включать версии объектов:

db.version(2).stores({
  notes: "++id, title, updatedAt, version, isSynced"
});

Версионирование позволяет реализовать контроль конфликтов.


Обнаружение изменения сетевого состояния

Работа оффлайн-синхронизации тесно связана с событиями браузера:

window.addEventListener("online", () => {
  processOutbox();
});

window.addEventListener("offline", () => {
  console.log("offline mode active");
});

Однако наличие сети не гарантирует доступность backend-сервера, поэтому дополнительно применяется проверка “heartbeat”.


Service Worker как слой буферизации

Service Worker используется для перехвата сетевых запросов и обеспечения устойчивости при нестабильном соединении.

self.addEventListener("fetch", event => {
  event.respondWith(
    fetch(event.request).catch(async () => {
      const cached = await caches.match(event.request);
      return cached;
    })
  );
});

Dexie.js в этом случае выступает как долговременное хранилище состояния, а Service Worker — как промежуточный кэш сетевого уровня.


Стратегии синхронизации

1. Last Write Wins

Простейшая стратегия, при которой последняя версия данных перезаписывает предыдущую.

function resolveConflict(local, remote) {
  return local.updatedAt > remote.updatedAt ? local : remote;
}

Подходит для некритичных данных.


2. Version Vector

Каждая запись содержит версию, увеличиваемую при каждом изменении.

await db.notes.update(id, {
  version: Dexie.increment(1)
});

Конфликты разрешаются сравнением версий.


3. Server Reconciliation

Сервер выступает арбитром состояния, возвращая финальную версию объекта.


Реализация двухсторонней синхронизации

Синхронизация включает два направления:

  • локальные изменения → сервер
  • серверные изменения → локальная база

Отправка локальных изменений

async function pushChanges() {
  const changes = await db.outbox.where("status").equals("pending");

  for (const change of await changes.toArray()) {
    await fetch("/sync", {
      method: "POST",
      body: JSON.stringify(change)
    });
  }
}

Получение изменений с сервера

async function pullChanges() {
  const response = await fetch("/sync/updates");
  const serverData = await response.json();

  await db.notes.bulkPut(serverData.notes);
}

Оптимизация синхронизации

Батчинг операций

Снижение количества запросов достигается группировкой событий:

const batch = await db.outbox
  .where("status")
  .equals("pending")
  .limit(50)
  .toArray();

Дедупликация событий

await db.outbox
  .where({ type: "UPDATE_NOTE", "payload.id": id })
  .delete();

Ограничение частоты синхронизации

Использование debounce:

let syncTimeout;

function scheduleSync() {
  clearTimeout(syncTimeout);
  syncTimeout = setTimeout(processOutbox, 3000);
}

Индексы и производительность

Dexie.js позволяет эффективно индексировать поля, критичные для синхронизации:

db.version(3).stores({
  notes: "++id, updatedAt, isSynced, version",
  outbox: "++id, status, createdAt"
});

Индексация status ускоряет выборку очереди синхронизации, а updatedAt упрощает сортировку изменений.


Конфликты данных и их обработка

Конфликт возникает при одновременном изменении одной сущности локально и на сервере.

Типовой алгоритм:

  1. получение серверной версии
  2. сравнение с локальной
  3. применение стратегии разрешения
  4. запись финального состояния
async function resolveNoteConflict(local, remote) {
  if (local.version === remote.version) {
    return remote;
  }

  return local.updatedAt > remote.updatedAt ? local : remote;
}

Локальная очередь как источник истины

Outbox может рассматриваться как журнал событий (event log). Такой подход позволяет восстанавливать состояние приложения:

async function rebuildState() {
  const events = await db.outbox.orderBy("createdAt").toArray();

  let state = {};

  for (const event of events) {
    state = applyEvent(state, event);
  }

  return state;
}

Частично оффлайн-архитектура

Некоторые данные могут оставаться только локальными, не участвуя в синхронизации. Dexie.js поддерживает разделение:

  • синхронизируемые таблицы
  • локальные кэши
  • временные данные UI
db.version(4).stores({
  notes: "++id, updatedAt, isSynced",
  uiCache: "key",
  tempData: "++id"
});

Работа с большими объемами данных

При росте локальной базы важны стратегии оптимизации:

  • пагинация через offset/limit
  • использование where вместо полного сканирования
  • удаление устаревших синхронизированных записей
await db.outbox
  .where("status")
  .equals("sent")
  .delete();

Интеграция с реактивными фреймворками

Dexie.js поддерживает наблюдаемые коллекции через liveQuery:

import { liveQuery } from "dexie";

const liveNotes = liveQuery(() =>
  db.notes.orderBy("updatedAt").toArray()
);

Это позволяет автоматически обновлять UI при изменении локальных данных, включая оффлайн-операции.


Безопасность и целостность данных

При оффлайн-синхронизации критично учитывать:

  • проверку целостности payload
  • валидацию перед отправкой
  • идемпотентность серверных операций
async function safeSend(item) {
  if (!item.payload || !item.type) return;

  return fetch("/sync", {
    method: "POST",
    headers: { "Idempotency-Key": item.id },
    body: JSON.stringify(item)
  });
}

Гибридные сценарии синхронизации

В сложных системах применяется комбинация стратегий:

  • периодическая синхронизация (polling)
  • push-уведомления от сервера
  • ручная синхронизация при восстановлении сети
  • фоновые задачи через Service Worker

Dexie.js в таких сценариях выступает как единый слой локального состояния, объединяющий все потоки данных в консистентную модель.