Offline-First Desktop Apps with Electron

Why offline-first matters on desktop

Desktop users expect software to feel dependable. If a note-taking app, task manager, or internal business tool becomes half-broken the moment Wi-Fi drops, it feels less like a desktop app and more like a fragile website in a window.

Offline-first design flips the default assumption. Instead of treating the server as the only “real” source of state, the app treats local state as immediately usable and the network as a synchronization layer. That produces faster interactions, better resilience, and a much more polished user experience.

Offline-first does not mean “never talk to the server.” It means the app remains useful and trustworthy when the server or network is unavailable.

Core principles of an offline-first Electron app

1. Read locally first

Load screens from local data immediately, then refresh in the background when online.

2. Save locally first

User actions should succeed locally and be queued for sync instead of failing outright.

3. Persist pending changes

Unsynced operations must survive app restarts, crashes, and power loss.

4. Surface sync state

The UI should clearly communicate pending items, last sync time, and any conflicts.

Electron architecture for offline-first apps

Electron gives you a main process and one or more renderer processes. For offline-first behavior, a clean split helps:

  • Renderer: UI, local state display, user interaction.
  • Main process: durable storage access, sync jobs, file access, background tasks.
  • Preload bridge: a secure API that exposes only the operations the UI needs.

In small apps, it is tempting to keep everything in the renderer. That works at first, but sync logic, file access, and persistent queues are usually easier to manage in the main process. The renderer should ask for actions, not own the whole data pipeline.

// preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('offlineApi', {
  getNotes: () => ipcRenderer.invoke('notes:getAll'),
  createNote: (payload) => ipcRenderer.invoke('notes:create', payload),
  syncNow: () => ipcRenderer.invoke('sync:run'),
  getSyncStatus: () => ipcRenderer.invoke('sync:status'),
  onSyncStatus: (cb) => ipcRenderer.on('sync:statusChanged', (_e, data) => cb(data))
});

Choosing local storage

The best storage choice depends on the app’s complexity. For simple key-value settings, something lightweight is enough. For rich local data with queries and indexing, you want a real database.

electron-store / JSON files

Good for preferences, feature flags, small cached objects, and lightweight metadata.

SQLite

Excellent for notes, tasks, messages, queued mutations, search indexes, and relational data.

SQLite is often the sweet spot for Electron. It is fast, embedded, portable, and reliable. If your app needs offline search, histories, complex filters, or a sync queue, SQLite usually ages much better than plain JSON blobs.

A practical pattern is to store app data and sync queue entries in SQLite, while keeping user preferences in a small config store.

Build a durable sync queue

This is the heart of the system. When a user edits data offline, do not just keep that change in memory. Write it to local storage as a pending operation, then apply it to the visible local state. Later, when connectivity is available, replay the queued operations to the server.

A queue entry often includes:

  • Operation type, like create_note or update_task
  • Entity ID
  • Payload
  • Timestamp
  • Retry count
  • Status, such as pending, processing, failed, or synced
// main.js
const queue = [];

async function enqueueOperation(op) {
  // In a real app, persist this to SQLite instead of memory
  queue.push({
    id: crypto.randomUUID(),
    status: 'pending',
    retries: 0,
    createdAt: Date.now(),
    ...op
  });
}

async function processQueue(sendToServer) {
  for (const item of queue) {
    if (item.status !== 'pending' && item.status !== 'failed') continue;

    try {
      item.status = 'processing';
      await sendToServer(item);
      item.status = 'synced';
    } catch (err) {
      item.status = 'failed';
      item.retries += 1;
    }
  }
}

That example shows the idea, but in production the queue must live in persistent storage. If the app crashes after the user creates a task, the task still needs to exist locally and still needs to sync later.

Detect connectivity, but do not trust it too much

Browser-style online checks are useful, but they are not enough. A machine can have a network connection and still be unable to reach your API because of VPN issues, captive portals, expired auth, or backend outages.

A stronger approach is to combine:

  • Basic online/offline events for quick hints
  • Periodic health checks against your backend
  • Actual request success or failure as the final truth
function isProbablyOnline() {
  return navigator.onLine;
}

async function canReachApi() {
  try {
    const res = await fetch('https://api.example.com/health', {
      method: 'GET',
      cache: 'no-store'
    });
    return res.ok;
  } catch {
    return false;
  }
}

The app should not instantly switch into “all good” mode just because the OS says the machine is online. Let real API reachability drive sync behavior.

Plan for conflict resolution early

Conflicts happen when the same record changes in two places before sync completes. Even if your first version is simple, you need a policy.

Common approaches

  • Last write wins: easy to implement, but can overwrite useful work.
  • Field-level merge: safer for structured data where different fields change independently.
  • User-assisted resolution: best for critical data like notes, documents, or business records.

For many apps, a hybrid model is ideal: automatically merge safe cases, but flag text-heavy or high-value conflicts for the user to review.

Conflict resolution is not only a data problem. It is a product design problem. The UI must explain what happened in a way users can actually understand.

A practical example: offline notes in Electron

Suppose you are building a notes app. The user creates and edits notes whether online or offline. The architecture could look like this:

  1. The renderer asks the main process to create a note.
  2. The main process writes the note to SQLite immediately.
  3. The main process inserts a pending sync operation for that note.
  4. The renderer updates instantly from local data.
  5. A background sync worker attempts to upload pending changes when the API is reachable.
// renderer.js
async function createNote(title, body) {
  await window.offlineApi.createNote({ title, body });
  await refreshNotes();
}

// main.js
ipcMain.handle('notes:create', async (_event, payload) => {
  const note = {
    id: crypto.randomUUID(),
    title: payload.title,
    body: payload.body,
    updatedAt: Date.now(),
    syncState: 'pending'
  };

  await db.insertNote(note);
  await db.insertQueueItem({
    type: 'create_note',
    entityId: note.id,
    payload: note,
    status: 'pending'
  });

  broadcastSyncStatus();
  return note;
});

The key is that the note exists and is visible immediately, even before the server knows about it.

UX patterns that make offline-first feel polished

  • Show last synced time. Users want confidence, not mystery.
  • Mark pending items subtly. A small icon or badge is usually enough.
  • Offer manual sync. Users like control when something seems stuck.
  • Explain errors clearly. “Sync failed: auth expired” is better than “Unknown error.”
  • Keep the app usable during retries. Do not lock the entire UI because one request failed.

Great offline-first UX feels calm. The app should not panic every time the network flickers. Most of the time, the best experience is to accept the action locally, reflect it in the UI, and quietly handle the hard parts in the background.

How to test offline behavior properly

Offline support is one of those features that seems done until real users hit edge cases. Test it aggressively:

  • Start online, then disconnect during a save
  • Create ten or twenty local changes offline, then reconnect
  • Kill the app mid-sync and reopen it
  • Simulate expired authentication while offline changes are pending
  • Edit the same record from two devices to trigger conflicts

You should also test slow or flaky connections, not just total disconnection. A poor network often reveals more bugs than a fully offline laptop.

Common mistakes to avoid

Only caching reads

That helps browsing, but it does not make the app truly offline-capable if writes still fail.

Keeping pending writes in memory

If the queue is not durable, one restart can erase the user’s unsynced work.

Hiding sync state

Users need to know whether changes are synced, pending, or blocked by an error.

No conflict strategy

Ignoring conflicts early almost always creates confusing data loss later.