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







