Web apps got used to treating the network as permanent. Desktop apps cannot afford that. An Electron app runs on laptops that go to conference basements, trains, airports, and customer sites with restricted Wi-Fi; if the screen goes blank the moment the signal drops, users assume the app itself is broken. The engineering job is not to pretend you are online forever — it is to decide, for every piece of data, where it lives when the network is gone, how it gets back in sync, and how old copies are retired after an upgrade. This guide covers the three state layers, the persistence options that actually ship in production, offline detection with a retry queue, cache invalidation across app upgrades, and the pitfalls that show up only after a release.
TL;DR
- Separate your state layers: bundled static assets (inside the asar), an HTTP/session cache for remote assets, and a durable local store for user data. Each has a different invalidation rule.
-
Persist user data in
userData, never inapp.getAppPath()or a temp dir. Pick a store by access pattern: key-value inelectron-store, relational data inbetter-sqlite3, renderer-side state in IndexedDB. -
Detect connectivity in main, forward
online/offlineevents to renderers, and queue writes that cannot complete now; flush the queue when connectivity returns with idempotent retries. -
Cache remote content through the session cache, set sensible
Cache-Controlon your API, and avoid storing mutable documents in the HTTP cache. - Invalidate on version change: wipe stale cached assets and run lightweight DB migrations when the app upgrades; never let a new version read a half-written old schema.
-
The two traps that cost me days: (1)
localStorage/IndexedDB are per-renderer and per-origin, and are cleared in some upgrade paths — do not treat them as your durable source of truth; (2) queued writes need an idempotency key, or replaying the queue doubles every mutation. ## Table of contents - Why a desktop app still needs an offline story
- The three state layers in an Electron app
- Static assets: bundle what ships, cache what you fetch
- Choosing a local persistence store
- Building the local store
- Detecting offline and queueing writes
- Caching remote content: session cache, headers, and service workers
- Cache invalidation across app upgrades
- Common pitfalls
10. Checklist
Why a desktop app still needs an offline story
Electron is Chromium plus Node, which makes it tempting to build the renderer like a single-page web app and let every action hit an API. In a browser that degrades gracefully because the tab reloads on the next connection. On the desktop, the user opens the same app all day; a dropped VPN, a flaky coffee-shop network, or an offline customer machine should not erase their working state.
Offline-first does not mean "store everything locally and sync later for every feature." It means classifying data up front:
- Bundled resources — your JS, CSS, icons, local images. They ship inside the installer and never depend on the network.
- Remote-but-cacheable content — public reference data, avatars, documentation, static screenshots. Safe to keep a stale copy and refresh in the background.
- User-generated, mutable data — notes, settings, uploads, edits. These need a durable local copy, an explicit sync path, and a conflict rule.
-
Ephemeral state — transient UI flags, in-memory scroll position. Keep these in memory only; adding them to disk just creates another source of stale truth.
Draw this map before you write a persistence layer. Most offline bugs come from putting a mutable document in the wrong layer.
---
## The three state layers in an Electron app
| Layer | Where it lives | Survives quit? | When to use | Invalidated by |
|---|---|---|---|---|
| Bundled assets | asar archive in the install dir | Yes | JS/CSS/fonts/icons that ship with the app | App upgrade / reinstall |
| HTTP / session cache | Chromium cache under
userData| Usually yes | Remote static GETs, images, public reference docs | Cache headers,session.clearCache(), version bump | | Durable local store | files inuserData, SQLite, IndexedDB | Yes | User settings, drafts, pending mutations, offline copy of remote records | Explicit migration / user action | | Renderer in-memory | JS heap of the page | No | Ephemeral UI state, caches rebuilt on load | Reload / window close | A recurring mistake is treating the HTTP cache as a database. The cache is a performance layer for immutable GETs; it has no transaction guarantees, no schema, and Chromium can evict it under storage pressure. Anything the user created or edited belongs in the durable store. --- ## Static assets: bundle what ships, cache what you fetch Your own code should be bundled into the asar so the app shell renders before any network call. That matters more on the desktop than on the web: a slow startup that waits on a CDN feels like a broken app, and your installer already contains the bytes. Remote assets you deliberately fetch at runtime — avatars, remote help docs, preview thumbnails — go through the session cache. Configure the session once in main before windows load:
// main process
import { session } from 'electron';
function configureSession() {
const ses = session.defaultSession;
// Keep the HTTP cache on disk so images survive a restart.
ses.setCacheSize(50 * 1024 * 1024); // 50 MB budget
// For remote assets you control, send explicit cache headers
// from your CDN/API; the renderer should not re-fetch unchanged files.
ses.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Cache-Control': ['public, max-age=3600, stale-while-revalidate=86400'],
},
});
});
}
Do not cache authenticated, mutable documents this way. If an endpoint returns a user's editable record, store that record in your durable store instead of relying on the browser cache to hold it.
Choosing a local persistence store
There is no one-size-fits-all store. Pick by access pattern, and be explicit about which process owns the data.
| Option | Best for | Lives in | Notes |
|---|---|---|---|
| electron-store (JSON file) | Settings, small key-value config, feature flags | Main process | Simple, synchronous, human-readable; not for high-volume writes or large blobs. |
| better-sqlite3 | Relational records, offline copies of lists, drafts, queryable history | Main process | Fast, embedded, transactional; a native module that must be unpacked from asar. |
| Plain fs files | Exports, logs, downloaded media, per-record documents | Main process | Full control over format and cleanup; you own concurrency and atomic writes. |
| IndexedDB | Renderer-side caches, framework state, large blobs in the web layer | Renderer | Per-origin; convenient for web code, but treat it as a cache unless you mirror to main. |
| localStorage | Tiny renderer preferences | Renderer | Synchronous, limited size, easy to clear; never a durable source of truth. |
Rule of thumb: the main process owns durable user data. Renderer stores are convenient but are tied to the web origin and can be wiped by storage-clearing actions; if the data matters, mirror it to a main-process store.
Building the local store
Key-value settings with electron-store
// main process
import Store from 'electron-store';
interface Settings {
theme: 'light' | 'dark' | 'system';
lastWorkspaceId: string | null;
telemetryEnabled: boolean;
}
const settings = new Store<Settings>({
name: 'settings',
defaults: {
theme: 'system',
lastWorkspaceId: null,
telemetryEnabled: true,
},
});
settings.set('lastWorkspaceId', 'ws_1024');
const theme = settings.get('theme');
The file lands in app.getPath('userData'), which is per-user and survives upgrades. Never write settings next to the app executable: that directory is often read-only under Program Files or /Applications.
Relational offline data with SQLite
For lists the user browses offline — synced records, drafts, a local copy of a table — an embedded database beats JSON files once you need filtering, updates, or transactions.
// main process
import Database from 'better-sqlite3';
import { app } from 'electron';
import * as path from 'path';
const dbPath = path.join(app.getPath('userData'), 'local.db');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS records (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
updated_at INTEGER NOT NULL,
synced INTEGER NOT NULL DEFAULT 0
)
`);
const insertOrReplace = db.prepare(
'INSERT OR REPLACE INTO records (id, title, body, updated_at, synced) VALUES (?, ?, ?, ?, ?)'
);
function saveRecord(rec: { id: string; title: string; body: string; updated_at: number; synced: number }) {
insertOrReplace.run(rec.id, rec.title, rec.body, rec.updated_at, rec.synced);
}
better-sqlite3 is a native addon. If it works in dev but crashes after packaging, it was not unpacked from the asar — add it to asarUnpack in your build config (see the packaging guide for that half).
Renderer-side IndexedDB
For state that lives naturally in the web layer — a framework's offline cache, image blobs, draft buffers — IndexedDB is the right tool. Keep it as a renderer cache, and mirror anything critical to main through IPC.
// renderer
const request = indexedDB.open('app-cache', 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('drafts')) {
db.createObjectStore('drafts', { keyPath: 'id' });
}
};
Detecting offline and queueing writes
The renderer's navigator.onLine and online/offline events are a signal, not a guarantee. In Electron, centralize connectivity detection in main and broadcast the state so every renderer agrees.
// main process
import { app, BrowserWindow, ipcMain, net } from 'electron';
let online = true;
function broadcastConnectivity(win: BrowserWindow) {
win.webContents.send('app:connectivity', online);
}
app.whenReady().then(() => {
const win = new BrowserWindow({ width: 1280, height: 800 });
// Poll reachability rather than trusting navigator.onLine alone.
setInterval(async () => {
const wasOnline = online;
online = await checkReachable();
if (online !== wasOnline) broadcastConnectivity(win);
}, 10_000);
ipcMain.handle('app:enqueue-mutation', async (_e, mutation) => {
if (online) {
await flushMutation(mutation);
} else {
queue.push(mutation); // persisted to SQLite, see below
}
});
});
async function checkReachable(): Promise<boolean> {
try {
const res = await net.fetch('https://api.example.com/health', { method: 'HEAD' });
return res.ok;
} catch {
return false;
}
}
The pending-write queue
Writes made offline must survive a quit. Persist the queue in SQLite (or a JSON file) rather than memory, and give every mutation an idempotency key so a replay cannot double-apply.
// queue table
db.exec(`
CREATE TABLE IF NOT EXISTS pending_queue (
id TEXT PRIMARY KEY, -- idempotency key
method TEXT NOT NULL,
endpoint TEXT NOT NULL,
payload TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)
`);
async function flushQueue() {
const pending = db.prepare('SELECT * FROM pending_queue ORDER BY created_at ASC').all();
for (const item of pending) {
try {
await net.fetch(item.endpoint, {
method: item.method,
body: item.payload,
headers: { 'Idempotency-Key': item.id },
});
db.prepare('DELETE FROM pending_queue WHERE id = ?').run(item.id);
} catch (err) {
db.prepare('UPDATE pending_queue SET attempts = attempts + 1 WHERE id = ?').run(item.id);
break; // stop on first failure; retry on next connectivity event
}
}
}
Conflict resolution is a product decision, not a database one: last-write-wins by timestamp, per-field merge, or a manual review queue. Pick one and document it before users start editing the same record on two machines.
Caching remote content: session cache, headers, and service workers
Session cache
Chromium's session cache is on by default and stores GET responses under userData. You can inspect or clear it explicitly:
const ses = session.defaultSession;
await ses.clearCache(); // wipe cached remote assets on demand
Clear the cache deliberately after a major version bump or when you know a remote asset contract changed; otherwise let cache headers do the work.
Cache headers
The fastest, most maintainable caching policy lives on the server. Versioned static assets get long max-age with a content hash in the filename; mutable API responses get no-store or a short max-age. Trying to reproduce that policy inside the renderer with manual "is this fresh?" checks is where most over-engineered cache bugs live.
Service workers in Electron
Service workers work in Electron renderers the same way they do in a browser, and can cache static shell assets offline. Two caveats: a service worker is scoped to the renderer origin and does not share storage with the main process; and for a packaged app whose shell ships in the asar, a SW mostly helps when you load remote web content. If your app is fully bundled, the session cache plus a local store usually covers the offline case without a SW.
Cache invalidation across app upgrades
The most embarrassing offline bug is a new version reading a stale, incompatible copy of old data. Treat upgrades as a first-class invalidation event.
// main process, after app.whenReady()
import { app } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
const STORE_VERSION = 3;
function migrateIfNeeded() {
const metaPath = path.join(app.getPath('userData'), 'store-version.json');
let current = 0;
if (fs.existsSync(metaPath)) {
current = JSON.parse(fs.readFileSync(metaPath, 'utf8')).version ?? 0;
}
if (current < STORE_VERSION) {
// Drop cached remote assets; keep durable user data and migrate schema.
session.defaultSession.clearCache();
// Run per-version schema migrations here (SQLite ALTER, JSON reshaping).
fs.writeFileSync(metaPath, JSON.stringify({ version: STORE_VERSION }));
}
}
Rules that hold up in production:
- Bundled assets are replaced by the installer; do not keep an old cached copy of the shell alongside the new version.
- Cached remote assets can be cleared on upgrade; they are re-fetched when online.
- User data is never wiped just because the version changed. Migrate it forward, keep a backup copy if a migration is destructive, and let the user export before a risky change.
Common pitfalls
| Pitfall | What you see | Fix |
|---|---|---|
| App blank on first offline launch | Shell waits on a remote CDN and never renders | Bundle the app shell in the asar; render local UI before any network call. |
localStorage/IndexedDB data missing after update |
Renderer cache was cleared on upgrade | Mirror durable data to a main-process store; treat renderer storage as a cache. |
| Replayed queue duplicates mutations | Offline edits appear twice after reconnect | Add an idempotency key per mutation; send it as a header and dedupe server-side. |
| SQLite native module crashes only packaged | Works in dev, dies after install | Add the module to asarUnpack; verify the ABI matches the Electron version. |
| Stale remote image after a deploy | Users see old avatars/docs | Use content-hashed filenames; set short max-age on mutable endpoints. |
| Settings written next to the exe | Lost after install, or read-only on Windows/macOS | Always use app.getPath('userData'). |
navigator.onLine lies |
App thinks it is offline while requests succeed (or vice versa) | Poll a health endpoint in main; broadcast a single connectivity state. |
| Cache grows unbounded |
userData balloons over months |
Set a session cache size budget; evict oldest cached blobs; prune old exports/logs. |
Checklist
- [ ] Classify every data item as bundled, cacheable remote, durable user data, or ephemeral.
- [ ] Confirm the app shell renders from bundled assets with the network unplugged.
- [ ] Pick a durable store per access pattern; keep user data in the main process under
userData. - [ ] Centralize connectivity detection in main and broadcast one
online/offlinestate to renderers. - [ ] Persist offline mutations to a durable queue with an idempotency key; flush on reconnect with backoff.
- [ ] Define and document a conflict-resolution rule for records edited on multiple devices.
- [ ] Set cache headers on the API; do not hand-roll freshness checks for immutable GETs.
- [ ] Add a store-version migration step that clears stale caches and migrates user data forward on upgrade.
- [ ] Set a cache-size budget and a cleanup path for old blobs, exports, and logs.
- [ ] Test the offline path: unplug the network, make edits, restart the app, reconnect, and verify the queue flushes once without duplicates. Offline support in Electron is mostly discipline: know which layer each piece of data belongs to, keep the durable copy in main, and make the upgrade path explicit. Once the shell renders locally, writes queue safely, and stale caches retire cleanly, a dropped connection stops looking like a broken app — it just looks like the app is waiting for the network to catch up.
Top comments (0)