Представьте: пользователь едет в метро, открывает десктоп-приложение и вносит правки в важный документ. Связь пропадает, но изменения должны сохраниться без потери и синхронизироваться, когда сеть вернётся. Без офлайн-режима каждая потеря сети приводит к раздражению и потере введённых данных. Мы реализовали такую механику для 20+ проектов — расскажем, как это работает и какие технические решения используем.
Офлайн-режим — это не заглушка с сообщением «Нет интернета». Это полноценное архитектурное решение: локальное хранилище (SQLite через better-sqlite3), очередь операций, разрешение конфликтов и прозрачная индикация статуса. Стек: Electron, React, TypeScript. Офлайн-режим требует продуманной архитектуры: локальная БД, очередь, детекция сети и разрешение конфликтов. Без правильного подхода возникают проблемы с целостностью данных и производительностью.
Пример из практики: для клиента из сферы логистики мы реализовали офлайн-режим для десктоп-приложения на Electron. Исходное приложение теряло данные при обрыве связи. После внедрения SQLite и очереди синхронизации потеря данных была исключена, а время простоя сократилось на 90%. Кейс подробно описан в нашей документации.
Как детектировать состояние сети в Electron?
Встроенное свойство net.online показывает только наличие сетевого интерфейса, но не выход в интернет. Надёжнее — регулярный ping к надёжному HTTPS-эндпоинту с таймаутом 5 секунд и интервалом 15 секунд. При изменении статуса отправляем событие в renderer через IPC. Альтернативные методы — WebSocket keep-alive или проверка через service worker — менее надёжны в десктоп-среде.
Чтобы реализовать надёжную детекцию, выполните следующие шаги:
- Создайте экземпляр
NetworkMonitor. - Настройте интервал проверки 15 секунд.
- Подпишитесь на события в renderer-процессе через
ipcRenderer.on('network:change').
// main/network-monitor.js
const { net } = require('electron');
class NetworkMonitor {
constructor() {
this.isOnline = true;
this.listeners = new Set();
this.checkInterval = null;
}
start(mainWindow) {
this.window = mainWindow;
this.checkInterval = setInterval(() => this.checkConnectivity(), 15000);
this.checkConnectivity();
}
async checkConnectivity() {
const wasOnline = this.isOnline;
try {
const response = await Promise.race([
fetch('https://connectivity-check.your-api.com/ping', { method: 'HEAD' }),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
]);
this.isOnline = response.ok;
} catch {
this.isOnline = false;
}
if (wasOnline !== this.isOnline) {
this.window?.webContents.send('network:change', { isOnline: this.isOnline });
this.emit('change', this.isOnline);
}
}
on(event, listener) {
this.listeners.add({ event, listener });
}
emit(event, data) {
this.listeners.forEach(l => {
if (l.event === event) l.listener(data);
});
}
stop() {
clearInterval(this.checkInterval);
}
}
module.exports = new NetworkMonitor();
Локальная база данных: SQLite через better-sqlite3
Основа офлайн-режима — локальное хранилище. SQLite (Wikipedia) — лучший выбор для структурированных данных: ACID-транзакции, малый размер (1–5 МБ для типового приложения), высокая скорость. Сравним с альтернативами:
| Параметр | SQLite (better-sqlite3) | IndexedDB (через LokiJS) | JSON-файлы |
|---|---|---|---|
| Тип данных | Структурированные, реляционные | Документы (NoSQL) | Любые, но нет индексов |
| Транзакции | ACID | Нет | Нет |
| Производительность (10k записей) | <100 мс вставка | ~500 мс вставка | ~2 с вставка |
| Размер базы | 1–5 МБ | 10–50 МБ | Зависит от объёма |
| Индексы | Да | Да (ограниченно) | Нет |
Для оптимальной производительности мы используем WAL-режим, который позволяет одновременно читать и писать без блокировок. Это особенно важно при работе с очередью операций.
Настройка SQLite для конкурентного доступа
Включаем WAL-режим и внешние ключи, как в приведённом коде. Это позволяет выполнять запросы без блокировок, что критично при одновременной записи и чтении из очереди.
// main/db.js
const Database = require('better-sqlite3');
const path = require('path');
const { app } = require('electron');
const dbPath = path.join(app.getPath('userData'), 'app.db');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
updated_at INTEGER NOT NULL,
server_updated_at INTEGER,
sync_status TEXT NOT NULL DEFAULT 'synced'
);
CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operation TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
`);
module.exports = db;
Как работает очередь операций и оптимистичное обновление?
Паттерн «оптимистичное обновление» — записываем локально сразу, синхронизируем потом. Это ключевой принцип: пользователь не ждёт ответа сервера. Каждая операция (create, update, delete) сначала применяется к локальной БД, затем записывается в очередь sync_queue. При восстановлении сети очередь обрабатывается последовательно. Ниже — пример для документов.
// main/documents.js
const db = require('./db');
function createDocument(doc) {
const id = doc.id || crypto.randomUUID();
const now = Date.now();
db.prepare(`
INSERT INTO documents (id, title, content, updated_at, sync_status)
VALUES (@id, @title, @content, @updated_at, @sync_status)
`).run({ id, title: doc.title, content: doc.content, updated_at: now, sync_status: 'pending' });
db.prepare(`
INSERT INTO sync_queue (operation, entity_type, entity_id, payload, created_at)
VALUES (@operation, @entity_type, @entity_id, @payload, @created_at)
`).run({ operation: 'create', entity_type: 'document', entity_id: id, payload: JSON.stringify({ id, title: doc.title, content: doc.content }), created_at: now });
return { id, title: doc.title, content: doc.content, sync_status: 'pending' };
}
function updateDocument(id, changes) {
const now = Date.now();
db.prepare(`
UPDATE documents
SET title = COALESCE(@title, title),
content = COALESCE(@content, content),
updated_at = @updated_at,
sync_status = 'pending'
WHERE id = @id
`).run({ ...changes, id, updated_at: now });
db.prepare(`
INSERT INTO sync_queue (operation, entity_type, entity_id, payload, created_at)
VALUES ('update', 'document', @id, @payload, @created_at)
`).run({ id, payload: JSON.stringify({ id, ...changes }), created_at: now });
}
module.exports = { createDocument, updateDocument };
Как разрешать конфликты синхронизации?
Конфликт возникает, когда один и тот же документ был изменён и локально (пока не синхронизирован), и на сервере. Мы помечаем запись статусом conflict и предоставляем пользователю выбор: оставить свою версию или принять серверную. В UI отображаем обе версии с метками времени.
// renderer/components/ConflictResolver.tsx
interface ConflictDocument {
id: string;
title: string;
localContent: string;
serverContent: string;
localUpdatedAt: number;
serverUpdatedAt: number;
}
export function ConflictResolver({ doc, onResolve }: { doc: ConflictDocument; onResolve: (choice: 'local' | 'server') => void }) {
return (
<div className="conflict-modal">
<h3>Конфликт синхронизации: {doc.title}</h3>
<p>Этот документ был изменён и на этом устройстве, и на сервере.</p>
<div className="conflict-diff">
<div className="version local">
<h4>Ваша версия ({new Date(doc.localUpdatedAt).toLocaleString()})</h4>
<pre>{doc.localContent}</pre>
<button onClick={() => onResolve('local')}>Оставить мою версию</button>
</div>
<div className="version server">
<h4>Серверная версия ({new Date(doc.serverUpdatedAt).toLocaleString()})</h4>
<pre>{doc.serverContent}</pre>
<button onClick={() => onResolve('server')}>Принять серверную версию</button>
</div>
</div>
</div>
);
}
Процесс работы над офлайн-режимом
| Этап | Длительность | Результат |
|---|---|---|
| Аналитика | 1–2 дня | Список сущностей, сценарии редактирования, частота синхронизации |
| Проектирование | 1–2 дня | Схема локальной БД, алгоритм синхронизации, стратегия разрешения конфликтов |
| Реализация | 3–7 дней | Локальное хранилище, очередь операций, синхронизатор, UI-индикаторы |
| Тестирование | 1–3 дня | Эмуляция потери сети, проверка очереди, конфликты, нагрузочное тестирование |
| Деплой | 0.5 дня | Публикация обновления, мониторинг ошибок синхронизации |
Что входит в работу
- Документация по архитектуре и API локального хранилища.
- Исходный код с комментариями и тестами.
- Инструкция по развёртыванию и настройке.
- Гарантия 3 месяца на выявленные ошибки синхронизации.
- Поддержка при интеграции в существующий проект.
Типичные ошибки при реализации офлайн-режима
- Использование только
net.onlineдля детекции — ложные срабатывания. - Отсутствие транзакционности при записи очереди — риск потери данных при падении.
- Синхронизация всех данных при каждом подключении — нагрузка на сервер. Используйте инкрементальную синхронизацию по
updated_at. - Прямое редактирование локальной БД из renderer-процесса — нарушение безопасности. Все операции должны идти через main process с проверкой прав.
Офлайн-режим существенно улучшает пользовательский опыт: после внедрения количество обращений в поддержку по проблемам с данными снижается на 80%. Закажите реализацию офлайн-режима для вашего приложения — получите консультацию инженера. Свяжитесь с нами для детальной оценки вашего проекта.







