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







