DEV Community

Cover image for Building Offline-First Desktop Software in 2026
Christian • ancer
Christian • ancer

Posted on

Building Offline-First Desktop Software in 2026

Every new app is drawn with a server in it.

Accounts, an API, sync, a subscription tier. It gets decided before anyone asks the question that actually matters: where will this run, and who is standing in front of it?

For the last few months I've been building a Windows desktop app called Rueda de Actos. It's a custom tool for the Filà Ligeros, one of the groups that take part in the Moros y Cristianos festival in Alcoy, Spain. Its job is to hand out participation in five festival events, eleven spots each, following a rotating turn that has to stay fair year after year.

It makes zero network calls. Not one. No accounts, no sync, no telemetry, no update check.

That isn't nostalgia and it wasn't a shortcut. It's the answer the context gave.

The room decides the architecture

Software gets used somewhere, and that somewhere usually has more to say about your stack than any benchmark does.

Here is the somewhere: a room, a projector, the whole board sitting around a table, and one person driving the app while everyone watches the screen. It happens once a year. The wifi in that building is whatever it happens to be that night.

Run a cloud app against that scenario and count the failure modes:

  • The connection drops on the one night of the year the tool is needed.
  • Members' personal data leaves the building to sit on somebody else's server.
  • The tool stops working the day a subscription lapses, or the day I stop maintaining it.

None of those are exotic. They are the ordinary price of having a server, and normally you pay it gladly, because you get sync, multi-user access, remote access and painless updates in return. In this room you get none of that back. There is one user, one machine, and one session a year.

So the server came out. What's left is a database file on the computer of the person running the meeting.

The stack

Layer Choice
Shell Electron 31
UI Vue 3 (Composition API) + Bootstrap 5
Data SQLite via better-sqlite3
Build Vite
Tests Vitest
Packaging electron-builder → NSIS installer
Logging electron-log, to a local file

Nothing exotic. The interesting part is what the renderer is allowed to do. It runs with nodeIntegration off and contextIsolation on, so its entire access to the machine is whatever the preload script decides to hand it:

// electron/preload.js
contextBridge.exposeInMainWorld('api', {
  getPersonas: () => ipcRenderer.invoke('db:getPersonas'),
  savePersonas: (data) => ipcRenderer.invoke('db:savePersonas', data),
  importXLSX: (path) => ipcRenderer.invoke('import:xlsx', path),
  exportJSON: (data, path) => ipcRenderer.invoke('export:json', data, path),
  // ...
})
Enter fullscreen mode Exit fullscreen mode

The UI is a web app that cannot touch the filesystem or the database. It can call the handful of functions the preload hands it, and nothing else. Every one of them is validated on the other side before it reaches SQLite. Removing the server does not mean removing the boundary — it means the boundary moved inside the process.

What disappears when you delete the server

  • Accounts and authentication. Physical access to the laptop is the authentication.
  • Sync and conflict resolution. There is one writer. There is never a merge.
  • Recurring cost. Nothing to pay, nothing to renew, nothing to migrate when a provider changes its pricing.
  • Attack surface. No listening port, no endpoint, no token to leak, no dependency on my uptime.
  • The privacy conversation. Not because it was answered well, but because it stopped applying.

That last one was the part the client actually felt. "Where do the names end up?" — "On your computer. They never leave it."

Three problems move in instead

Deleting the server doesn't simplify the project. It relocates the difficulty.

1. Backups are your job now

There is no nightly dump on somebody else's infrastructure. So the app makes its own: a full SQLite copy every 30 minutes, keeping the last ten.

function performBackup() {
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
  database.backupToFile(path.join(backupDir, `rueda-ligeros-${timestamp}.db`))
  rotate(backupDir, MAX_BACKUPS)   // newest ten stay, the rest are unlinked
}

performBackup()
setInterval(performBackup, 30 * 60 * 1000)
Enter fullscreen mode Exit fullscreen mode

better-sqlite3 exposes SQLite's own online backup API, so this is a consistent snapshot rather than a file copy taken mid-write.

Worth being honest about what this protects against: those copies live on the same disk as the original. They save you from "I just broke the data", not from "the laptop died". The second one has a different answer, further down.

2. Migrations run on a machine you will never see

There is no maintenance window, no staging environment, no rollback button, and nobody to call. The user double-clicks the new version and the schema has to be correct by the time the window appears.

So the schema is versioned, and the app migrates itself on startup:

function runMigrations(db, log) {
  const currentVersion = getSchemaVersion(db)   // from a metadata table
  const pending = migrations.filter(m => m.version > currentVersion)

  for (const migration of pending) {
    db.transaction(() => {
      migration.up(db)
      setSchemaVersion(db, migration.version)
    })()
    log.info(`Migration v${migration.version} applied: ${migration.description}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

Each migration is a { version, description, up } object in an ordered array, each one runs inside a transaction, and a throw stops the chain instead of leaving the database half-converted.

That structure earned its keep on migration 3, which normalised the event assignments out of the personas table into a persona_actos child table. SQLite's answer to dropping columns is create-copy-drop-rename, which is well documented and looks harmless:

  1. Create the new child table.
  2. Read the old assignments.
  3. Rebuild the parent table without the old columns.
  4. Insert the assignments into the child table.

Get steps 3 and 4 the wrong way round and you lose data silently. With foreign_keys = ON, the DROP TABLE personas in step 3 fires an implicit DELETE, which cascades through ON DELETE CASCADE and empties the child table you just carefully filled. No error. No warning. An empty table, and a version number claiming the migration succeeded.

The comment explaining that ordering is now four lines long and sits directly above the code. When a migration runs on a stranger's machine, in a building you're not in, the day before the tool is needed, there is no second attempt.

3. Distribution is a person carrying a file

No auto-update. Not even a version check — a version check is a network call, and the whole premise was that there aren't any. The electron-builder config has no publish block and never will: there is nowhere to publish to, and nothing to check against.

New version means a new installer, run over the old one, data preserved. That is the entire update story.

And then there's SmartScreen. The installer isn't code-signed, so Windows greets a non-technical user with a blue full-screen panel that says "Windows protected your PC" and hides the button that runs it anyway behind a More info link. To a developer that's an inconvenience. To the person installing it, that dialog reads as "this is a virus", and the software has failed before it ever opened.

There are two options: buy a certificate, or explain. A code-signing certificate is a recurring cost, and even with one, SmartScreen reputation takes downloads to accumulate — which an app with a single-digit user count will never get. So the build script is written to sign automatically the day a certificate exists, and until then the answer is documentation: one line in the install guide, and a heads-up to the person who will click the button, before they click it.

It's an unsatisfying answer. It's also the honest one, and pretending the dialog doesn't exist would have been worse.

The data has to be able to leave

With no server, there is no "your data is safe with us" to offer. What you can offer instead is that nothing is trapped.

Everything imports and exports in both directions, as XLSX and as JSON. Import accepts the spreadsheet the group was already keeping — a hand-made one is fine, as long as there's a name column. Export writes a file that goes on a USB stick, into an email, or onto another machine.

That's also the real backup story. The half-hourly copies protect the session; the export is the one that survives the hard drive. And it's the answer to the question every custom-software client should ask and rarely does: what happens to us if you disappear? The data is a spreadsheet. Somebody else can pick it up.

Testing replaces the operations you don't have

No error tracking, no logs streaming back to me, no ability to hotfix anything. Whatever ships is what runs until somebody tells me otherwise.

What's left is testing before the fact: close to 400 tests covering the turn rotation, the eligibility rules, validation, the migrations and the import/export round trip, running on every build of the installer. Plus electron-log writing to a local file, so that when something does go wrong there's something concrete to ask the user for.

That trade is easy to underestimate. Server-side, you can be wrong for twenty minutes. Here, being wrong means being wrong for a year.

Offline-first isn't a step back

The reflex is to read a local desktop app as what we used to do before we knew better. Sometimes that's exactly what it is. Here it isn't.

Everything the cloud would have added to this project — sync, accounts, remote access, seamless updates — is worth nothing in a room with one machine and one session a year. Everything it would have introduced — a connection that can fail, a bill that can lapse, personal data on somebody else's disk — lands squarely on the night it matters most.

Picking an architecture is not picking the most current option. It's picking the one that still works under the real conditions of use.

The wifi in that room can do whatever it likes. The app opens.

Top comments (0)