Представьте: пользователь едет в метро, открывает десктоп-приложение и вносит правки в важный документ. Связь пропадает, но изменения должны сохраниться без потери и синхронизироваться, когда сеть вернётся. Без офлайн-режима каждая потеря сети приводит к раздражению и потере введённых данных. Мы реализовали такую механику для 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%. Закажите реализацию офлайн-режима для вашего приложения — получите консультацию инженера. Свяжитесь с нами для детальной оценки вашего проекта.







