Obiora Igboanusi
Back to writing
February 18, 20262 min read

Designing an Offline-First Desktop App That Syncs Without Losing Data

  • Offline-first
  • TypeScript
  • SQLite
  • Sync

Some software gets to assume the network is there. Field operations software doesn't. The incident and case management application I architected had a hard requirement: operators must be able to do their entire job with zero connectivity, and nothing they enter can ever be silently lost.

That requirement changes everything about how you design the data layer.

Local-first means the local database is the source of truth

The app runs on React + TypeScript with a local SQLite database managed through TypeORM. Every read and every write goes to SQLite — the UI never waits on the network, ever. Sync is a background concern the operator can observe but never blocks on.

Each mutable row carries sync metadata:

@Entity()
class Incident {
  @PrimaryColumn("uuid")
  id: string; // UUIDs, because offline clients can't ask a server for IDs

  @Column()
  updatedAt: Date;

  @Column({ default: "pending" })
  syncStatus: "pending" | "synced" | "failed" | "conflict";

  @Column({ nullable: true })
  serverVersion: number; // last version we saw from the server
}

Two non-negotiables baked in here:

  • Client-generated UUIDs. Auto-increment IDs require a round-trip to the server, which offline clients can't make.
  • A visible syncStatus on every record. Operators can always see what has and hasn't reached the server.

The sync service is a queue, not a mirror

A background service drains an outbox of pending operations whenever connectivity returns:

Outbox: [create incident A] → [update incident A] → [attach evidence B]
           │ sent in order, acknowledged individually
           ▼
        Server API (idempotent endpoints, versioned records)

Operations are sent in order and acknowledged individually. A failure doesn't poison the queue — the failed operation is marked, surfaced in the UI, and the operator gets explicit recovery actions: retry, edit and retry, or discard.

Conflicts are a UX problem, not just a data problem

When a record changed both locally and on the server, the app doesn't guess. Detection is simple version comparison:

if (local.serverVersion !== remote.version) {
  markConflict(local, remote); // both versions preserved
}

Resolution is human. The operator sees both versions side by side — field by field — and chooses what wins. For incident data with legal and audit consequences, "last write wins" is not a strategy; it's data loss with extra steps.

What made it hold up in production

  1. The UI never lies. Sync state is always visible; there's no fake optimism about data being "saved to the cloud" when it isn't.
  2. Every server endpoint is idempotent. The sync service can safely resend anything it isn't sure about.
  3. Failed operations are first-class objects with their own screens, not log lines someone might read later.

Offline-first is more work up front — outboxes, versioning, conflict UIs. But the alternative is an app that fails exactly when your users need it most.